memleaf 0.2.12__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.12 → memleaf-0.2.13}/CHANGELOG.md +11 -0
  2. {memleaf-0.2.12/src/memleaf.egg-info → memleaf-0.2.13}/PKG-INFO +3 -3
  3. {memleaf-0.2.12 → memleaf-0.2.13}/README.en.md +2 -2
  4. {memleaf-0.2.12 → memleaf-0.2.13}/README.md +2 -2
  5. {memleaf-0.2.12 → memleaf-0.2.13}/pyproject.toml +1 -1
  6. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/processing.py +55 -0
  9. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/prompts.py +6 -1
  10. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/validation.py +40 -0
  11. {memleaf-0.2.12 → memleaf-0.2.13/src/memleaf.egg-info}/PKG-INFO +3 -3
  12. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_admission_noise.py +89 -0
  13. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_hermes_provider.py +4 -4
  14. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_pypi_install.py +5 -5
  15. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_b1.py +33 -0
  16. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_b2a.py +87 -1
  17. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_c1_mcp.py +1 -1
  18. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_c3_packaging.py +1 -1
  19. {memleaf-0.2.12 → memleaf-0.2.13}/IMPLEMENTATION_PLAN.md +0 -0
  20. {memleaf-0.2.12 → memleaf-0.2.13}/LICENSE +0 -0
  21. {memleaf-0.2.12 → memleaf-0.2.13}/MANIFEST.in +0 -0
  22. {memleaf-0.2.12 → memleaf-0.2.13}/RELEASE_CHECKLIST.md +0 -0
  23. {memleaf-0.2.12 → memleaf-0.2.13}/examples/README.md +0 -0
  24. {memleaf-0.2.12 → memleaf-0.2.13}/examples/basic_usage.py +0 -0
  25. {memleaf-0.2.12 → memleaf-0.2.13}/examples/mcp_stdio.ndjson +0 -0
  26. {memleaf-0.2.12 → memleaf-0.2.13}/install.ps1 +0 -0
  27. {memleaf-0.2.12 → memleaf-0.2.13}/install.sh +0 -0
  28. {memleaf-0.2.12 → memleaf-0.2.13}/setup.cfg +0 -0
  29. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/__main__.py +0 -0
  30. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/adapters/__init__.py +0 -0
  31. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/adapters/antigravity.py +0 -0
  32. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/adapters/base.py +0 -0
  33. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/adapters/codex.py +0 -0
  34. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/adapters/hermes.py +0 -0
  35. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/budget.py +0 -0
  36. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/capture.py +0 -0
  37. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/cli.py +0 -0
  38. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/compaction.py +0 -0
  39. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/config.py +0 -0
  40. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/credentials.py +0 -0
  41. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/frontmatter.py +0 -0
  42. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/hermes_provider/README.md +0 -0
  43. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/hermes_provider/__init__.py +0 -0
  44. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/host_events.py +0 -0
  45. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/host_runtime.py +0 -0
  46. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/inbox.py +0 -0
  47. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/index.py +0 -0
  48. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/installer.py +0 -0
  49. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/llm/__init__.py +0 -0
  50. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/llm/base.py +0 -0
  51. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/llm/claude_compatible.py +0 -0
  52. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/llm/gemini.py +0 -0
  53. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/llm/openai_compatible.py +0 -0
  54. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/llm/router.py +0 -0
  55. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/locking.py +0 -0
  56. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/mcp_server.py +0 -0
  57. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/memory_writer.py +0 -0
  58. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/model_discovery.py +0 -0
  59. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/models.py +0 -0
  60. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/native_index.py +0 -0
  61. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/redaction.py +0 -0
  62. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/retrieval.py +0 -0
  63. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/retrieval_gate.py +0 -0
  64. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/scope_maintenance.py +0 -0
  65. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/scope_state.py +0 -0
  66. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/service.py +0 -0
  67. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf/vault.py +0 -0
  68. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf.egg-info/SOURCES.txt +0 -0
  69. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf.egg-info/dependency_links.txt +0 -0
  70. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf.egg-info/entry_points.txt +0 -0
  71. {memleaf-0.2.12 → memleaf-0.2.13}/src/memleaf.egg-info/top_level.txt +0 -0
  72. {memleaf-0.2.12 → memleaf-0.2.13}/tests/__init__.py +0 -0
  73. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_codex_install.py +0 -0
  74. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_codex_native_cli.py +0 -0
  75. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_context_budget.py +0 -0
  76. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_credential_safety.py +0 -0
  77. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_cross_host_acceptance.py +0 -0
  78. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_hermes_stdio_transport.py +0 -0
  79. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_host_events.py +0 -0
  80. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_host_runtime_contract.py +0 -0
  81. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_install.py +0 -0
  82. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_maintenance_v2.py +0 -0
  83. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_model_discovery.py +0 -0
  84. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_retrieval_gate.py +0 -0
  85. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_retrieval_v2.py +0 -0
  86. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_session_lineage.py +0 -0
  87. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_a.py +0 -0
  88. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_b2b.py +0 -0
  89. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_b3a_commit.py +0 -0
  90. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_b3a_contract.py +0 -0
  91. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_b3b_native_context.py +0 -0
  92. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_b3b_native_index.py +0 -0
  93. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_b3b_scope.py +0 -0
  94. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_b3c_retrieval.py +0 -0
  95. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  96. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.12 → memleaf-0.2.13}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,17 @@
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
+
5
16
  ## 0.2.12 — 2026-09-02
6
17
 
7
18
  - Remove empty ASCII or Chinese parentheses left after an absolute date is
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.12
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.12。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.12 通过 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.12.**
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.12 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.12。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.12 通过 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.12"
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.12"
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.12
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
@@ -65,6 +65,7 @@ from .validation import (
65
65
  parse_summarize_output,
66
66
  normalize_relative_calendar_text,
67
67
  is_aggregate_operational_text,
68
+ is_attachment_followup_only_text,
68
69
  )
69
70
  from .vault import safe_component
70
71
 
@@ -1712,6 +1713,52 @@ class Processor:
1712
1713
  )
1713
1714
  )
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
+
1715
1762
  def _infer_update_target(
1716
1763
  self,
1717
1764
  candidate: Mapping[str, Any],
@@ -2140,6 +2187,8 @@ class Processor:
2140
2187
  # its own candidate; the aggregate shell itself is NO_CHANGE.
2141
2188
  if candidate.get("worth") and is_aggregate_operational_text(candidate.get("memory")):
2142
2189
  continue
2190
+ if candidate.get("worth") and is_attachment_followup_only_text(candidate.get("memory")):
2191
+ continue
2143
2192
  candidate_scopes = list(candidate["scopes"])
2144
2193
  # An automatic candidate with no reliable project attribution is
2145
2194
  # retained as a retryable inbox turn, never silently promoted to
@@ -2322,6 +2371,10 @@ class Processor:
2322
2371
  continue
2323
2372
  if summary.get("decision") == NO_CHANGE_DECISION:
2324
2373
  continue
2374
+ if is_attachment_followup_only_text(
2375
+ f"{summary.get('title', '')}\n{summary.get('body', '')}"
2376
+ ):
2377
+ continue
2325
2378
  if gate_update_target is not None:
2326
2379
  summary_update_target = summary.get("update_memory_id")
2327
2380
  if summary_update_target is None:
@@ -2338,6 +2391,8 @@ class Processor:
2338
2391
  else:
2339
2392
  summary = dict(summary)
2340
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)
2341
2396
  if summary["scopes"] == ["unscoped"] or summary.get("scope_source") == "insufficient_context":
2342
2397
  self._defer_candidate(
2343
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
@@ -182,7 +186,8 @@ still has a concrete future-use fact or action. If not, return exactly
182
186
  {"decision":"NO_CHANGE"}; this is the only no-write summary response. Use it
183
187
  for temporary delegation, waiting for feedback, a one-off meeting arrangement with
184
188
  no durable outcome/decision/project constraint, or a subject/attachment-only item
185
- 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.
186
191
  Explicit remember mode never permits NO_CHANGE and must return the normal summary
187
192
  object. When producing a normal automatic summary, copy the gate candidate's
188
193
  scopes exactly. scope_source may be omitted to inherit the gate value, but an
@@ -395,6 +395,45 @@ def is_aggregate_operational_text(value: Any) -> bool:
395
395
  return count_signals >= 2 or (count_signals >= 1 and separator_count >= 1)
396
396
 
397
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
+
398
437
  def _reject_constant(value: str) -> None:
399
438
  raise ModelOutputError("non-finite JSON number is not allowed", validation_reason="invalid_json")
400
439
 
@@ -1335,6 +1374,7 @@ __all__ = [
1335
1374
  "parse_compact_output",
1336
1375
  "parse_strict_json",
1337
1376
  "is_aggregate_operational_text",
1377
+ "is_attachment_followup_only_text",
1338
1378
  "normalize_relative_calendar_text",
1339
1379
  "parse_summarize_output",
1340
1380
  "parse_summary_output",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.12
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.12。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.12 通过 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
  ## 项目定位
@@ -358,6 +358,95 @@ class AdmissionFlowTests(unittest.TestCase):
358
358
  self.assertNotIn("完成4条", memories[0].memory.body)
359
359
  self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize"])
360
360
 
361
+ def test_attachment_only_followup_is_dropped_before_summary(self):
362
+ service = self.make_service("attachment-followup")
363
+ backend = QueueBackend()
364
+ user_key, assistant_key = self.capture_turn(
365
+ service,
366
+ session="attachment-followup",
367
+ turn="turn-1",
368
+ user="查看鑫元基金最近的邮件。",
369
+ assistant="评审PPT和SIT问题清单需要跟进处理。",
370
+ )
371
+ item = candidate(
372
+ "xinyuan-materials",
373
+ [user_key, assistant_key],
374
+ memory="鑫元基金评审PPT和SIT问题清单待跟进处理。",
375
+ )
376
+ item["scopes"] = ["project:鑫元基金"]
377
+ backend.responses.append(gate([item]))
378
+
379
+ result = service.process(source="hermes", session_id="attachment-followup", model=backend)
380
+
381
+ self.assertEqual(result["memories_written"], 0)
382
+ self.assertEqual(service._read_memories_unlocked("knowledge"), [])
383
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate"])
384
+
385
+ def test_attachment_summary_with_only_transport_details_is_dropped(self):
386
+ service = self.make_service("attachment-summary")
387
+ backend = QueueBackend()
388
+ user_key, assistant_key = self.capture_turn(
389
+ service,
390
+ session="attachment-summary",
391
+ turn="turn-1",
392
+ user="查看鑫元基金当前推进材料。",
393
+ assistant="有评审材料和问题清单。",
394
+ )
395
+ item = candidate(
396
+ "xinyuan-materials",
397
+ [user_key, assistant_key],
398
+ memory="鑫元基金当前推进材料。",
399
+ )
400
+ item["scopes"] = ["project:鑫元基金"]
401
+ summary_value = json.loads(
402
+ summary(
403
+ user_key,
404
+ title="鑫元基金评审PPT和SIT问题清单待跟进",
405
+ body="评审PPT(附件8MB,邮件918)和SIT问题清单(附件5.8MB,邮件920)需跟进处理。",
406
+ )
407
+ )
408
+ summary_value["scopes"] = ["project:鑫元基金"]
409
+ backend.responses.extend([gate([item]), json.dumps(summary_value, ensure_ascii=False)])
410
+
411
+ result = service.process(source="hermes", session_id="attachment-summary", model=backend)
412
+
413
+ self.assertEqual(result["memories_written"], 0)
414
+ self.assertEqual(service._read_memories_unlocked("knowledge"), [])
415
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize"])
416
+
417
+ def test_attachment_with_owner_deadline_and_remediation_remains_admissible(self):
418
+ service = self.make_service("attachment-action")
419
+ backend = QueueBackend()
420
+ user_key, assistant_key = self.capture_turn(
421
+ service,
422
+ session="attachment-action",
423
+ turn="turn-1",
424
+ user="鑫元基金SIT问题清单怎么处理?",
425
+ assistant="张三需在2026-09-03前逐项整改SIT问题清单。",
426
+ )
427
+ item = candidate(
428
+ "xinyuan-sit-remediation",
429
+ [user_key, assistant_key],
430
+ memory="鑫元基金SIT问题清单需在2026-09-03前由张三逐项整改。",
431
+ type="todo",
432
+ )
433
+ item["scopes"] = ["project:鑫元基金"]
434
+ summary_value = json.loads(
435
+ summary(
436
+ user_key,
437
+ title="鑫元基金SIT问题清单整改",
438
+ body="张三需在2026-09-03前逐项整改鑫元基金SIT问题清单。",
439
+ type="todo",
440
+ )
441
+ )
442
+ summary_value.update({"scopes": ["project:鑫元基金"], "status": "active"})
443
+ backend.responses.extend([gate([item]), json.dumps(summary_value, ensure_ascii=False)])
444
+
445
+ result = service.process(source="hermes", session_id="attachment-action", model=backend)
446
+
447
+ self.assertEqual(result["memories_written"], 1)
448
+ self.assertIn("逐项整改", service._read_memories_unlocked("knowledge")[0].memory.body)
449
+
361
450
  def test_mixed_preference_is_kept_while_mcp_failure_is_not(self):
362
451
  service = self.make_service("mixed-preference")
363
452
  backend = QueueBackend()
@@ -284,7 +284,7 @@ class HermesProviderTests(unittest.TestCase):
284
284
 
285
285
  def test_provider_warns_with_one_line_update_when_core_versions_differ(self) -> None:
286
286
  client = FakeClient(responses=[{"stats": True}])
287
- client.server_version = "0.2.12"
287
+ client.server_version = "0.2.13"
288
288
  provider = provider_module.MemleafMemoryProvider()
289
289
  with patch.object(provider_module, "_resolve_command", return_value="memleaf-mcp"), \
290
290
  patch.object(provider_module, "_MCPClient", return_value=client), \
@@ -303,11 +303,11 @@ class HermesProviderTests(unittest.TestCase):
303
303
 
304
304
  def test_provider_does_not_warn_when_core_versions_match(self) -> None:
305
305
  client = FakeClient(responses=[{"stats": True}])
306
- client.server_version = "0.2.12"
306
+ client.server_version = "0.2.13"
307
307
  provider = provider_module.MemleafMemoryProvider()
308
308
  with patch.object(provider_module, "_resolve_command", return_value="memleaf-mcp"), \
309
309
  patch.object(provider_module, "_MCPClient", return_value=client), \
310
- patch.object(provider_module, "_provider_manifest_version", return_value="0.2.12"), \
310
+ patch.object(provider_module, "_provider_manifest_version", return_value="0.2.13"), \
311
311
  patch.object(provider_module.logger, "warning") as warning:
312
312
  provider.initialize(
313
313
  "version-match-session",
@@ -324,7 +324,7 @@ class HermesProviderTests(unittest.TestCase):
324
324
  provider = provider_module.MemleafMemoryProvider()
325
325
  with patch.object(provider_module, "_resolve_command", return_value="memleaf-mcp"), \
326
326
  patch.object(provider_module, "_MCPClient", return_value=client), \
327
- patch.object(provider_module, "_provider_manifest_version", return_value="0.2.12"), \
327
+ patch.object(provider_module, "_provider_manifest_version", return_value="0.2.13"), \
328
328
  patch.object(provider_module.logger, "warning") as warning:
329
329
  provider.initialize(
330
330
  "version-missing-session",
@@ -81,7 +81,7 @@ class PyPIInstallTests(unittest.TestCase):
81
81
 
82
82
  self.assertTrue(target.is_dir())
83
83
  self.assertFalse(target.is_symlink())
84
- self.assertIn("version: 0.2.12", (target / "plugin.yaml").read_text(encoding="utf-8"))
84
+ self.assertIn("version: 0.2.13", (target / "plugin.yaml").read_text(encoding="utf-8"))
85
85
 
86
86
  def test_windows_hermes_paths_follow_official_native_layout(self) -> None:
87
87
  with tempfile.TemporaryDirectory(prefix="memleaf-win-paths-") as temporary:
@@ -212,16 +212,16 @@ class PyPIInstallTests(unittest.TestCase):
212
212
  "status": "configured",
213
213
  "reason": "ok",
214
214
  "vault": "/tmp/memleaf-vault",
215
- "core_version": "0.2.12",
216
- "provider_version": "0.2.12",
215
+ "core_version": "0.2.13",
216
+ "provider_version": "0.2.13",
217
217
  "model": {"status": "configured"},
218
218
  }
219
219
  with mock.patch("memleaf.installer.install_hermes", return_value=result), \
220
220
  mock.patch("sys.stdout") as stdout:
221
221
  self.assertEqual(cli.main(["install"]), 0)
222
222
  output = "\n".join(str(call.args[0]) for call in stdout.write.call_args_list)
223
- self.assertIn("core=0.2.12", output)
224
- self.assertIn("Hermes provider=0.2.12", output)
223
+ self.assertIn("core=0.2.13", output)
224
+ self.assertIn("Hermes provider=0.2.13", output)
225
225
 
226
226
  def test_readmes_document_one_line_core_and_provider_upgrade(self) -> None:
227
227
  command = "python -m pip install -U memleaf && python -m memleaf install"
@@ -27,6 +27,7 @@ from memleaf.validation import (
27
27
  ModelOutputError,
28
28
  NO_CHANGE_DECISION,
29
29
  is_aggregate_operational_text,
30
+ is_attachment_followup_only_text,
30
31
  normalize_relative_calendar_text,
31
32
  )
32
33
  from memleaf.index import event_key, turn_key
@@ -445,6 +446,38 @@ class StageB1Test(unittest.TestCase):
445
446
  )
446
447
  )
447
448
 
449
+ def test_attachment_followup_detector_keeps_only_concrete_actions(self):
450
+ self.assertTrue(
451
+ is_attachment_followup_only_text(
452
+ "鑫元基金评审PPT和SIT问题清单待跟进处理。"
453
+ )
454
+ )
455
+ self.assertTrue(
456
+ is_attachment_followup_only_text(
457
+ "评审PPT附件8MB,邮件918,需跟进。"
458
+ )
459
+ )
460
+ self.assertTrue(
461
+ is_attachment_followup_only_text(
462
+ "评审PPT 2026-09-02版本待跟进。"
463
+ )
464
+ )
465
+ self.assertFalse(
466
+ is_attachment_followup_only_text(
467
+ "SIT问题清单需在2026-09-03前由张三逐项整改。"
468
+ )
469
+ )
470
+ self.assertFalse(
471
+ is_attachment_followup_only_text(
472
+ "以后需求文档直接发给王佳鑫、黄欣婕,并抄送谭宇来。"
473
+ )
474
+ )
475
+ self.assertFalse(
476
+ is_attachment_followup_only_text(
477
+ "中银国际历史数据库和附件需要全量迁移。"
478
+ )
479
+ )
480
+
448
481
  def test_process_retries_schema_violation_until_third_gate_attempt_and_commits(self):
449
482
  responses = [
450
483
  json.dumps({"candidates": "invalid"}),
@@ -1100,6 +1100,89 @@ class StageB2ATest(unittest.TestCase):
1100
1100
  self.assertEqual(processed["watermark"], 2)
1101
1101
  self.assertEqual(processed["processing"]["status"], "idle")
1102
1102
 
1103
+ def test_additive_project_plan_update_retains_existing_plan_facts(self):
1104
+ backend = QueueBackend()
1105
+ service = self.service(backend, name="additive-project-plan")
1106
+ old_body = (
1107
+ "金元顺安信创实施计划采用达梦和东方通,要求38个工作日完成,"
1108
+ "上线日期为2026-10-27,负责人为吴江波。"
1109
+ )
1110
+ existing = service.create_memory(
1111
+ memory_id="mem-jinyuan-plan",
1112
+ title="金元顺安员工投资行为申报系统信创改造实施计划",
1113
+ body=old_body,
1114
+ tags=["金元顺安", "达梦", "东方通", "负责人"],
1115
+ type="project",
1116
+ scopes=["project:金元顺安"],
1117
+ )
1118
+ user_key, assistant_key = self.capture_turn(
1119
+ service,
1120
+ source="hermes",
1121
+ session="jinyuan-plan",
1122
+ turn="feedback",
1123
+ user_event="jinyuan-plan-user",
1124
+ assistant_event="jinyuan-plan-assistant",
1125
+ user="金元顺安客户对实施计划提出补充建议。",
1126
+ assistant="需增加数据迁移、安全基线、漏洞扫描和回滚演练。",
1127
+ )
1128
+ item = self.candidate(
1129
+ "jinyuan-plan-feedback",
1130
+ [user_key, assistant_key],
1131
+ memory="金元顺安实施计划需补充数据迁移、安全基线、漏洞扫描和回滚演练。",
1132
+ type="fact",
1133
+ )
1134
+ item["scopes"] = ["project:金元顺安"]
1135
+ backend.responses.extend(
1136
+ [
1137
+ self.gate([item]),
1138
+ self.summary(
1139
+ user_key,
1140
+ title=existing.title,
1141
+ body="客户建议补充数据迁移、安全基线、漏洞扫描和回滚演练。",
1142
+ tags=["金元顺安", "调整建议"],
1143
+ type="project",
1144
+ scopes=["project:金元顺安"],
1145
+ update_memory_id=existing.memory_id,
1146
+ ),
1147
+ ]
1148
+ )
1149
+
1150
+ result = service.process(source="hermes", session_id="jinyuan-plan", model=backend)
1151
+
1152
+ self.assertEqual(result["memory_ids"], [existing.memory_id])
1153
+ current = service.read(existing.memory_id)
1154
+ self.assertIn(old_body, current.body)
1155
+ self.assertIn("回滚演练", current.body)
1156
+ self.assertEqual(current.title, existing.title)
1157
+ self.assertEqual(current.tags, ["金元顺安", "达梦", "东方通", "负责人", "调整建议"])
1158
+ history = service._read_memories_unlocked("history")
1159
+ self.assertEqual(len(history), 1)
1160
+ self.assertEqual(history[0].memory.body, old_body)
1161
+
1162
+ def test_explicit_project_plan_replacement_does_not_merge_conflicting_body(self):
1163
+ target = self.service().create_memory(
1164
+ memory_id="mem-plan-replacement",
1165
+ title="北辰项目实施计划",
1166
+ body="北辰项目数据库采用达梦。",
1167
+ tags=["达梦"],
1168
+ type="project",
1169
+ scopes=["project:北辰"],
1170
+ )
1171
+ summary = {
1172
+ "title": target.title,
1173
+ "body": "北辰项目数据库改为PostgreSQL。",
1174
+ "tags": ["PostgreSQL"],
1175
+ "type": "project",
1176
+ }
1177
+
1178
+ merged = Processor._merge_additive_project_plan_update(
1179
+ {"memory": "客户建议将北辰项目数据库改为PostgreSQL。"},
1180
+ summary,
1181
+ target,
1182
+ )
1183
+
1184
+ self.assertEqual(merged, summary)
1185
+
1103
1186
  def test_summary_update_target_mismatch_is_retried_before_writing(self):
1104
1187
  backend = QueueBackend()
1105
1188
  service = self.service(backend, name="email-update-mismatch")
@@ -1252,7 +1335,10 @@ class StageB2ATest(unittest.TestCase):
1252
1335
 
1253
1336
  self.assertEqual(result["memories_written"], 1)
1254
1337
  self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize"])
1255
- self.assertEqual(service.read(old.memory_id).body, "金元顺安实施计划新增部署要求。")
1338
+ self.assertEqual(
1339
+ service.read(old.memory_id).body,
1340
+ "金元顺安实施计划按原方案执行。\n\n金元顺安实施计划新增部署要求。",
1341
+ )
1256
1342
  self.assertEqual(len(service.vault.list_markdown("history")), 1)
1257
1343
 
1258
1344
  def test_same_project_plan_fact_candidate_is_reconciled_to_immutable_project_target(self):
@@ -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.12"},
107
+ {"name": "memleaf", "version": "0.2.13"},
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.12")
27
+ self.assertEqual(self.project["version"], "0.2.13")
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