memleaf 0.2.11__tar.gz → 0.2.13__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.11 → memleaf-0.2.13}/CHANGELOG.md +25 -0
  2. {memleaf-0.2.11/src/memleaf.egg-info → memleaf-0.2.13}/PKG-INFO +3 -3
  3. {memleaf-0.2.11 → memleaf-0.2.13}/README.en.md +2 -2
  4. {memleaf-0.2.11 → memleaf-0.2.13}/README.md +2 -2
  5. {memleaf-0.2.11 → memleaf-0.2.13}/pyproject.toml +1 -1
  6. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/processing.py +174 -0
  9. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/prompts.py +23 -1
  10. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/validation.py +94 -0
  11. {memleaf-0.2.11 → memleaf-0.2.13/src/memleaf.egg-info}/PKG-INFO +3 -3
  12. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_admission_noise.py +142 -0
  13. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_hermes_provider.py +4 -4
  14. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_pypi_install.py +5 -5
  15. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_b1.py +81 -1
  16. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_b2a.py +274 -1
  17. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_c1_mcp.py +1 -1
  18. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_c3_packaging.py +1 -1
  19. {memleaf-0.2.11 → memleaf-0.2.13}/IMPLEMENTATION_PLAN.md +0 -0
  20. {memleaf-0.2.11 → memleaf-0.2.13}/LICENSE +0 -0
  21. {memleaf-0.2.11 → memleaf-0.2.13}/MANIFEST.in +0 -0
  22. {memleaf-0.2.11 → memleaf-0.2.13}/RELEASE_CHECKLIST.md +0 -0
  23. {memleaf-0.2.11 → memleaf-0.2.13}/examples/README.md +0 -0
  24. {memleaf-0.2.11 → memleaf-0.2.13}/examples/basic_usage.py +0 -0
  25. {memleaf-0.2.11 → memleaf-0.2.13}/examples/mcp_stdio.ndjson +0 -0
  26. {memleaf-0.2.11 → memleaf-0.2.13}/install.ps1 +0 -0
  27. {memleaf-0.2.11 → memleaf-0.2.13}/install.sh +0 -0
  28. {memleaf-0.2.11 → memleaf-0.2.13}/setup.cfg +0 -0
  29. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/__main__.py +0 -0
  30. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/adapters/__init__.py +0 -0
  31. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/adapters/antigravity.py +0 -0
  32. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/adapters/base.py +0 -0
  33. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/adapters/codex.py +0 -0
  34. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/adapters/hermes.py +0 -0
  35. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/budget.py +0 -0
  36. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/capture.py +0 -0
  37. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/cli.py +0 -0
  38. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/compaction.py +0 -0
  39. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/config.py +0 -0
  40. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/credentials.py +0 -0
  41. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/frontmatter.py +0 -0
  42. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/hermes_provider/README.md +0 -0
  43. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/hermes_provider/__init__.py +0 -0
  44. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/host_events.py +0 -0
  45. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/host_runtime.py +0 -0
  46. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/inbox.py +0 -0
  47. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/index.py +0 -0
  48. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/installer.py +0 -0
  49. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/llm/__init__.py +0 -0
  50. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/llm/base.py +0 -0
  51. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/llm/claude_compatible.py +0 -0
  52. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/llm/gemini.py +0 -0
  53. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/llm/openai_compatible.py +0 -0
  54. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/llm/router.py +0 -0
  55. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/locking.py +0 -0
  56. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/mcp_server.py +0 -0
  57. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/memory_writer.py +0 -0
  58. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/model_discovery.py +0 -0
  59. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/models.py +0 -0
  60. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/native_index.py +0 -0
  61. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/redaction.py +0 -0
  62. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/retrieval.py +0 -0
  63. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/retrieval_gate.py +0 -0
  64. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/scope_maintenance.py +0 -0
  65. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/scope_state.py +0 -0
  66. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/service.py +0 -0
  67. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf/vault.py +0 -0
  68. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf.egg-info/SOURCES.txt +0 -0
  69. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf.egg-info/dependency_links.txt +0 -0
  70. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf.egg-info/entry_points.txt +0 -0
  71. {memleaf-0.2.11 → memleaf-0.2.13}/src/memleaf.egg-info/top_level.txt +0 -0
  72. {memleaf-0.2.11 → memleaf-0.2.13}/tests/__init__.py +0 -0
  73. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_codex_install.py +0 -0
  74. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_codex_native_cli.py +0 -0
  75. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_context_budget.py +0 -0
  76. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_credential_safety.py +0 -0
  77. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_cross_host_acceptance.py +0 -0
  78. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_hermes_stdio_transport.py +0 -0
  79. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_host_events.py +0 -0
  80. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_host_runtime_contract.py +0 -0
  81. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_install.py +0 -0
  82. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_maintenance_v2.py +0 -0
  83. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_model_discovery.py +0 -0
  84. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_retrieval_gate.py +0 -0
  85. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_retrieval_v2.py +0 -0
  86. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_session_lineage.py +0 -0
  87. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_a.py +0 -0
  88. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_b2b.py +0 -0
  89. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_b3a_commit.py +0 -0
  90. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_b3a_contract.py +0 -0
  91. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_b3b_native_context.py +0 -0
  92. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_b3b_native_index.py +0 -0
  93. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_b3b_scope.py +0 -0
  94. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_b3c_retrieval.py +0 -0
  95. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  96. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.11 → memleaf-0.2.13}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.11 → memleaf-0.2.13}/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.13 — 2026-09-02
6
+
7
+ - Preserve the full active project-plan body, stable title, and retrieval
8
+ metadata when a customer suggestion only adds constraints; explicit
9
+ replacements continue to replace conflicting state normally.
10
+ - Reject attachment, document, presentation, material, and issue-list items
11
+ described only by transport metadata or a generic follow-up at both the gate
12
+ and summary boundaries.
13
+ - Keep concrete attachment-backed actions eligible when they include a
14
+ remediation, owner, deadline, delivery rule, deployment, or migration.
15
+
16
+ ## 0.2.12 — 2026-09-02
17
+
18
+ - Remove empty ASCII or Chinese parentheses left after an absolute date is
19
+ normalized, so permanent memory never stores values such as
20
+ `2026-09-03()`.
21
+ - Reconcile a same-scoped implementation-plan candidate with its unique active
22
+ project-plan memory even when the model labels the candidate as a fact or
23
+ omits `update_memory_id`; preserve the target's immutable type and history.
24
+ - Exclude sent-mail, attachment, archive, and meeting records from automatic
25
+ plan-target inference, and defer instead of guessing when multiple project
26
+ plan targets remain.
27
+ - Reject combined mailbox or daily-report count/status digests as permanent
28
+ memory while keeping separately emitted atomic actions eligible.
29
+
5
30
  ## 0.2.11 — 2026-09-02
6
31
 
7
32
  - Document one complete macOS/Linux upgrade command that upgrades the core
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.11
3
+ Version: 0.2.13
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.11。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.11 通过 PyPI 分发。
26
+ > **当前版本:0.2.13。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.13 通过 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.11.**
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.11 is distributed through PyPI.
7
+ > **Version: 0.2.13.**
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.13 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.11。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.11 通过 PyPI 分发。
7
+ > **当前版本:0.2.13。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.13 通过 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.11"
7
+ version = "0.2.13"
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.11"
3
+ __version__ = "0.2.13"
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.11
2
+ version: 0.2.13
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -64,6 +64,8 @@ from .validation import (
64
64
  parse_strict_json,
65
65
  parse_summarize_output,
66
66
  normalize_relative_calendar_text,
67
+ is_aggregate_operational_text,
68
+ is_attachment_followup_only_text,
67
69
  )
68
70
  from .vault import safe_component
69
71
 
@@ -1682,6 +1684,160 @@ class Processor:
1682
1684
  # parser has already limited the target to an active related memory.
1683
1685
  return candidate_scope_source in {"user", "session_context"}
1684
1686
 
1687
+ @staticmethod
1688
+ def _project_scope_keys(scopes: Any) -> set[str]:
1689
+ if not isinstance(scopes, list):
1690
+ return set()
1691
+ return {
1692
+ scope.casefold()
1693
+ for scope in scopes
1694
+ if isinstance(scope, str) and scope.startswith("project:")
1695
+ }
1696
+
1697
+ @staticmethod
1698
+ def _is_project_plan_title(value: Any) -> bool:
1699
+ text = normalize_term(value) if isinstance(value, str) else ""
1700
+ return any(
1701
+ marker in text
1702
+ for marker in ("实施计划", "项目计划", "implementation plan", "project plan")
1703
+ )
1704
+
1705
+ @staticmethod
1706
+ def _is_adjacent_plan_record(value: Any) -> bool:
1707
+ text = normalize_term(value) if isinstance(value, str) else ""
1708
+ return any(
1709
+ marker in text
1710
+ for marker in (
1711
+ "已发送", "发送", "邮件", "附件", "存档", "会议", "启动会", "纪要",
1712
+ "sent", "email", "mail", "attachment", "archive", "meeting", "minutes",
1713
+ )
1714
+ )
1715
+
1716
+ @classmethod
1717
+ def _merge_additive_project_plan_update(
1718
+ cls,
1719
+ candidate: Mapping[str, Any],
1720
+ summary: Mapping[str, Any],
1721
+ target: Memory | None,
1722
+ ) -> dict[str, Any]:
1723
+ """Preserve a full active plan when current evidence only adds constraints."""
1724
+
1725
+ result = dict(summary)
1726
+ if (
1727
+ target is None
1728
+ or target.type != "project"
1729
+ or not cls._is_project_plan_title(target.title)
1730
+ ):
1731
+ return result
1732
+ candidate_text = str(candidate.get("memory", "")).casefold()
1733
+ additive_markers = (
1734
+ "建议", "补充", "增加", "新增", "追加", "还需", "同时", "纳入", "完善",
1735
+ "suggest", "propose", "additional", "add ", "include",
1736
+ )
1737
+ replacement_markers = (
1738
+ "改为", "变更为", "替换", "取消", "删除", "不再", "更新为", "调整为", "推迟至", "提前至",
1739
+ "replace", "changed to", "no longer", "cancel", "remove", "instead", "moved to",
1740
+ )
1741
+ if not any(marker in candidate_text for marker in additive_markers):
1742
+ return result
1743
+ if any(marker in candidate_text for marker in replacement_markers):
1744
+ return result
1745
+
1746
+ old_body = target.body.strip()
1747
+ new_body = str(result.get("body", "")).strip()
1748
+ normalized_old = " ".join(old_body.casefold().split())
1749
+ normalized_new = " ".join(new_body.casefold().split())
1750
+ if old_body and normalized_old not in normalized_new:
1751
+ result["body"] = f"{old_body}\n\n{new_body}" if new_body else old_body
1752
+ result["title"] = target.title
1753
+ for field in ("tags", "aliases", "keywords"):
1754
+ merged: list[str] = []
1755
+ for value in list(getattr(target, field)) + list(result.get(field, [])):
1756
+ if isinstance(value, str) and value not in merged:
1757
+ merged.append(value)
1758
+ if merged or field in result:
1759
+ result[field] = merged
1760
+ return result
1761
+
1762
+ def _infer_update_target(
1763
+ self,
1764
+ candidate: Mapping[str, Any],
1765
+ related: Iterable[Mapping[str, Any]],
1766
+ ) -> dict[str, Any]:
1767
+ """Reuse one unambiguously matching project plan/constraint memory.
1768
+
1769
+ The gate remains authoritative when it names a target. This narrow
1770
+ fallback handles a common model omission: a project-plan candidate is
1771
+ emitted as a new ``fact`` without ``update_memory_id`` even though the
1772
+ same scoped plan is already active. The active target's type is the
1773
+ canonical type; it is copied before summary validation so the target
1774
+ type invariant remains enforced.
1775
+ """
1776
+
1777
+ result = dict(candidate)
1778
+ if (
1779
+ not result.get("worth")
1780
+ or result.get("duplicate")
1781
+ or any(result.get(field) for field in ("duplicate_memory_id", "update_memory_id"))
1782
+ or not isinstance(result.get("memory"), str)
1783
+ ):
1784
+ return result
1785
+ if result.get("type") not in {"fact", "project"}:
1786
+ return result
1787
+ if not self._is_project_plan_title(result["memory"]):
1788
+ return result
1789
+ project_keys = self._project_scope_keys(result.get("scopes"))
1790
+ if len(project_keys) != 1:
1791
+ return result
1792
+
1793
+ project_matches: list[Memory] = []
1794
+ fact_matches: list[Memory] = []
1795
+ seen: set[str] = set()
1796
+ for item in related:
1797
+ if not isinstance(item, Mapping) or item.get("native") is True:
1798
+ continue
1799
+ memory_id = item.get("memory_id")
1800
+ item_type = item.get("type")
1801
+ item_scopes = item.get("scopes")
1802
+ if (
1803
+ not isinstance(memory_id, str)
1804
+ or not isinstance(item_type, str)
1805
+ or item_type not in {"fact", "project"}
1806
+ or self._project_scope_keys(item_scopes) != project_keys
1807
+ or memory_id.casefold() in seen
1808
+ or not self._is_project_plan_title(item.get("title"))
1809
+ or self._is_adjacent_plan_record(item.get("title"))
1810
+ ):
1811
+ continue
1812
+ try:
1813
+ target = Memory.from_mapping(item)
1814
+ except (TypeError, ValueError):
1815
+ continue
1816
+ seen.add(memory_id.casefold())
1817
+ if target.type == "project":
1818
+ project_matches.append(target)
1819
+ else:
1820
+ fact_matches.append(target)
1821
+
1822
+ # Prefer a project target over a same-topic fact. If there are
1823
+ # multiple project targets, defer rather than creating a sibling by
1824
+ # guessing; the same conservative rule applies when only multiple
1825
+ # durable fact targets remain.
1826
+ if len(project_matches) > 1 or (not project_matches and len(fact_matches) > 1):
1827
+ result["_defer_reason"] = "ambiguous_update_target"
1828
+ return result
1829
+ if project_matches:
1830
+ target = project_matches[0]
1831
+ elif len(fact_matches) == 1:
1832
+ target = fact_matches[0]
1833
+ else:
1834
+ return result
1835
+ result["update_memory_id"] = target.memory_id
1836
+ # The update target's type is immutable. Correct a model's fact-vs-
1837
+ # project label only after the same-use target was identified.
1838
+ result["type"] = target.type
1839
+ return result
1840
+
1685
1841
  def _defer_candidate(
1686
1842
  self,
1687
1843
  turn_ref: tuple[str, str, str],
@@ -2026,6 +2182,13 @@ class Processor:
2026
2182
  requests: list[dict[str, Any]] = []
2027
2183
  observed_scopes: list[str] = []
2028
2184
  for candidate in gate["candidates"]:
2185
+ # A combined mailbox/daily digest is not an atomic memory. If a
2186
+ # concrete action was worth retaining, the gate must emit it as
2187
+ # its own candidate; the aggregate shell itself is NO_CHANGE.
2188
+ if candidate.get("worth") and is_aggregate_operational_text(candidate.get("memory")):
2189
+ continue
2190
+ if candidate.get("worth") and is_attachment_followup_only_text(candidate.get("memory")):
2191
+ continue
2029
2192
  candidate_scopes = list(candidate["scopes"])
2030
2193
  # An automatic candidate with no reliable project attribution is
2031
2194
  # retained as a retryable inbox turn, never silently promoted to
@@ -2075,6 +2238,11 @@ class Processor:
2075
2238
  priority_only=scope_directory is not None,
2076
2239
  scope_records=scoped_records if scope_directory is not None else None,
2077
2240
  )
2241
+ candidate = self._infer_update_target(candidate, candidate_related)
2242
+ defer_reason = candidate.pop("_defer_reason", None)
2243
+ if defer_reason:
2244
+ self._defer_candidate(turn_ref, candidate, defer_reason)
2245
+ continue
2078
2246
  candidate_native_ids = [item["native_id"] for item in candidate_native_refs]
2079
2247
  candidate_memory_ids = [
2080
2248
  item["memory_id"]
@@ -2203,6 +2371,10 @@ class Processor:
2203
2371
  continue
2204
2372
  if summary.get("decision") == NO_CHANGE_DECISION:
2205
2373
  continue
2374
+ if is_attachment_followup_only_text(
2375
+ f"{summary.get('title', '')}\n{summary.get('body', '')}"
2376
+ ):
2377
+ continue
2206
2378
  if gate_update_target is not None:
2207
2379
  summary_update_target = summary.get("update_memory_id")
2208
2380
  if summary_update_target is None:
@@ -2219,6 +2391,8 @@ class Processor:
2219
2391
  else:
2220
2392
  summary = dict(summary)
2221
2393
  summary["update_memory_id"] = gate_update_target
2394
+ target = self.service.read(gate_update_target, include_history=False)
2395
+ summary = self._merge_additive_project_plan_update(candidate, summary, target)
2222
2396
  if summary["scopes"] == ["unscoped"] or summary.get("scope_source") == "insufficient_context":
2223
2397
  self._defer_candidate(
2224
2398
  turn_ref,
@@ -115,6 +115,10 @@ independent future use, set worth=false. A one-off meeting with no durable
115
115
  decision or commitment, a request awaiting PM acceptance, and pending feedback
116
116
  or another intermediate status normally have worth=false. Keep an independent
117
117
  project risk or concrete future action even when it came from a digest.
118
+ A PPT, attachment, material, document, or issue-list item described only as
119
+ "needs follow-up/review/processing" has no independent action and normally has
120
+ worth=false; keep it only when the evidence supplies a concrete remediation,
121
+ owner, deadline, delivery rule, migration, or durable conclusion.
118
122
  An intermediate state such as a draft awaiting confirmation, preparation/
119
123
  processing status, temporary error, repeated confirmation, or unconfirmed
120
124
  suggestion normally has no independent future use and is worth=false. Keep one
@@ -141,6 +145,16 @@ the current turn adds no confirmed change, use worth=false and do not create a
141
145
  sibling. If a customer or user proposes a new change without confirmation of
142
146
  implementation, preserve it as requested/proposed/pending and never state that
143
147
  it is already implemented.
148
+ When a candidate names a project plan or project constraint already represented
149
+ by one same-scoped active memory, prefer that exact active memory as
150
+ update_memory_id, even when the candidate's initial type label is fact; the
151
+ active target type is immutable and must be copied. Never create a sibling for
152
+ the same future question/action. Prefer the durable project-plan target over
153
+ same-topic sent-mail, attachment/archive, or meeting records; if multiple
154
+ same-use project targets remain, do not guess an update target. A candidate
155
+ that still combines a mailbox or daily digest shell with counts/statuses is not
156
+ atomic: set worth=false for the aggregate, and emit any independent concrete
157
+ action as a separate candidate.
144
158
  Return no prose, markdown fences, comments, or trailing text."""
145
159
 
146
160
 
@@ -172,7 +186,8 @@ still has a concrete future-use fact or action. If not, return exactly
172
186
  {"decision":"NO_CHANGE"}; this is the only no-write summary response. Use it
173
187
  for temporary delegation, waiting for feedback, a one-off meeting arrangement with
174
188
  no durable outcome/decision/project constraint, or a subject/attachment-only item
175
- without a concrete impact or conclusion.
189
+ without a concrete impact or conclusion. Attachment sizes, mail/message IDs,
190
+ and a generic "needs follow-up" phrase do not create such an impact.
176
191
  Explicit remember mode never permits NO_CHANGE and must return the normal summary
177
192
  object. When producing a normal automatic summary, copy the gate candidate's
178
193
  scopes exactly. scope_source may be omitted to inherit the gate value, but an
@@ -243,6 +258,13 @@ surrounding diagnosis into a memory. If a user-confirmed future-use
243
258
  preference, identity, constraint, project risk, or durable lesson appears in
244
259
  the same evidence, summarize only that future-use topic. In explicit remember mode,
245
260
  summarize only the requested object and do not append tool/test diagnostics.
261
+ If the gate candidate points to a same-scoped existing plan or constraint,
262
+ update that exact target and retain its still-valid content; do not create a
263
+ new sibling merely because the gate called it a fact. Prefer a durable project
264
+ plan target over same-topic sent-mail, attachment/archive, or meeting records;
265
+ the target type remains immutable. Never summarize a combined mailbox/daily
266
+ digest shell with counts or statuses; return NO_CHANGE for that aggregate,
267
+ while a separate atomic future-use action may still be summarized.
246
268
  Return no prose, markdown fences, comments, or trailing text."""
247
269
 
248
270
 
@@ -104,6 +104,10 @@ _ISO_CALENDAR_DATE = re.compile(
104
104
  r"(?<![A-Za-z\d./-])\d{4}-(?:0?[1-9]|1[0-2])-(?:0?[1-9]|[12]\d|3[01])"
105
105
  r"(?![A-Za-z\d./-])"
106
106
  )
107
+ _EMPTY_ISO_DATE_PARENTHESIS = re.compile(
108
+ r"(?P<date>\d{4}-(?:0?[1-9]|1[0-2])-(?:0?[1-9]|[12]\d|3[01]))"
109
+ r"[ \t]*(?:\([ \t]*[))]|([ \t]*[))])"
110
+ )
107
111
  _DUPLICATE_ISO_DATE = re.compile(
108
112
  r"(?P<date>\d{4}-\d{2}-\d{2})"
109
113
  r"\s*[,,、:]?\s*(?:(?:就是|即|即为|即是|也就是|是|为)|[//])\s*"
@@ -275,6 +279,14 @@ def _collapse_duplicate_calendar_dates(value: str) -> str:
275
279
  return value
276
280
 
277
281
 
282
+ def _strip_empty_iso_date_parenthesis(value: str) -> str:
283
+ """Remove empty ASCII/Chinese parentheses attached to an ISO date."""
284
+
285
+ if not isinstance(value, str):
286
+ return value
287
+ return _EMPTY_ISO_DATE_PARENTHESIS.sub(r"\g<date>", value)
288
+
289
+
278
290
  def _normalize_relative_calendar_text(
279
291
  text: str,
280
292
  anchor: Any,
@@ -328,6 +340,7 @@ def _normalize_relative_calendar_text(
328
340
  normalized = _RELATIVE_CALENDAR_EXPRESSION.sub(replace_token, text)
329
341
  if relative_replaced:
330
342
  normalized = _collapse_duplicate_calendar_dates(normalized)
343
+ normalized = _strip_empty_iso_date_parenthesis(normalized)
331
344
  return normalized, safe
332
345
 
333
346
 
@@ -345,6 +358,82 @@ def normalize_relative_calendar_text(text: str, anchor: Any) -> str | None:
345
358
  return normalized if safe else None
346
359
 
347
360
 
361
+ _AGGREGATE_DIGEST_MARKERS = (
362
+ "日报",
363
+ "汇总",
364
+ "巡检",
365
+ "收件箱",
366
+ "邮箱巡检",
367
+ "daily report",
368
+ "digest",
369
+ "watchlist",
370
+ "mailbox sweep",
371
+ "inbox sweep",
372
+ )
373
+ _AGGREGATE_COUNT = re.compile(
374
+ r"(?:\d+\s*(?:条|项|个|件|封|tasks?|items?)|(?:完成|待受理|派发|新增|逾期)\s*\d+)"
375
+ r"|(?:completed|pending|assigned|new|overdue)\s*[::]?\s*\d+",
376
+ re.IGNORECASE,
377
+ )
378
+
379
+
380
+ def is_aggregate_operational_text(value: Any) -> bool:
381
+ """Recognize a combined operational digest, not one concrete work item.
382
+
383
+ This deliberately requires multiple aggregate signals. A single overdue
384
+ task reported by a daily scan remains eligible for the model's normal
385
+ future-use decision; a multi-count sweep must be split before persistence.
386
+ """
387
+
388
+ if not isinstance(value, str):
389
+ return False
390
+ folded = value.casefold()
391
+ if not any(marker in folded for marker in _AGGREGATE_DIGEST_MARKERS):
392
+ return False
393
+ count_signals = len(_AGGREGATE_COUNT.findall(value))
394
+ separator_count = len(re.findall(r"[;;]|[—-].*[;;]", value))
395
+ return count_signals >= 2 or (count_signals >= 1 and separator_count >= 1)
396
+
397
+
398
+ _ATTACHMENT_SUBJECT_MARKERS = (
399
+ "附件", "材料", "文档", "问题清单", "ppt", "slides", "deck", "attachment", "checklist",
400
+ )
401
+ _GENERIC_FOLLOWUP_MARKERS = (
402
+ "待跟进", "需跟进", "需要跟进", "待处理", "需处理", "需要处理", "请查看", "看下",
403
+ "follow up", "follow-up", "needs review", "to review",
404
+ )
405
+ _CONCRETE_ATTACHMENT_ACTION_MARKERS = (
406
+ "迁移", "部署", "修复", "整改", "逐项", "发送给", "发给", "抄送", "提交", "回复", "确认",
407
+ "负责人", "禁止", "必须", "以后", "归档至", "保存至", "migrate", "deploy", "fix",
408
+ "remediate", "send to", "cc ", "owner", "must",
409
+ )
410
+ _TRANSPORT_DETAIL = re.compile(
411
+ r"(?:\d+(?:\.\d+)?\s*(?:kb|mb|gb|字节)|(?:邮件|mail|message)\s*(?:id\s*[=::]?\s*)?#?\d+)",
412
+ re.IGNORECASE,
413
+ )
414
+ _CONCRETE_DEADLINE = re.compile(
415
+ r"(?:截至|截止|在|by|before)\s*\d{4}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}\s*(?:前|之前|截止)",
416
+ re.IGNORECASE,
417
+ )
418
+
419
+
420
+ def is_attachment_followup_only_text(value: Any) -> bool:
421
+ """Return true for an attachment subject with no reusable concrete action."""
422
+
423
+ if not isinstance(value, str):
424
+ return False
425
+ folded = value.casefold()
426
+ if not any(marker in folded for marker in _ATTACHMENT_SUBJECT_MARKERS):
427
+ return False
428
+ if any(marker in folded for marker in _CONCRETE_ATTACHMENT_ACTION_MARKERS):
429
+ return False
430
+ if _CONCRETE_DEADLINE.search(value):
431
+ return False
432
+ return any(marker in folded for marker in _GENERIC_FOLLOWUP_MARKERS) or bool(
433
+ _TRANSPORT_DETAIL.search(value)
434
+ )
435
+
436
+
348
437
  def _reject_constant(value: str) -> None:
349
438
  raise ModelOutputError("non-finite JSON number is not allowed", validation_reason="invalid_json")
350
439
 
@@ -1022,6 +1111,9 @@ def validate_summarize_output(
1022
1111
  }
1023
1112
  _require_keys(value, {"title", "body", "tags", "type", "scopes", "sources"}, allowed)
1024
1113
  item = dict(value)
1114
+ for field in ("title", "body"):
1115
+ if isinstance(item.get(field), str):
1116
+ item[field] = _strip_empty_iso_date_parenthesis(item[field])
1025
1117
  _string(item["title"], "title")
1026
1118
  _string(item["body"], "body", multiline=True)
1027
1119
  _reject_relative_calendar_expression(item)
@@ -1281,6 +1373,8 @@ __all__ = [
1281
1373
  "parse_gate_output",
1282
1374
  "parse_compact_output",
1283
1375
  "parse_strict_json",
1376
+ "is_aggregate_operational_text",
1377
+ "is_attachment_followup_only_text",
1284
1378
  "normalize_relative_calendar_text",
1285
1379
  "parse_summarize_output",
1286
1380
  "parse_summary_output",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.11
3
+ Version: 0.2.13
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.11。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.11 通过 PyPI 分发。
26
+ > **当前版本:0.2.13。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.13 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位