memleaf 0.2.7__tar.gz → 0.2.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {memleaf-0.2.7 → memleaf-0.2.9}/CHANGELOG.md +24 -0
  2. {memleaf-0.2.7/src/memleaf.egg-info → memleaf-0.2.9}/PKG-INFO +3 -3
  3. {memleaf-0.2.7 → memleaf-0.2.9}/README.en.md +2 -2
  4. {memleaf-0.2.7 → memleaf-0.2.9}/README.md +2 -2
  5. {memleaf-0.2.7 → memleaf-0.2.9}/pyproject.toml +1 -1
  6. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/hermes_provider/__init__.py +3 -0
  8. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  9. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/processing.py +251 -25
  10. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/prompts.py +63 -0
  11. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/scope_state.py +14 -2
  12. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/validation.py +121 -2
  13. {memleaf-0.2.7 → memleaf-0.2.9/src/memleaf.egg-info}/PKG-INFO +3 -3
  14. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_admission_noise.py +9 -11
  15. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_cross_host_acceptance.py +4 -1
  16. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_hermes_provider.py +19 -0
  17. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_maintenance_v2.py +35 -20
  18. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_pypi_install.py +1 -1
  19. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_session_lineage.py +2 -2
  20. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b1.py +143 -3
  21. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b2a.py +467 -16
  22. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b2b.py +19 -5
  23. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3b_native_context.py +6 -6
  24. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3b_scope.py +29 -8
  25. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3d_scope_maintenance.py +11 -2
  26. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_c1_mcp.py +1 -1
  27. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_c3_packaging.py +1 -1
  28. {memleaf-0.2.7 → memleaf-0.2.9}/IMPLEMENTATION_PLAN.md +0 -0
  29. {memleaf-0.2.7 → memleaf-0.2.9}/LICENSE +0 -0
  30. {memleaf-0.2.7 → memleaf-0.2.9}/MANIFEST.in +0 -0
  31. {memleaf-0.2.7 → memleaf-0.2.9}/RELEASE_CHECKLIST.md +0 -0
  32. {memleaf-0.2.7 → memleaf-0.2.9}/examples/README.md +0 -0
  33. {memleaf-0.2.7 → memleaf-0.2.9}/examples/basic_usage.py +0 -0
  34. {memleaf-0.2.7 → memleaf-0.2.9}/examples/mcp_stdio.ndjson +0 -0
  35. {memleaf-0.2.7 → memleaf-0.2.9}/install.ps1 +0 -0
  36. {memleaf-0.2.7 → memleaf-0.2.9}/install.sh +0 -0
  37. {memleaf-0.2.7 → memleaf-0.2.9}/setup.cfg +0 -0
  38. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/__main__.py +0 -0
  39. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/adapters/__init__.py +0 -0
  40. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/adapters/antigravity.py +0 -0
  41. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/adapters/base.py +0 -0
  42. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/adapters/codex.py +0 -0
  43. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/adapters/hermes.py +0 -0
  44. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/budget.py +0 -0
  45. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/capture.py +0 -0
  46. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/cli.py +0 -0
  47. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/compaction.py +0 -0
  48. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/config.py +0 -0
  49. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/credentials.py +0 -0
  50. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/frontmatter.py +0 -0
  51. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/hermes_provider/README.md +0 -0
  52. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/host_events.py +0 -0
  53. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/host_runtime.py +0 -0
  54. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/inbox.py +0 -0
  55. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/index.py +0 -0
  56. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/installer.py +0 -0
  57. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/llm/__init__.py +0 -0
  58. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/llm/base.py +0 -0
  59. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/llm/claude_compatible.py +0 -0
  60. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/llm/gemini.py +0 -0
  61. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/llm/openai_compatible.py +0 -0
  62. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/llm/router.py +0 -0
  63. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/locking.py +0 -0
  64. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/mcp_server.py +0 -0
  65. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/memory_writer.py +0 -0
  66. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/model_discovery.py +0 -0
  67. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/models.py +0 -0
  68. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/native_index.py +0 -0
  69. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/redaction.py +0 -0
  70. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/retrieval.py +0 -0
  71. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/retrieval_gate.py +0 -0
  72. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/scope_maintenance.py +0 -0
  73. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/service.py +0 -0
  74. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/vault.py +0 -0
  75. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf.egg-info/SOURCES.txt +0 -0
  76. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf.egg-info/dependency_links.txt +0 -0
  77. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf.egg-info/entry_points.txt +0 -0
  78. {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf.egg-info/top_level.txt +0 -0
  79. {memleaf-0.2.7 → memleaf-0.2.9}/tests/__init__.py +0 -0
  80. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_codex_install.py +0 -0
  81. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_codex_native_cli.py +0 -0
  82. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_context_budget.py +0 -0
  83. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_credential_safety.py +0 -0
  84. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_hermes_stdio_transport.py +0 -0
  85. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_host_events.py +0 -0
  86. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_host_runtime_contract.py +0 -0
  87. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_install.py +0 -0
  88. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_model_discovery.py +0 -0
  89. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_retrieval_gate.py +0 -0
  90. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_retrieval_v2.py +0 -0
  91. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_a.py +0 -0
  92. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3a_commit.py +0 -0
  93. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3a_contract.py +0 -0
  94. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3b_native_index.py +0 -0
  95. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3c_retrieval.py +0 -0
  96. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,30 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.9 — 2026-09-01
6
+
7
+ - Reject update and duplicate targets that are relevant only to another item
8
+ in the same aggregate turn, preventing cross-project memory overwrites.
9
+ - Retry an unrelated gate target with an explicit correction, then recover
10
+ deterministically after the bounded final attempt: preserve unrelated
11
+ updates as independent candidates and discard unsafe duplicate claims.
12
+ - Query related memories from each candidate's own complete topic instead of
13
+ the whole mailbox turn, while allowing the remaining valid candidates to be
14
+ processed without holding the retained inbox turn hostage.
15
+
16
+ ## 0.2.8 — 2026-09-01
17
+
18
+ - Let the automatic summarizer independently reject an over-admitted gate
19
+ candidate with an exact `NO_CHANGE` response, without writing memory,
20
+ history, or Scope state; explicit remember remains write-oriented and does
21
+ not permit this response.
22
+ - Ground every model-attributed project Scope in the candidate's own project
23
+ name or registered alias, and prevent the summarizer from drifting away
24
+ from the gate-selected Scope.
25
+ - Register session and Vault Scopes only for committed memories or trusted
26
+ active duplicate targets, so temporary, deferred, and no-change mailbox
27
+ items cannot pollute Scope state.
28
+
5
29
  ## 0.2.7 — 2026-09-01
6
30
 
7
31
  - 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.9
4
4
  Summary: A local-first Markdown memory core for AI agents
5
5
  Author: memleaf contributors
6
6
  License-Expression: MIT
@@ -23,8 +23,8 @@ Dynamic: license-file
23
23
 
24
24
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
25
25
 
26
- > **当前版本:0.2.7。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.7 通过 PyPI 分发。
26
+ > **当前版本:0.2.9。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -4,8 +4,8 @@
4
4
 
5
5
  [中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **Version: 0.2.4.**
8
- > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. memleaf 0.2.4 is distributed through PyPI.
7
+ > **Version: 0.2.9.**
8
+ > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. memleaf 0.2.9 is distributed through PyPI.
9
9
  > **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
10
10
 
11
11
  ## Project scope
@@ -4,8 +4,8 @@
4
4
 
5
5
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **当前版本:0.2.7。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.7 通过 PyPI 分发。
7
+ > **当前版本:0.2.9。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 PyPI 分发。
9
9
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
10
10
 
11
11
  ## 项目定位
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memleaf"
7
- version = "0.2.7"
7
+ version = "0.2.9"
8
8
  description = "A local-first Markdown memory core for AI agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  """Local-first Markdown memory core for AI agents."""
2
2
 
3
- __version__ = "0.2.7"
3
+ __version__ = "0.2.9"
4
4
 
5
5
  from .config import DEFAULT_CONFIG, default_config, load_config, save_config
6
6
  from .frontmatter import FrontmatterError, dump_frontmatter, dump_yaml, load_yaml, parse_frontmatter
@@ -78,6 +78,9 @@ _MODEL_VALIDATION_DETAILS = frozenset(
78
78
  "duplicate_update_target",
79
79
  "mixed_project_scopes",
80
80
  "update_target_type_mismatch",
81
+ "target_not_relevant",
82
+ "scope_not_grounded",
83
+ "scope_drift",
81
84
  "invalid_evidence",
82
85
  "invalid_flags",
83
86
  "invalid_type",
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.7
2
+ version: 0.2.9
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -37,9 +37,12 @@ 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,
45
+ TARGET_RELEVANCE_CORRECTION,
43
46
  UPDATE_TARGET_TYPE_CORRECTION,
44
47
  gate_prompt,
45
48
  summarize_prompt,
@@ -51,6 +54,7 @@ from .scope_maintenance import ScopeMaintainer, ScopeMaintenanceError, scope_reg
51
54
  from .validation import (
52
55
  MODEL_VALIDATION_DETAILS,
53
56
  ModelOutputError,
57
+ NO_CHANGE_DECISION,
54
58
  parse_gate_output,
55
59
  parse_summarize_output,
56
60
  )
@@ -488,6 +492,12 @@ class Processor:
488
492
  return UPDATE_TARGET_TYPE_CORRECTION
489
493
  if stage == "gate" and hint == "invalid_type":
490
494
  return GATE_TYPE_CORRECTION
495
+ if stage == "gate" and hint == "scope_not_grounded":
496
+ return SCOPE_GROUNDING_CORRECTION
497
+ if stage == "gate" and hint == "target_not_relevant":
498
+ return TARGET_RELEVANCE_CORRECTION
499
+ if stage == "summarize" and hint == "scope_drift":
500
+ return SUMMARY_SCOPE_CORRECTION
491
501
  if hint == "relative_time":
492
502
  return RELATIVE_TIME_CORRECTION
493
503
  if stage == "summarize" and hint == "invalid_update_target":
@@ -1412,6 +1422,157 @@ class Processor:
1412
1422
  strict_relevance=True,
1413
1423
  )
1414
1424
 
1425
+ def _target_is_relevant_to_candidate(
1426
+ self,
1427
+ turn: InboxTurn,
1428
+ candidate: Mapping[str, Any],
1429
+ *,
1430
+ scope_directory: Optional[list[dict[str, Any]]] = None,
1431
+ scope_directory_complete: bool = True,
1432
+ ) -> bool:
1433
+ """Check a model-selected target against the candidate topic only.
1434
+
1435
+ The ordinary related-memory prompt may include several independent
1436
+ items from one mailbox turn. Target selection must not inherit that
1437
+ aggregate relevance: only the candidate's own extracted topic may
1438
+ make an active target eligible. A complete scope directory remains a
1439
+ bounded escape hatch for an explicitly indirect user/session scope;
1440
+ it never turns the target into an unconditional priority hit for a
1441
+ direct model-attributed candidate.
1442
+ """
1443
+
1444
+ target = next(
1445
+ (
1446
+ candidate.get(field)
1447
+ for field in ("duplicate_memory_id", "update_memory_id")
1448
+ if isinstance(candidate.get(field), str) and candidate.get(field)
1449
+ ),
1450
+ None,
1451
+ )
1452
+ memory = candidate.get("memory")
1453
+ candidate_scopes = candidate.get("scopes")
1454
+ if (
1455
+ not isinstance(target, str)
1456
+ or not isinstance(memory, str)
1457
+ or not isinstance(candidate_scopes, list)
1458
+ ):
1459
+ return False
1460
+
1461
+ target_key = target.casefold()
1462
+ candidate_scope_source = candidate.get("scope_source")
1463
+
1464
+ # A complete bounded directory is the existing indirect resolution
1465
+ # path. It contains only active IDs from the requested inherited
1466
+ # scope, so preserve its exact-ID selection semantics rather than
1467
+ # forcing a body relevance check that the directory intentionally
1468
+ # cannot provide.
1469
+ if scope_directory is not None:
1470
+ if not scope_directory_complete:
1471
+ return False
1472
+ selected = next(
1473
+ (
1474
+ item
1475
+ for item in scope_directory
1476
+ if isinstance(item, Mapping)
1477
+ and isinstance(item.get("memory_id"), str)
1478
+ and item["memory_id"].casefold() == target_key
1479
+ ),
1480
+ None,
1481
+ )
1482
+ if selected is not None and selected.get("type") == candidate.get("type"):
1483
+ return True
1484
+
1485
+ # Model-attributed, directly named scopes must match the active
1486
+ # target's stable topic title. Matching the whole old body is too
1487
+ # permissive: two different project memories can share implementation
1488
+ # details such as milestones while serving different future uses.
1489
+ if candidate_scope_source == "model":
1490
+ scope_terms: list[str] = []
1491
+ try:
1492
+ scope_config = self.service.vault.config()
1493
+ configured_scopes = scope_config.get("scopes", {})
1494
+ except (OSError, UnicodeError, ValueError, TypeError):
1495
+ scope_config = {}
1496
+ configured_scopes = {}
1497
+ for candidate_scope in candidate_scopes:
1498
+ if not isinstance(candidate_scope, str) or not candidate_scope.startswith("project:"):
1499
+ continue
1500
+ scope_terms.append(candidate_scope.partition(":")[2])
1501
+ metadata = (
1502
+ configured_scopes.get(candidate_scope)
1503
+ if isinstance(configured_scopes, Mapping)
1504
+ else None
1505
+ )
1506
+ aliases = metadata.get("aliases") if isinstance(metadata, Mapping) else None
1507
+ if isinstance(aliases, list):
1508
+ scope_terms.extend(
1509
+ alias for alias in aliases if isinstance(alias, str) and alias
1510
+ )
1511
+
1512
+ # The regression is project-scope drift inside aggregate turns.
1513
+ # Global/domain/portfolio targets keep their established semantic
1514
+ # gate behavior because there is no project identity to remove
1515
+ # before comparing stable topics.
1516
+ if not scope_terms:
1517
+ return True
1518
+
1519
+ visible_turn = " ".join(
1520
+ event.content for event in turn.events if isinstance(event.content, str)
1521
+ )
1522
+ if scope_terms and not any(
1523
+ normalize_term(term) in normalize_term(visible_turn) for term in scope_terms
1524
+ ):
1525
+ # The visible turn is elliptical (for example, "this
1526
+ # project"); the inherited scope and bounded related context
1527
+ # already resolved its target.
1528
+ return True
1529
+
1530
+ target_memory = None
1531
+ with self.service.vault.lock():
1532
+ for record in self.service._read_memories_unlocked("knowledge"):
1533
+ if record.memory.memory_id.casefold() == target_key:
1534
+ target_memory = record.memory
1535
+ break
1536
+ if target_memory is None:
1537
+ for item in self._planned_related:
1538
+ if (
1539
+ isinstance(item, Mapping)
1540
+ and isinstance(item.get("memory_id"), str)
1541
+ and item["memory_id"].casefold() == target_key
1542
+ ):
1543
+ try:
1544
+ target_memory = Memory.from_mapping(item)
1545
+ except (TypeError, ValueError):
1546
+ target_memory = None
1547
+ break
1548
+ if target_memory is None:
1549
+ return False
1550
+ if not filter_by_scope([target_memory], candidate_scopes, scope_config):
1551
+ return False
1552
+
1553
+ def without_scope_terms(value: str) -> str:
1554
+ result = value
1555
+ for term in sorted(set(scope_terms), key=len, reverse=True):
1556
+ result = re.sub(re.escape(term), "", result, flags=re.IGNORECASE)
1557
+ return result.strip()
1558
+
1559
+ topic_title = without_scope_terms(target_memory.title)
1560
+ topic_query = without_scope_terms(memory)
1561
+ if not topic_title or not topic_query:
1562
+ return False
1563
+ topic_memory = Memory(
1564
+ memory_id=target_memory.memory_id,
1565
+ title=topic_query,
1566
+ body="",
1567
+ type=target_memory.type,
1568
+ scopes=target_memory.scopes,
1569
+ )
1570
+ return candidate_matches_query(topic_memory, topic_title)
1571
+
1572
+ # User/session scopes are authoritative indirect context. The base
1573
+ # parser has already limited the target to an active related memory.
1574
+ return candidate_scope_source in {"user", "session_context"}
1575
+
1415
1576
  def _defer_candidate(
1416
1577
  self,
1417
1578
  turn_ref: tuple[str, str, str],
@@ -1614,6 +1775,7 @@ class Processor:
1614
1775
  related_native_ids=related_native_ids,
1615
1776
  related_memory_ids=related_memory_ids,
1616
1777
  scope_registry=validation_scope_registry,
1778
+ allow_no_change=False,
1617
1779
  ),
1618
1780
  diagnostic_context={
1619
1781
  "source": turn.source,
@@ -1635,6 +1797,60 @@ class Processor:
1635
1797
  list(summary["scopes"]),
1636
1798
  )
1637
1799
 
1800
+ gate_attempt_count = 0
1801
+
1802
+ def parse_gate(raw: str) -> dict[str, Any]:
1803
+ nonlocal gate_attempt_count
1804
+ gate_attempt_count += 1
1805
+ parsed = parse_gate_output(
1806
+ raw,
1807
+ current_event_keys=turn.event_keys,
1808
+ related_memory_ids=gate_related_memory_ids,
1809
+ related_memory_types=gate_related_memory_types,
1810
+ scope_registry=validation_scope_registry,
1811
+ )
1812
+ invalid_targets: dict[str, set[str]] = {}
1813
+ for candidate in parsed["candidates"]:
1814
+ target_fields = {
1815
+ field
1816
+ for field in ("duplicate_memory_id", "update_memory_id")
1817
+ if isinstance(candidate.get(field), str) and candidate.get(field)
1818
+ }
1819
+ if target_fields and not self._target_is_relevant_to_candidate(
1820
+ turn,
1821
+ candidate,
1822
+ scope_directory=scope_directory,
1823
+ scope_directory_complete=scope_directory_complete,
1824
+ ):
1825
+ invalid_targets[candidate["candidate_id"].casefold()] = target_fields
1826
+
1827
+ if invalid_targets and gate_attempt_count < 3:
1828
+ raise ModelOutputError(
1829
+ "selected target is not relevant to the candidate topic",
1830
+ validation_detail="target_not_relevant",
1831
+ )
1832
+
1833
+ if invalid_targets:
1834
+ # A persistently non-converging model must not hold an entire
1835
+ # inbox turn hostage. An unrelated update target can safely
1836
+ # become an independent CREATE candidate; an unrelated
1837
+ # duplicate has no independent fact to write and is dropped.
1838
+ candidates: list[dict[str, Any]] = []
1839
+ for candidate in parsed["candidates"]:
1840
+ fields = invalid_targets.get(candidate["candidate_id"].casefold())
1841
+ if not fields:
1842
+ candidates.append(candidate)
1843
+ continue
1844
+ if "duplicate_memory_id" in fields:
1845
+ continue
1846
+ if "update_memory_id" in fields and candidate.get("worth"):
1847
+ independent = dict(candidate)
1848
+ independent.pop("update_memory_id", None)
1849
+ candidates.append(independent)
1850
+ parsed = dict(parsed)
1851
+ parsed["candidates"] = candidates
1852
+ return parsed
1853
+
1638
1854
  gate = self._complete_json_stage(
1639
1855
  backend,
1640
1856
  gate_prompt(
@@ -1647,12 +1863,7 @@ class Processor:
1647
1863
  ),
1648
1864
  system=GATE_SYSTEM,
1649
1865
  purpose="gate",
1650
- parser=lambda raw: parse_gate_output(
1651
- raw,
1652
- current_event_keys=turn.event_keys,
1653
- related_memory_ids=gate_related_memory_ids,
1654
- related_memory_types=gate_related_memory_types,
1655
- ),
1866
+ parser=parse_gate,
1656
1867
  diagnostic_context={
1657
1868
  "source": turn.source,
1658
1869
  "session_id": turn.session_id,
@@ -1663,13 +1874,6 @@ class Processor:
1663
1874
  observed_scopes: list[str] = []
1664
1875
  for candidate in gate["candidates"]:
1665
1876
  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
1877
  # An automatic candidate with no reliable project attribution is
1674
1878
  # retained as a retryable inbox turn, never silently promoted to
1675
1879
  # global knowledge. The processed ledger records only a compact
@@ -1708,15 +1912,7 @@ class Processor:
1708
1912
  candidate_related, candidate_scope_background, candidate_native_refs, _ = self._related_query(
1709
1913
  turn,
1710
1914
  state,
1711
- (
1712
- str(candidate.get("memory", ""))
1713
- + "\n"
1714
- + " ".join(
1715
- event.get("content", "")
1716
- for event in events
1717
- if isinstance(event.get("content"), str)
1718
- )
1719
- ).strip(),
1915
+ str(candidate.get("memory", "")).strip(),
1720
1916
  candidate_scopes,
1721
1917
  overlay=self._planned_related,
1722
1918
  priority_memory_ids=[
@@ -1758,6 +1954,27 @@ class Processor:
1758
1954
  native_refs=candidate_native_refs,
1759
1955
  )
1760
1956
  )
1957
+ # Automatic duplicate observations are metadata no-ops;
1958
+ # only the already-active target's scopes are trustworthy
1959
+ # session context, never a transient model-provided scope.
1960
+ duplicate_scopes = next(
1961
+ (
1962
+ item.get("scopes")
1963
+ for item in candidate_related
1964
+ if isinstance(item, Mapping)
1965
+ and isinstance(item.get("memory_id"), str)
1966
+ and item["memory_id"].casefold() == duplicate_memory_id.casefold()
1967
+ and isinstance(item.get("scopes"), list)
1968
+ ),
1969
+ [],
1970
+ )
1971
+ for observed_scope in duplicate_scopes:
1972
+ if (
1973
+ isinstance(observed_scope, str)
1974
+ and observed_scope != "unscoped"
1975
+ and observed_scope not in observed_scopes
1976
+ ):
1977
+ observed_scopes.append(observed_scope)
1761
1978
  continue
1762
1979
 
1763
1980
  gate_update_target = candidate.get("update_memory_id")
@@ -1793,6 +2010,9 @@ class Processor:
1793
2010
  related_native_ids=candidate_native_ids,
1794
2011
  related_memory_ids=candidate_memory_ids,
1795
2012
  scope_registry=validation_scope_registry,
2013
+ expected_scopes=candidate["scopes"],
2014
+ expected_scope_source=candidate["scope_source"],
2015
+ allow_no_change=True,
1796
2016
  expected_type=(
1797
2017
  candidate.get("type")
1798
2018
  if (
@@ -1814,6 +2034,8 @@ class Processor:
1814
2034
  "turn_index": turn.turn_index,
1815
2035
  },
1816
2036
  )
2037
+ if summary.get("decision") == NO_CHANGE_DECISION:
2038
+ continue
1817
2039
  if gate_update_target is not None:
1818
2040
  summary_update_target = summary.get("update_memory_id")
1819
2041
  if summary_update_target is None:
@@ -1839,9 +2061,6 @@ class Processor:
1839
2061
  scope_source=summary.get("scope_source"),
1840
2062
  )
1841
2063
  continue
1842
- for observed_scope in summary["scopes"]:
1843
- if observed_scope not in observed_scopes:
1844
- observed_scopes.append(observed_scope)
1845
2064
  requests.append(
1846
2065
  self._request(
1847
2066
  summary,
@@ -1851,6 +2070,13 @@ class Processor:
1851
2070
  native_refs=candidate_native_refs,
1852
2071
  )
1853
2072
  )
2073
+ for observed_scope in summary["scopes"]:
2074
+ if (
2075
+ isinstance(observed_scope, str)
2076
+ and observed_scope != "unscoped"
2077
+ and observed_scope not in observed_scopes
2078
+ ):
2079
+ observed_scopes.append(observed_scope)
1854
2080
  return requests, observed_scopes
1855
2081
 
1856
2082
  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,40 @@ 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
+ TARGET_RELEVANCE_CORRECTION = (
339
+ "Previous output violated: target_not_relevant. An update or duplicate "
340
+ "target must match the candidate's own memory topic through the supplied "
341
+ "active memory; do not borrow relevance from another event in the same "
342
+ "mailbox turn, aggregate context, or a priority ID. For a different "
343
+ "future use, remove the target and emit a separate atomic candidate. An "
344
+ "indirect same-use update may use a complete scope directory or session "
345
+ "context only when exactly one supplied active target is clear; otherwise "
346
+ "leave the target unset or defer it. Return only the strict gate JSON object."
347
+ )
348
+
349
+
350
+ SCOPE_GROUNDING_CORRECTION = (
351
+ "Previous output violated: scope_not_grounded. For each worthy candidate "
352
+ "with scope_source=model, choose a project scope named by that candidate's "
353
+ "own memory text: use the registered project name or alias, or the name "
354
+ "itself for a new scope. Do not borrow a name from another event, related "
355
+ "memory, session background, or an aggregate mailbox turn. If exactly one "
356
+ "project cannot be supported, choose the evidence-supported scope, use "
357
+ "unscoped with insufficient_context, defer it, or set worth=false. Return "
358
+ "only the strict gate JSON object."
359
+ )
360
+
361
+
309
362
  COMPACT_SYSTEM = """You are memleaf's memory compactor. Return JSON only.
310
363
  Merge only the supplied low-priority memories when they express compatible
311
364
  information. Return an object with a memories array; [] is a safe no-op.
@@ -398,6 +451,14 @@ def summarize_prompt(
398
451
  f"Current scope registry (safe projection; no paths):\n"
399
452
  f"{_json(scope_registry if scope_registry is not None else [])}\n"
400
453
  )
454
+ if not explicit:
455
+ prompt += (
456
+ "Automatic admission re-check: if the candidate has no independent "
457
+ "future-use fact or action after reviewing the evidence, return exactly "
458
+ '{"decision":"NO_CHANGE"}; do not return an empty or partial memory object. '
459
+ "For a normal summary, copy Candidate scopes exactly; omit scope_source "
460
+ "to inherit it or repeat the same value, never choose another scope.\n"
461
+ )
401
462
  example_key = _first_event_key(events)
402
463
  if example_key is not None:
403
464
  candidate_type = candidate.get("type") if isinstance(candidate, dict) else None
@@ -431,6 +492,8 @@ def summarize_prompt(
431
492
  }
432
493
  )
433
494
  )
495
+ if not explicit:
496
+ return prompt + '\nReturn one summary JSON object or exactly {"decision":"NO_CHANGE"}.'
434
497
  return prompt + "\nReturn one summary JSON object."
435
498
 
436
499
 
@@ -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",