memleaf 0.2.24__tar.gz → 0.2.25__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 (115) hide show
  1. {memleaf-0.2.24 → memleaf-0.2.25}/CHANGELOG.md +11 -0
  2. {memleaf-0.2.24/src/memleaf.egg-info → memleaf-0.2.25}/PKG-INFO +3 -3
  3. {memleaf-0.2.24 → memleaf-0.2.25}/README.en.md +2 -2
  4. {memleaf-0.2.24 → memleaf-0.2.25}/README.md +2 -2
  5. {memleaf-0.2.24 → memleaf-0.2.25}/pyproject.toml +1 -1
  6. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/processing.py +471 -9
  9. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/prompts.py +12 -1
  10. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/validation.py +2 -2
  11. {memleaf-0.2.24 → memleaf-0.2.25/src/memleaf.egg-info}/PKG-INFO +3 -3
  12. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf.egg-info/SOURCES.txt +2 -0
  13. memleaf-0.2.25/tests/test_email_actionable_coverage.py +369 -0
  14. memleaf-0.2.25/tests/test_global_todo_query_no_write.py +444 -0
  15. {memleaf-0.2.24 → memleaf-0.2.25}/IMPLEMENTATION_PLAN.md +0 -0
  16. {memleaf-0.2.24 → memleaf-0.2.25}/LICENSE +0 -0
  17. {memleaf-0.2.24 → memleaf-0.2.25}/MANIFEST.in +0 -0
  18. {memleaf-0.2.24 → memleaf-0.2.25}/RELEASE_CHECKLIST.md +0 -0
  19. {memleaf-0.2.24 → memleaf-0.2.25}/examples/README.md +0 -0
  20. {memleaf-0.2.24 → memleaf-0.2.25}/examples/basic_usage.py +0 -0
  21. {memleaf-0.2.24 → memleaf-0.2.25}/examples/mcp_stdio.ndjson +0 -0
  22. {memleaf-0.2.24 → memleaf-0.2.25}/install.ps1 +0 -0
  23. {memleaf-0.2.24 → memleaf-0.2.25}/install.sh +0 -0
  24. {memleaf-0.2.24 → memleaf-0.2.25}/setup.cfg +0 -0
  25. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/__main__.py +0 -0
  26. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/adapters/__init__.py +0 -0
  27. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/adapters/antigravity.py +0 -0
  28. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/adapters/base.py +0 -0
  29. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/adapters/codex.py +0 -0
  30. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/adapters/hermes.py +0 -0
  31. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/budget.py +0 -0
  32. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/capture.py +0 -0
  33. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/cli.py +0 -0
  34. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/compaction.py +0 -0
  35. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/config.py +0 -0
  36. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/credentials.py +0 -0
  37. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/frontmatter.py +0 -0
  38. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/hermes_provider/README.md +0 -0
  39. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/hermes_provider/__init__.py +0 -0
  40. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/hermes_runtime.py +0 -0
  41. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/host_events.py +0 -0
  42. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/host_runtime.py +0 -0
  43. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/inbox.py +0 -0
  44. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/index.py +0 -0
  45. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/installer.py +0 -0
  46. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/llm/__init__.py +0 -0
  47. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/llm/base.py +0 -0
  48. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/llm/claude_compatible.py +0 -0
  49. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/llm/gemini.py +0 -0
  50. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/llm/openai_compatible.py +0 -0
  51. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/llm/router.py +0 -0
  52. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/locking.py +0 -0
  53. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/mcp_server.py +0 -0
  54. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/memory_writer.py +0 -0
  55. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/model_discovery.py +0 -0
  56. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/models.py +0 -0
  57. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/native_index.py +0 -0
  58. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/native_registration.py +0 -0
  59. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/redaction.py +0 -0
  60. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/retrieval.py +0 -0
  61. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/retrieval_gate.py +0 -0
  62. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/scope_maintenance.py +0 -0
  63. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/scope_state.py +0 -0
  64. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/service.py +0 -0
  65. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/vault.py +0 -0
  66. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf.egg-info/dependency_links.txt +0 -0
  67. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf.egg-info/entry_points.txt +0 -0
  68. {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf.egg-info/top_level.txt +0 -0
  69. {memleaf-0.2.24 → memleaf-0.2.25}/tests/__init__.py +0 -0
  70. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_admission_noise.py +0 -0
  71. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_codex_install.py +0 -0
  72. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_codex_native_cli.py +0 -0
  73. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_context_budget.py +0 -0
  74. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_credential_safety.py +0 -0
  75. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_cross_host_acceptance.py +0 -0
  76. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_cross_turn_dedupe_regressions.py +0 -0
  77. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_extraction_quality_regressions.py +0 -0
  78. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_global_todo_acceptance.py +0 -0
  79. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_global_todo_retrieval.py +0 -0
  80. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_hermes_native_registration.py +0 -0
  81. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_hermes_provider.py +0 -0
  82. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_hermes_runtime_install.py +0 -0
  83. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_hermes_stdio_transport.py +0 -0
  84. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_host_events.py +0 -0
  85. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_host_runtime_contract.py +0 -0
  86. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_install.py +0 -0
  87. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_maintenance_v2.py +0 -0
  88. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_model_discovery.py +0 -0
  89. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_pypi_install.py +0 -0
  90. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_retrieval_gate.py +0 -0
  91. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_retrieval_v2.py +0 -0
  92. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_session_lineage.py +0 -0
  93. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_a.py +0 -0
  94. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b1.py +0 -0
  95. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b2a.py +0 -0
  96. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b2b.py +0 -0
  97. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3a_commit.py +0 -0
  98. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3a_contract.py +0 -0
  99. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3b_native_context.py +0 -0
  100. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3b_native_index.py +0 -0
  101. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3b_scope.py +0 -0
  102. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3c_retrieval.py +0 -0
  103. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  104. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_c1_mcp.py +0 -0
  105. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_c2_init.py +0 -0
  106. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_c3_packaging.py +0 -0
  107. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_todo_state_recovery.py +0 -0
  108. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_update_target_recovery.py +0 -0
  109. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_upgrade_preserves_vault.py +0 -0
  110. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_v023_scope_correction.py +0 -0
  111. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_v2_gate_limits.py +0 -0
  112. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_v2_host_flow.py +0 -0
  113. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_v2_mcp_flow.py +0 -0
  114. {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_v2_nomatch_semantics.py +0 -0
  115. {memleaf-0.2.24 → memleaf-0.2.25}/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.25 — 2026-09-04
6
+
7
+ - Keep global todo questions and cross-session recaps strictly read-only,
8
+ without creating memories, appending sources, or producing history; an
9
+ explicit user assertion before a follow-up question remains write-eligible.
10
+ - Recover project-grounded, explicitly counted repair items omitted from a
11
+ mailbox extraction gate, split concrete detail lines into independent todos,
12
+ and reuse an existing same-scope item when it is an update.
13
+ - Record compact candidate-level `CREATE`, `UPDATE`, `NO_CHANGE`, and
14
+ `DEFERRED` dispositions in the processed-turn ledger for auditable coverage.
15
+
5
16
  ## 0.2.24 — 2026-09-04
6
17
 
7
18
  - Make Hermes MCP installation reliably persistent: write `mcp_servers.memleaf` through Hermes' canonical `config set` interface, disable the entry while command/arguments are updated, then read `config.yaml` back and require an exact command plus `--vault` match before reporting success.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.24
3
+ Version: 0.2.25
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.24。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.24 通过 PyPI 分发。
26
+ > **当前版本:0.2.25。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.25 通过 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.24.**
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.24 is distributed through PyPI.
7
+ > **Version: 0.2.25.**
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.25 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.24。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.24 通过 PyPI 分发。
7
+ > **当前版本:0.2.25。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.25 通过 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.24"
7
+ version = "0.2.25"
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.24"
3
+ __version__ = "0.2.25"
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.24
2
+ version: 0.2.25
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -93,8 +93,17 @@ _DIAGNOSTIC_CANDIDATE_REQUIRED = frozenset(
93
93
  )
94
94
 
95
95
  _READ_ONLY_QUERY_MARKERS = (
96
- "有没有", "有哪些", "是什么", "是谁", "多少", "查询", "查一下", "查下",
97
- "看看", "查看", "汇总", "列出", "告诉我", "what", "which", "who", "show me",
96
+ "有没有", "有什么", "有哪些", "是什么", "是谁", "多少", "哪个", "哪些",
97
+ "什么时候", "何时", "如何", "怎么", "为什么", "是否", "能否", "可否",
98
+ "查询", "查一下", "查下", "看看", "看下", "查看", "汇总", "列出", "告诉我",
99
+ "what", "which", "who", "show me", "list",
100
+ )
101
+ # A mailbox scan is a query-shaped request, but its answer may contain new
102
+ # external facts (for example, a customer asks for two fixes). Do not apply
103
+ # the pure-existing-memory no-write rule to those requests; the gate still
104
+ # decides which individual facts/actions are durable.
105
+ _EXTERNAL_SOURCE_QUERY_MARKERS = (
106
+ "邮箱", "收件箱", "mailbox", "inbox",
98
107
  )
99
108
  _USER_ASSERTION_MARKERS = (
100
109
  "已确认", "确认了", "已决定", "我决定", "改为", "更新为", "变更为", "以后",
@@ -140,6 +149,16 @@ _REWORK_ACTION_MARKERS = (
140
149
  "修改", "修复", "整改", "补充", "补上", "补齐", "调整", "改下", "改一下", "改成", "更改",
141
150
  "完善", "修订", "重新", "更新", "rework", "revise", "redraw", "fix", "update", "change",
142
151
  )
152
+ _EXPLICIT_REPAIR_COUNT_RE = re.compile(
153
+ r"(?:\d+|[一二三四五六七八九十百千万两俩]+)\s*"
154
+ r"(?:个|项|条|处|点|件|问题|缺陷|模块)",
155
+ re.IGNORECASE,
156
+ )
157
+ _EXPLICIT_REPAIR_RE = re.compile(
158
+ r"(?:需|需要|须|待|仍需|还需|尚需|有待|要)\s*"
159
+ r"(?:修正|修复|改正|整改|修改|更正|补充|完善|fix|repair|remediate)",
160
+ re.IGNORECASE,
161
+ )
143
162
 
144
163
 
145
164
 
@@ -480,14 +499,239 @@ def _automatic_read_only_query(events: Iterable[Mapping[str, Any]]) -> bool:
480
499
  if not user_text:
481
500
  return False
482
501
  folded = user_text.casefold()
483
- query_prefix = re.sub(r"^(?:请|帮我|麻烦|请帮我)\s*", "", folded)
484
- asks = "?" in user_text or "?" in user_text or any(
485
- query_prefix.startswith(marker) or query_prefix.startswith(f"我{marker}")
486
- for marker in _READ_ONLY_QUERY_MARKERS
502
+ query_prefix = folded
503
+ # Requests often stack polite prefixes ("请帮我看下…"). Strip them
504
+ # repeatedly so the actual interrogative is visible to the classifier.
505
+ while True:
506
+ stripped = re.sub(r"^(?:请|帮我|麻烦|请问|麻烦你)\s*", "", query_prefix)
507
+ if stripped == query_prefix:
508
+ break
509
+ query_prefix = stripped
510
+ interrogative_markers = {
511
+ "有没有", "有什么", "有哪些", "是什么", "是谁", "多少", "哪个", "哪些",
512
+ "什么时候", "何时", "如何", "怎么", "为什么", "是否", "能否", "可否",
513
+ "what", "which", "who",
514
+ }
515
+ imperative_markers = {
516
+ "查询", "查一下", "查下", "看看", "看下", "查看", "汇总", "列出", "告诉我",
517
+ "show me",
518
+ }
519
+ asks = bool(
520
+ re.search(r"[??]", user_text)
521
+ or re.search(r"(?:吗|么|呢)\s*$", user_text)
522
+ or any(marker in query_prefix for marker in interrogative_markers)
523
+ or bool(re.search(r"\b(?:what|which|who|list)\b", query_prefix, flags=re.IGNORECASE))
524
+ or any(
525
+ re.search(
526
+ rf"(?:^|[。!?!?;;::])\s*{re.escape(marker)}",
527
+ query_prefix,
528
+ flags=re.IGNORECASE,
529
+ )
530
+ for marker in imperative_markers
531
+ )
487
532
  )
488
533
  if not asks:
489
534
  return False
490
- return not any(marker in folded for marker in _USER_ASSERTION_MARKERS)
535
+ # A mailbox read can surface new customer facts/actions. It is not a
536
+ # query over existing knowledge, even though the user's wording is a
537
+ # question; let the gate admit concrete project-local items from it.
538
+ if any(marker in folded for marker in _EXTERNAL_SOURCE_QUERY_MARKERS):
539
+ return False
540
+
541
+ # An assertion-shaped phrase can be part of the requested result rather
542
+ # than a new fact. For example, both “有什么是我必须完成的” and
543
+ # “我有哪些已确认的需求” are pure queries. Conversely,
544
+ # “这个需求必须完成,什么时候开始?” contains a new
545
+ # assertion before the question and must remain write-eligible.
546
+ question_words = ("谁", "什么", "哪些", "哪个", "哪项", "多少", "吗", "么", "呢")
547
+ for marker in _USER_ASSERTION_MARKERS:
548
+ start = 0
549
+ while True:
550
+ position = folded.find(marker, start)
551
+ if position < 0:
552
+ break
553
+ suffix = folded[position + len(marker) :]
554
+ clause_start = max(
555
+ (folded.rfind(separator, 0, position) for separator in "。!?!?;;,,"),
556
+ default=-1,
557
+ ) + 1
558
+ later_separators = [
559
+ folded.find(separator, position + len(marker))
560
+ for separator in "。!?!?;;,,"
561
+ ]
562
+ clause_end = min(
563
+ (value for value in later_separators if value >= 0),
564
+ default=len(folded),
565
+ )
566
+ question_before = any(
567
+ folded.find(word, clause_start, position) >= 0
568
+ for word in interrogative_markers
569
+ )
570
+ question_after = marker == "必须" and any(
571
+ folded.find(word, position + len(marker), clause_end) >= 0
572
+ for word in (*interrogative_markers, *question_words)
573
+ )
574
+ if (
575
+ not question_before
576
+ and not question_after
577
+ and not any(suffix.startswith(word) for word in question_words)
578
+ ):
579
+ return False
580
+ start = position + len(marker)
581
+ return True
582
+
583
+
584
+ def _repair_line_text(value: str) -> str:
585
+ """Keep a deterministic repair candidate focused on its action."""
586
+
587
+ text = re.sub(r"^\s*(?:[-*•]|\d+[.)、])\s*", "", value).strip()
588
+ text = re.sub(r"\*+", "", text)
589
+ text = re.sub(
590
+ r"[((][^()()]{0,120}(?:附件|文档|下载|邮件|message|attachment)[^()()]{0,120}[))]",
591
+ "",
592
+ text,
593
+ flags=re.IGNORECASE,
594
+ )
595
+ text = re.sub(r"(?:她|他|对方|客户)(?:说|反馈|回复)\s*[::]?", "", text)
596
+ text = re.sub(r"[“\"]([^”\"]+)[”\"]", r"\1", text)
597
+ text = re.sub(r"\s+", " ", text).strip(" \t,,;;。.!!??::")
598
+ return text
599
+
600
+
601
+ def _deterministic_project_repair_candidates(
602
+ events: Iterable[Mapping[str, Any]],
603
+ scope_registry: Mapping[str, Any] | None,
604
+ existing_candidates: Iterable[Mapping[str, Any]],
605
+ ) -> list[dict[str, Any]]:
606
+ """Recover explicit counted project repairs omitted by the gate.
607
+
608
+ Mailbox summaries frequently put the project name in a numbered heading
609
+ and the concrete repair count in the following bullet. The normal gate
610
+ can conservatively discard that attachment-shaped bullet; this narrow
611
+ recovery only handles a candidate-local, registered project plus an
612
+ explicit quantity and an unfinished repair verb. It never promotes a
613
+ generic attachment or follow-up line.
614
+ """
615
+
616
+ if not isinstance(scope_registry, Mapping):
617
+ return []
618
+ existing_topics: dict[str, list[str]] = {}
619
+ for candidate in existing_candidates:
620
+ if not isinstance(candidate, Mapping) or candidate.get("worth") is not True:
621
+ continue
622
+ text = candidate.get("memory")
623
+ if not isinstance(text, str):
624
+ continue
625
+ if not _EXPLICIT_REPAIR_COUNT_RE.search(text) or not _EXPLICIT_REPAIR_RE.search(text):
626
+ continue
627
+ occurrences = _project_scope_occurrences(text, scope_registry)
628
+ if occurrences is None:
629
+ continue
630
+ for _start, _end, scope in occurrences:
631
+ existing_topics.setdefault(scope.casefold(), []).append(normalize_term(text))
632
+
633
+ # Keep the extraction candidate-local. A heading such as “6. 摩根基金”
634
+ # commonly precedes the actual repair bullet, so retain that scope until
635
+ # the next project heading (or a separator) and collect only explicit
636
+ # remediation bullets underneath a counted repair statement.
637
+ sections: list[tuple[str, str, str, list[str]]] = []
638
+ for event in events:
639
+ if str(event.get("role", "")).casefold() not in {"user", "assistant"}:
640
+ continue
641
+ content = event.get("content")
642
+ evidence_event_id = event.get("event_key")
643
+ if not isinstance(content, str) or not content.strip() or not isinstance(evidence_event_id, str):
644
+ continue
645
+ current_scope: str | None = None
646
+ aggregate_line: str | None = None
647
+ aggregate_scope: str | None = None
648
+ detail_lines: list[str] = []
649
+
650
+ def flush_section() -> None:
651
+ nonlocal aggregate_line, aggregate_scope, detail_lines
652
+ if aggregate_line is not None and aggregate_scope is not None:
653
+ sections.append(
654
+ (evidence_event_id, aggregate_scope, aggregate_line, list(detail_lines))
655
+ )
656
+ aggregate_line = None
657
+ aggregate_scope = None
658
+ detail_lines = []
659
+
660
+ for line in content.splitlines():
661
+ line_text = line.strip()
662
+ if re.fullmatch(r"[-—]{3,}", line_text):
663
+ flush_section()
664
+ current_scope = None
665
+ continue
666
+ if not line_text:
667
+ continue
668
+ line_occurrences = _project_scope_occurrences(line_text, scope_registry)
669
+ line_scopes = list(dict.fromkeys(item[2] for item in (line_occurrences or [])))
670
+ if len(line_scopes) == 1:
671
+ if current_scope is not None and line_scopes[0].casefold() != current_scope.casefold():
672
+ flush_section()
673
+ current_scope = line_scopes[0]
674
+ elif len(line_scopes) > 1:
675
+ flush_section()
676
+ current_scope = None
677
+ if _EXPLICIT_REPAIR_COUNT_RE.search(line_text) and _EXPLICIT_REPAIR_RE.search(line_text):
678
+ flush_section()
679
+ if current_scope is not None:
680
+ aggregate_line = line_text
681
+ aggregate_scope = current_scope
682
+ continue
683
+ # Only explicit repair/remediation language can turn a following
684
+ # bullet into an independent item. Generic “见附件/需跟进” text
685
+ # remains outside this recovery path.
686
+ if (
687
+ aggregate_line is not None
688
+ and current_scope is not None
689
+ and current_scope.casefold() == aggregate_scope.casefold()
690
+ and _EXPLICIT_REPAIR_RE.search(line_text)
691
+ ):
692
+ detail_lines.append(line_text)
693
+ flush_section()
694
+
695
+ recovered: list[dict[str, Any]] = []
696
+ emitted_keys: set[tuple[str, str]] = set()
697
+ for evidence_event_id, scope, aggregate_line, detail_lines in sections:
698
+ lines = detail_lines or [aggregate_line]
699
+ scope_name = scope.partition(":")[2]
700
+ for line in lines:
701
+ cleaned = _repair_line_text(line)
702
+ if not cleaned:
703
+ continue
704
+ if scope_name.casefold() not in cleaned.casefold():
705
+ cleaned = f"{scope_name}:{cleaned}"
706
+ normalized = normalize_term(cleaned)
707
+ key = (scope.casefold(), normalized)
708
+ if not normalized or key in emitted_keys:
709
+ continue
710
+ # A model candidate covering this exact repair already has a
711
+ # normal UPDATE/NO_CHANGE path; do not create a sibling. Other
712
+ # repairs in the same project remain independently recoverable.
713
+ if any(
714
+ normalized in existing_text or existing_text in normalized
715
+ for existing_text in existing_topics.get(scope.casefold(), [])
716
+ ):
717
+ continue
718
+ candidate_id = "email-repair-" + hashlib.sha256(
719
+ f"{evidence_event_id}|{scope}|{normalized}".encode("utf-8")
720
+ ).hexdigest()[:16]
721
+ recovered.append(
722
+ {
723
+ "candidate_id": candidate_id,
724
+ "memory": cleaned,
725
+ "evidence_event_ids": [evidence_event_id],
726
+ "duplicate": False,
727
+ "worth": True,
728
+ "type": "todo",
729
+ "scopes": [scope],
730
+ "scope_source": "model",
731
+ }
732
+ )
733
+ emitted_keys.add(key)
734
+ return recovered
491
735
 
492
736
 
493
737
  def _automatic_transient_memory(value: Any) -> bool:
@@ -1160,6 +1404,84 @@ class Processor:
1160
1404
  # exposing an uncommitted body outside this processor.
1161
1405
  self._planned_related: list[dict[str, Any]] = []
1162
1406
  self._deferred_by_turn: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
1407
+ self._dispositions_by_turn: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
1408
+
1409
+ def _record_disposition(
1410
+ self,
1411
+ turn_ref: tuple[str, str, str],
1412
+ candidate: Mapping[str, Any],
1413
+ disposition: str,
1414
+ *,
1415
+ reason: str | None = None,
1416
+ memory_id: str | None = None,
1417
+ ) -> None:
1418
+ """Record a compact, candidate-level processing outcome.
1419
+
1420
+ The ledger deliberately keeps only the stable candidate id and the
1421
+ outcome metadata. Candidate text belongs in the inbox/model
1422
+ diagnostics, not in the durable processing index.
1423
+ """
1424
+
1425
+ candidate_id = candidate.get("candidate_id")
1426
+ if not isinstance(candidate_id, str) or not candidate_id:
1427
+ return
1428
+ if disposition not in {"CREATE", "UPDATE", "NO_CHANGE", "DEFERRED"}:
1429
+ return
1430
+ value: dict[str, Any] = {
1431
+ "candidate_id": candidate_id,
1432
+ "disposition": disposition,
1433
+ }
1434
+ if isinstance(reason, str) and reason:
1435
+ value["reason"] = reason
1436
+ if isinstance(memory_id, str) and memory_id:
1437
+ value["memory_id"] = memory_id
1438
+ values = self._dispositions_by_turn.setdefault(turn_ref, [])
1439
+ candidate_key = candidate_id.casefold()
1440
+ for index, previous in enumerate(values):
1441
+ if (
1442
+ isinstance(previous, Mapping)
1443
+ and isinstance(previous.get("candidate_id"), str)
1444
+ and previous["candidate_id"].casefold() == candidate_key
1445
+ ):
1446
+ values[index] = value
1447
+ return
1448
+ values.append(value)
1449
+
1450
+ def _record_request_disposition(
1451
+ self,
1452
+ request: Mapping[str, Any],
1453
+ disposition: str,
1454
+ *,
1455
+ reason: str | None = None,
1456
+ memory_id: str | None = None,
1457
+ ) -> None:
1458
+ turn = request.get("turn")
1459
+ if not isinstance(turn, InboxTurn):
1460
+ return
1461
+ self._record_disposition(
1462
+ (turn.source, turn.session_id, turn.turn_key),
1463
+ request,
1464
+ disposition,
1465
+ reason=reason,
1466
+ memory_id=memory_id,
1467
+ )
1468
+
1469
+ def _candidate_dispositions(
1470
+ self,
1471
+ snapshots: Iterable[_Snapshot] = (),
1472
+ ) -> list[dict[str, Any]]:
1473
+ """Return compact outcomes in the same order as processed turns."""
1474
+
1475
+ result: list[dict[str, Any]] = []
1476
+ for snapshot in snapshots:
1477
+ turn = snapshot.turn
1478
+ turn_ref = (turn.source, turn.session_id, turn.turn_key)
1479
+ result.extend(
1480
+ dict(item)
1481
+ for item in self._dispositions_by_turn.get(turn_ref, [])
1482
+ if isinstance(item, Mapping)
1483
+ )
1484
+ return result
1163
1485
 
1164
1486
  def _resolve_backend(self, model: Any = None, router: Any = None) -> Any:
1165
1487
  backend = router if router is not None else model
@@ -2755,6 +3077,19 @@ class Processor:
2755
3077
  scopes: Optional[Iterable[str]] = None,
2756
3078
  scope_source: Any = _UNSET,
2757
3079
  ) -> None:
3080
+ self._record_disposition(
3081
+ turn_ref,
3082
+ candidate,
3083
+ "DEFERRED",
3084
+ reason=reason,
3085
+ memory_id=(
3086
+ candidate.get("update_memory_id")
3087
+ if isinstance(candidate.get("update_memory_id"), str)
3088
+ else candidate.get("duplicate_memory_id")
3089
+ if isinstance(candidate.get("duplicate_memory_id"), str)
3090
+ else None
3091
+ ),
3092
+ )
2758
3093
  self._deferred_by_turn[turn_ref].append(
2759
3094
  {
2760
3095
  "candidate_id": str(candidate["candidate_id"]),
@@ -3237,6 +3572,23 @@ class Processor:
3237
3572
  "turn_index": turn.turn_index,
3238
3573
  },
3239
3574
  )
3575
+ # A mailbox summary may put a project in a numbered heading and the
3576
+ # concrete “N items need repair” action in its following bullet. If
3577
+ # the gate conservatively omits that attachment-shaped item, recover
3578
+ # only this narrow, project-grounded action before candidate-level
3579
+ # dedupe. Pure existing-memory queries remain write-free.
3580
+ if not _automatic_read_only_query(events):
3581
+ recovered_repairs = _deterministic_project_repair_candidates(
3582
+ events,
3583
+ validation_scope_registry,
3584
+ gate.get("candidates", []),
3585
+ )
3586
+ if recovered_repairs:
3587
+ gate = dict(gate)
3588
+ gate["candidates"] = [
3589
+ *gate.get("candidates", []),
3590
+ *recovered_repairs,
3591
+ ]
3240
3592
  # A model may correctly treat the rest of a mixed turn as a query and
3241
3593
  # still miss the user's explicit completion update. Recover only one
3242
3594
  # uniquely related active todo; all other gate decisions stay intact.
@@ -3415,6 +3767,17 @@ class Processor:
3415
3767
  correction_plan = scope_correction_plans.get(candidate_id_key)
3416
3768
  detached_update_target_id = detached_update_target_ids.get(candidate_id_key)
3417
3769
  defer_reason = candidate.get("_defer_reason")
3770
+ # A pure existing-memory query must not leave even a deferred
3771
+ # candidate behind when the model mislabels its recap. Explicit
3772
+ # todo state recovery is the only write-eligible exception.
3773
+ if read_only_query and recovery is None:
3774
+ self._record_disposition(
3775
+ turn_ref,
3776
+ candidate,
3777
+ "NO_CHANGE",
3778
+ reason="read_only_query",
3779
+ )
3780
+ continue
3418
3781
  if isinstance(defer_reason, str) and defer_reason in {"mixed_future_use", "scope_conflict"}:
3419
3782
  self._defer_candidate(
3420
3783
  turn_ref,
@@ -3436,16 +3799,33 @@ class Processor:
3436
3799
  )
3437
3800
  continue
3438
3801
  if candidate.get("worth") and (
3439
- (read_only_query and recovery is None)
3440
- or _automatic_transient_memory(candidate.get("memory"))
3802
+ _automatic_transient_memory(candidate.get("memory"))
3441
3803
  ):
3804
+ self._record_disposition(
3805
+ turn_ref,
3806
+ candidate,
3807
+ "NO_CHANGE",
3808
+ reason="transient",
3809
+ )
3442
3810
  continue
3443
3811
  # A combined mailbox/daily digest is not an atomic memory. If a
3444
3812
  # concrete action was worth retaining, the gate must emit it as
3445
3813
  # its own candidate; the aggregate shell itself is NO_CHANGE.
3446
3814
  if candidate.get("worth") and is_aggregate_operational_text(candidate.get("memory")):
3815
+ self._record_disposition(
3816
+ turn_ref,
3817
+ candidate,
3818
+ "NO_CHANGE",
3819
+ reason="aggregate",
3820
+ )
3447
3821
  continue
3448
3822
  if candidate.get("worth") and is_attachment_followup_only_text(candidate.get("memory")):
3823
+ self._record_disposition(
3824
+ turn_ref,
3825
+ candidate,
3826
+ "NO_CHANGE",
3827
+ reason="attachment_followup",
3828
+ )
3449
3829
  continue
3450
3830
  candidate_scopes = list(candidate["scopes"])
3451
3831
  # An automatic candidate with no reliable project attribution is
@@ -3709,6 +4089,20 @@ class Processor:
3709
4089
  and observed_scope not in observed_scopes
3710
4090
  ):
3711
4091
  observed_scopes.append(observed_scope)
4092
+ self._record_disposition(
4093
+ turn_ref,
4094
+ candidate,
4095
+ "NO_CHANGE",
4096
+ reason="duplicate",
4097
+ memory_id=duplicate_memory_id,
4098
+ )
4099
+ else:
4100
+ self._record_disposition(
4101
+ turn_ref,
4102
+ candidate,
4103
+ "NO_CHANGE",
4104
+ reason="not_worthy",
4105
+ )
3712
4106
  continue
3713
4107
 
3714
4108
  gate_update_target = candidate.get("update_memory_id")
@@ -3797,14 +4191,37 @@ class Processor:
3797
4191
  )
3798
4192
  continue
3799
4193
  if summary.get("decision") == NO_CHANGE_DECISION:
4194
+ self._record_disposition(
4195
+ turn_ref,
4196
+ candidate,
4197
+ "NO_CHANGE",
4198
+ reason="summary_no_change",
4199
+ memory_id=(
4200
+ summary.get("update_memory_id")
4201
+ if isinstance(summary.get("update_memory_id"), str)
4202
+ else None
4203
+ ),
4204
+ )
3800
4205
  continue
3801
4206
  if is_attachment_followup_only_text(
3802
4207
  f"{summary.get('title', '')}\n{summary.get('body', '')}"
3803
4208
  ):
4209
+ self._record_disposition(
4210
+ turn_ref,
4211
+ candidate,
4212
+ "NO_CHANGE",
4213
+ reason="attachment_followup",
4214
+ )
3804
4215
  continue
3805
4216
  if _automatic_transient_memory(
3806
4217
  f"{summary.get('title', '')}\n{summary.get('body', '')}"
3807
4218
  ):
4219
+ self._record_disposition(
4220
+ turn_ref,
4221
+ candidate,
4222
+ "NO_CHANGE",
4223
+ reason="transient",
4224
+ )
3808
4225
  continue
3809
4226
  if gate_update_target is not None:
3810
4227
  summary_update_target = summary.get("update_memory_id")
@@ -3853,8 +4270,24 @@ class Processor:
3853
4270
  candidate_related,
3854
4271
  ignore_memory_ids=current_turn_request_ids,
3855
4272
  ):
4273
+ self._record_disposition(
4274
+ turn_ref,
4275
+ candidate,
4276
+ "NO_CHANGE",
4277
+ reason="already_covered",
4278
+ )
3856
4279
  continue
3857
4280
  requests.append(pending_request)
4281
+ self._record_disposition(
4282
+ turn_ref,
4283
+ candidate,
4284
+ "UPDATE" if not final_is_create else "CREATE",
4285
+ memory_id=(
4286
+ summary_update_target
4287
+ if not final_is_create
4288
+ else pending_request["memory_id"]
4289
+ ),
4290
+ )
3858
4291
  for observed_scope in summary["scopes"]:
3859
4292
  if (
3860
4293
  isinstance(observed_scope, str)
@@ -4074,6 +4507,34 @@ class Processor:
4074
4507
  self.writer.last_metadata_merged = 0
4075
4508
  self.writer.last_noop_memory_ids = set()
4076
4509
  noop_memory_ids = self.writer.last_noop_memory_ids
4510
+ for request in all_requests:
4511
+ request_memory_id = request.get("memory_id")
4512
+ if request_memory_id not in noop_memory_ids:
4513
+ continue
4514
+ duplicate_id = request.get("duplicate_memory_id")
4515
+ summary = request.get("summary")
4516
+ update_id = (
4517
+ summary.get("update_memory_id")
4518
+ if isinstance(summary, Mapping)
4519
+ else None
4520
+ )
4521
+ target_id = (
4522
+ duplicate_id
4523
+ if isinstance(duplicate_id, str) and duplicate_id
4524
+ else update_id
4525
+ if isinstance(update_id, str) and update_id
4526
+ else None
4527
+ )
4528
+ self._record_request_disposition(
4529
+ request,
4530
+ "NO_CHANGE",
4531
+ reason=(
4532
+ "duplicate"
4533
+ if isinstance(duplicate_id, str) and duplicate_id
4534
+ else "unchanged"
4535
+ ),
4536
+ memory_id=target_id,
4537
+ )
4077
4538
  if prepared_scopes is not None:
4078
4539
  try:
4079
4540
  ScopeMaintainer(self.service).apply_unlocked(
@@ -4177,6 +4638,7 @@ class Processor:
4177
4638
  entry.pop("deferred_candidates", None)
4178
4639
  if not entry.get("cleanup_done_at"):
4179
4640
  entry["eligible_cleanup_at"] = _add_hours(now, cleanup_hours)
4641
+ entry["candidate_dispositions"] = self._candidate_dispositions([snapshot])
4180
4642
  state["processed_turns"] = entries
4181
4643
  watermark = max(_as_int(state.get("watermark"), 0), _as_int(snapshot.turn.turn_index, 0))
4182
4644
  state["watermark"] = watermark