memleaf 0.2.83__tar.gz → 0.2.84__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 (178) hide show
  1. {memleaf-0.2.83 → memleaf-0.2.84}/CHANGELOG.md +22 -0
  2. {memleaf-0.2.83/src/memleaf.egg-info → memleaf-0.2.84}/PKG-INFO +7 -4
  3. {memleaf-0.2.83 → memleaf-0.2.84}/README.en.md +6 -3
  4. {memleaf-0.2.83 → memleaf-0.2.84}/README.md +6 -3
  5. memleaf-0.2.84/docs/hermes-source-comparison-context.md +60 -0
  6. memleaf-0.2.84/docs/memory-lifecycle-tools.md +26 -0
  7. {memleaf-0.2.83 → memleaf-0.2.84}/pyproject.toml +1 -1
  8. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/__init__.py +1 -1
  9. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/capture.py +39 -1
  10. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/_provider.py +176 -9
  11. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  12. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/inbox.py +3 -0
  13. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_preview.py +13 -1
  14. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_prompts.py +1 -1
  15. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_protocol.py +1 -1
  16. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/mcp_server.py +71 -8
  17. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/retrieval_gate.py +19 -0
  18. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/service.py +4 -0
  19. {memleaf-0.2.83 → memleaf-0.2.84/src/memleaf.egg-info}/PKG-INFO +7 -4
  20. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf.egg-info/SOURCES.txt +2 -0
  21. {memleaf-0.2.83 → memleaf-0.2.84}/LICENSE +0 -0
  22. {memleaf-0.2.83 → memleaf-0.2.84}/MANIFEST.in +0 -0
  23. {memleaf-0.2.83 → memleaf-0.2.84}/docs/acceptance-evidence-guide.md +0 -0
  24. {memleaf-0.2.83 → memleaf-0.2.84}/docs/automatic-processing-route.md +0 -0
  25. {memleaf-0.2.83 → memleaf-0.2.84}/docs/body-compaction.md +0 -0
  26. {memleaf-0.2.83 → memleaf-0.2.84}/docs/candidate-reconciliation.md +0 -0
  27. {memleaf-0.2.83 → memleaf-0.2.84}/docs/capture-budget-design.md +0 -0
  28. {memleaf-0.2.83 → memleaf-0.2.84}/docs/config-migrations.md +0 -0
  29. {memleaf-0.2.83 → memleaf-0.2.84}/docs/core-refactor.md +0 -0
  30. {memleaf-0.2.83 → memleaf-0.2.84}/docs/development-closeout.md +0 -0
  31. {memleaf-0.2.83 → memleaf-0.2.84}/docs/evidence-retention.md +0 -0
  32. {memleaf-0.2.83 → memleaf-0.2.84}/docs/explicit-memory-update.md +0 -0
  33. {memleaf-0.2.83 → memleaf-0.2.84}/docs/extraction-latency.md +0 -0
  34. {memleaf-0.2.83 → memleaf-0.2.84}/docs/failed-run-recovery.md +0 -0
  35. {memleaf-0.2.83 → memleaf-0.2.84}/docs/gate-evidence-boundary.md +0 -0
  36. {memleaf-0.2.83 → memleaf-0.2.84}/docs/general-processing.md +0 -0
  37. {memleaf-0.2.83 → memleaf-0.2.84}/docs/hermes-mcp-runtime.md +0 -0
  38. {memleaf-0.2.83 → memleaf-0.2.84}/docs/hermes-provider-compatibility.md +0 -0
  39. {memleaf-0.2.83 → memleaf-0.2.84}/docs/host-acceptance-runbook.md +0 -0
  40. {memleaf-0.2.83 → memleaf-0.2.84}/docs/immutable-release-assets.md +0 -0
  41. {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-commit-contract.md +0 -0
  42. {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-execution.md +0 -0
  43. {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-native-comparison.md +0 -0
  44. {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-partial-recovery.md +0 -0
  45. {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-planner-preview.md +0 -0
  46. {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-runtime-contract.md +0 -0
  47. {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-scope-registration.md +0 -0
  48. {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-selected-retention.md +0 -0
  49. {memleaf-0.2.83 → memleaf-0.2.84}/docs/installed-artifact-verification.md +0 -0
  50. {memleaf-0.2.83 → memleaf-0.2.84}/docs/memory-state-contract.md +0 -0
  51. {memleaf-0.2.83 → memleaf-0.2.84}/docs/migration-preflight.md +0 -0
  52. {memleaf-0.2.83 → memleaf-0.2.84}/docs/processing-quality-acceptance.md +0 -0
  53. {memleaf-0.2.83 → memleaf-0.2.84}/docs/public-query-integrity.md +0 -0
  54. {memleaf-0.2.83 → memleaf-0.2.84}/docs/query-performance.md +0 -0
  55. {memleaf-0.2.83 → memleaf-0.2.84}/docs/remember-incremental-route.md +0 -0
  56. {memleaf-0.2.83 → memleaf-0.2.84}/docs/runtime-state-retention.md +0 -0
  57. {memleaf-0.2.83 → memleaf-0.2.84}/docs/semantic-extraction-protocol.md +0 -0
  58. {memleaf-0.2.83 → memleaf-0.2.84}/docs/source-work-contract.md +0 -0
  59. {memleaf-0.2.83 → memleaf-0.2.84}/docs/v0.2.26-processing-status.md +0 -0
  60. {memleaf-0.2.83 → memleaf-0.2.84}/examples/README.md +0 -0
  61. {memleaf-0.2.83 → memleaf-0.2.84}/examples/basic_usage.py +0 -0
  62. {memleaf-0.2.83 → memleaf-0.2.84}/examples/incremental_acceptance.json +0 -0
  63. {memleaf-0.2.83 → memleaf-0.2.84}/examples/mcp_stdio.ndjson +0 -0
  64. {memleaf-0.2.83 → memleaf-0.2.84}/examples/query_benchmark.py +0 -0
  65. {memleaf-0.2.83 → memleaf-0.2.84}/install.ps1 +0 -0
  66. {memleaf-0.2.83 → memleaf-0.2.84}/install.sh +0 -0
  67. {memleaf-0.2.83 → memleaf-0.2.84}/setup.cfg +0 -0
  68. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/__main__.py +0 -0
  69. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/acceptance.py +0 -0
  70. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/adapters/__init__.py +0 -0
  71. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/adapters/antigravity.py +0 -0
  72. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/adapters/base.py +0 -0
  73. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/adapters/codex.py +0 -0
  74. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/adapters/hermes.py +0 -0
  75. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/admission.py +0 -0
  76. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/batch_review.py +0 -0
  77. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/budget.py +0 -0
  78. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/cli.py +0 -0
  79. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/compaction.py +0 -0
  80. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/config.py +0 -0
  81. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/create_coordinator.py +0 -0
  82. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/credentials.py +0 -0
  83. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/evidence_budget.py +0 -0
  84. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/evidence_policy.py +0 -0
  85. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/evidence_structure.py +0 -0
  86. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/evidence_syntax.py +0 -0
  87. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/explicit_text_source.py +0 -0
  88. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/extraction_budget.py +0 -0
  89. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/extraction_capability.py +0 -0
  90. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/extraction_work_state.py +0 -0
  91. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/frontmatter.py +0 -0
  92. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/README.md +0 -0
  93. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/__init__.py +0 -0
  94. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
  95. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/_shared.py +0 -0
  96. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
  97. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/provider_compatibility.py +0 -0
  98. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_runtime.py +0 -0
  99. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/host_events.py +0 -0
  100. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/host_runtime.py +0 -0
  101. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_commit.py +0 -0
  102. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_dates.py +0 -0
  103. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_execution.py +0 -0
  104. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_explicit.py +0 -0
  105. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_failed_recovery.py +0 -0
  106. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_journal.py +0 -0
  107. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_native.py +0 -0
  108. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_partial.py +0 -0
  109. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_recovery.py +0 -0
  110. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_run_state.py +0 -0
  111. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_runtime.py +0 -0
  112. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_scopes.py +0 -0
  113. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_selection.py +0 -0
  114. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/index.py +0 -0
  115. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/inspection.py +0 -0
  116. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/installer.py +0 -0
  117. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/legacy_update_journal.py +0 -0
  118. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/__init__.py +0 -0
  119. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/base.py +0 -0
  120. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/claude_compatible.py +0 -0
  121. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/gemini.py +0 -0
  122. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/openai_compatible.py +0 -0
  123. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/router.py +0 -0
  124. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/thinking.py +0 -0
  125. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/locking.py +0 -0
  126. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/memory_commit.py +0 -0
  127. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/memory_planner.py +0 -0
  128. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/memory_retraction.py +0 -0
  129. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/memory_update.py +0 -0
  130. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/memory_writer.py +0 -0
  131. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/migration.py +0 -0
  132. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/model_capabilities.py +0 -0
  133. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/model_discovery.py +0 -0
  134. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/model_execution.py +0 -0
  135. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/models.py +0 -0
  136. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/native_index.py +0 -0
  137. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/native_registration.py +0 -0
  138. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/parallel_model.py +0 -0
  139. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/planning_context.py +0 -0
  140. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/process_common.py +0 -0
  141. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/process_jobs.py +0 -0
  142. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/process_journal.py +0 -0
  143. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/process_owner.py +0 -0
  144. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/processing.py +0 -0
  145. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/processing_route.py +0 -0
  146. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/prompts.py +0 -0
  147. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/provenance.py +0 -0
  148. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/provider_compatibility.py +0 -0
  149. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/query_clock.py +0 -0
  150. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/query_progress.py +0 -0
  151. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/query_scan.py +0 -0
  152. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/receipt_codec.py +0 -0
  153. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/recording_policy.py +0 -0
  154. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/redaction.py +0 -0
  155. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/remember_route.py +0 -0
  156. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/retention.py +0 -0
  157. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/retrieval.py +0 -0
  158. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/runtime_retention.py +0 -0
  159. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/scope_maintenance.py +0 -0
  160. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/scope_state.py +0 -0
  161. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/semantic_maintenance.py +0 -0
  162. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/semantic_protocol.py +0 -0
  163. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/single_pass_memory_planner.py +0 -0
  164. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/single_pass_plan.py +0 -0
  165. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/source_policy.py +0 -0
  166. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/state_layout.py +0 -0
  167. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/subprocess_flags.py +0 -0
  168. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/summary_batch.py +0 -0
  169. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/target_reconciliation.py +0 -0
  170. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/turn_audit.py +0 -0
  171. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/turn_plan.py +0 -0
  172. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/update_coordinator.py +0 -0
  173. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/update_review.py +0 -0
  174. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/validation.py +0 -0
  175. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/vault.py +0 -0
  176. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf.egg-info/dependency_links.txt +0 -0
  177. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf.egg-info/entry_points.txt +0 -0
  178. {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf.egg-info/top_level.txt +0 -0
@@ -4,6 +4,28 @@ All notable changes to memleaf are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.2.84 — 2026-09-30
8
+
9
+ ### Task lifecycle, Hermes source times and read-linked comparison
10
+
11
+ - Expose revision-bound MCP `update_memory` for completion, cancellation and
12
+ reopening; retain the memory body, other fields and version history. Both MCP
13
+ permanent-forgetting tools now require strict `confirm_delete=true`.
14
+ - Verify Hermes' native file-mutation footer as an exact display transformation
15
+ instead of losing both message times. Verify legacy correction rows against
16
+ their native scaffold and full merge format; capture each source event with
17
+ its own timestamp. Unknown inputs remain unknown.
18
+ - Recognize a repeated source delivery before consuming another same-text turn;
19
+ copy messages at callback invocation and retain the queued retrieval identity.
20
+ - Freeze successful current-turn read IDs in assistant capture metadata and
21
+ immutable receipts. Prioritize those targets for automatic extraction; reject
22
+ mismatched source/session/turn context and block missing or over-budget targets.
23
+ Recover captured context after Inbox/receipt interruption even if the gate expires.
24
+ - Require structured deadline maintenance before NO_CHANGE when current evidence
25
+ verifies an existing textual deadline whose date is absent or unresolved.
26
+ Advance semantic protocol to v11 without changing legacy snapshot field contracts.
27
+ - No production memory migration or automatic retry of historical runs is performed.
28
+
7
29
  ## 0.2.83 — 2026-09-30
8
30
 
9
31
  ### Reviewed extraction fields and existing-item maintenance
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.83
3
+ Version: 0.2.84
4
4
  Summary: A local-first Markdown memory core for AI agents
5
5
  Author: memleaf contributors
6
6
  License-Expression: MIT
@@ -23,7 +23,7 @@ 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.83。**
26
+ > **版本:0.2.84。**
27
27
  > 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
28
28
  > 提炼先比较旧事项中的编号、目标、进展、责任和期限;补全未知信息也应维护原事项,另建独立待办不能替代项目事实更新。Core 不按标题强行合并。
29
29
  > 新请求使用 `field-reviewed-v1`:CREATE/UPDATE 显式判断结构化期限;未知执行人填 `null`,新增或变更非空负责人必须引用来源原文。Core 校验字段和来源,模型判断业务含义;非法行进入现有 partial 处理。来源时间用于解析相对日期,不补造事实日期。
@@ -283,11 +283,14 @@ python -m memleaf.mcp_server --vault "$HOME/.memleaf"
283
283
  | `process_status` | 只读查询异步处理 job 状态 |
284
284
  | `recover_failed_run` | 预览并显式恢复终态传输失败,保留原请求预算 |
285
285
  | `remember` | 用户明确要求时创建或更新记忆 |
286
- | `forget_memory` | 按精确 ID 删除一条记忆 |
287
- | `forget_about` | 忘记明确主题;有歧义时只返回候选 |
286
+ | `update_memory` | 携带当前 revision 办结、取消或重新激活任务,保留正文与历史 |
287
+ | `forget_memory` | 用户明确要求遗忘时,携带 `confirm_delete=true` 按精确 ID 永久删除 |
288
+ | `forget_about` | 携带 `confirm_delete=true` 遗忘明确主题;有歧义时只返回候选 |
288
289
  | `rebuild_index` | 重建可重建的本地派生索引 |
289
290
  | `stats` | 返回 Vault 计数和诊断统计 |
290
291
 
292
+ 任务办结与永久遗忘的接口和兼容性见 [生命周期工具](docs/memory-lifecycle-tools.md);来源时间及本轮比较目标的捕获边界见 [Hermes 来源与比较上下文](docs/hermes-source-comparison-context.md)。
293
+
291
294
  `search` 的候选只是线索,标题不能单独作为事实依据。受管理检索必须使用同一条检索链的 `retrieval_id` 完成 `search → read`。Hermes/Codex Hook 可直接提供该 token;只有 MCP 的客户端可在第一条 `search` / `list_todos` 省略 token,并使用返回值继续分页和读取。带 cursor 的后续页以及所有 `read` 都不能省略 `retrieval_id`。工具错误、Scope 冲突或读取失败都应如实处理。
292
295
 
293
296
  ## Python API
@@ -4,7 +4,7 @@
4
4
 
5
5
  [中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **Version: 0.2.83.**
7
+ > **Version: 0.2.84.**
8
8
  > Automatic extraction and review share one future-value standard: the model weighs likely reuse, information gain, direct usability when read again, and the cost of forgetting. It keeps only the smallest core that can materially help future understanding, decisions, or actions; information without clear value is not extracted, and no business-specific exclusion rule is hard-coded.
9
9
  > Extraction compares existing identifiers, objectives, progress, responsibility and deadlines first. Filling an unknown also maintains the original item; a separate task does not replace updating shared project facts. Core does not merge matching titles.
10
10
  > New requests use `field-reviewed-v1`: CREATE/UPDATE explicitly decide structural deadlines; unknown executors use `null`, and new or changed non-null responsibility requires a source quote. Core validates fields and provenance while the model judges business meaning; invalid rows use existing partial handling. Source time resolves relative dates without inventing factual dates.
@@ -264,11 +264,14 @@ The server exposes the following tools:
264
264
  | `process_status` | Read the status of a background processing job |
265
265
  | `recover_failed_run` | Preview and explicitly recover terminal transport failures within the original request budget |
266
266
  | `remember` | Create or update memory after an explicit request |
267
- | `forget_memory` | Delete one memory by exact ID |
268
- | `forget_about` | Forget an unambiguous topic; return candidates when ambiguous |
267
+ | `update_memory` | Complete, cancel or reopen a task using its current revision; retain body and history |
268
+ | `forget_memory` | Permanently delete an exact ID after explicit user forgetting, with `confirm_delete=true` |
269
+ | `forget_about` | Forget an unambiguous topic with `confirm_delete=true`; return candidates when ambiguous |
269
270
  | `rebuild_index` | Rebuild local derived indexes |
270
271
  | `stats` | Return Vault counts and diagnostic statistics |
271
272
 
273
+ See [Lifecycle tools](docs/memory-lifecycle-tools.md) and [Hermes source and comparison context](docs/hermes-source-comparison-context.md) for operation and capture boundaries.
274
+
272
275
  Search results are clues; a title alone is not a business fact. Managed retrieval must use the same `retrieval_id` for `search → read`. Tool errors, Scope conflicts, retrieval-turn violations, and read page/version errors must be handled as such.
273
276
 
274
277
  ## Python API
@@ -4,7 +4,7 @@
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.83。**
7
+ > **版本:0.2.84。**
8
8
  > 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
9
9
  > 提炼先比较旧事项中的编号、目标、进展、责任和期限;补全未知信息也应维护原事项,另建独立待办不能替代项目事实更新。Core 不按标题强行合并。
10
10
  > 新请求使用 `field-reviewed-v1`:CREATE/UPDATE 显式判断结构化期限;未知执行人填 `null`,新增或变更非空负责人必须引用来源原文。Core 校验字段和来源,模型判断业务含义;非法行进入现有 partial 处理。来源时间用于解析相对日期,不补造事实日期。
@@ -264,11 +264,14 @@ python -m memleaf.mcp_server --vault "$HOME/.memleaf"
264
264
  | `process_status` | 只读查询异步处理 job 状态 |
265
265
  | `recover_failed_run` | 预览并显式恢复终态传输失败,保留原请求预算 |
266
266
  | `remember` | 用户明确要求时创建或更新记忆 |
267
- | `forget_memory` | 按精确 ID 删除一条记忆 |
268
- | `forget_about` | 忘记明确主题;有歧义时只返回候选 |
267
+ | `update_memory` | 携带当前 revision 办结、取消或重新激活任务,保留正文与历史 |
268
+ | `forget_memory` | 用户明确要求遗忘时,携带 `confirm_delete=true` 按精确 ID 永久删除 |
269
+ | `forget_about` | 携带 `confirm_delete=true` 遗忘明确主题;有歧义时只返回候选 |
269
270
  | `rebuild_index` | 重建可重建的本地派生索引 |
270
271
  | `stats` | 返回 Vault 计数和诊断统计 |
271
272
 
273
+ 任务办结与永久遗忘的接口和兼容性见 [生命周期工具](docs/memory-lifecycle-tools.md);来源时间及本轮比较目标的捕获边界见 [Hermes 来源与比较上下文](docs/hermes-source-comparison-context.md)。
274
+
272
275
  `search` 的候选只是线索,标题不能单独作为事实依据。受管理检索必须使用同一条检索链的 `retrieval_id` 完成 `search → read`。Hermes/Codex Hook 可直接提供该 token;只有 MCP 的客户端可在第一条 `search` / `list_todos` 省略 token,并使用返回值继续分页和读取。带 cursor 的后续页以及所有 `read` 都不能省略 `retrieval_id`。工具错误、Scope 冲突或读取失败都应如实处理。
273
276
 
274
277
  ## Python API
@@ -0,0 +1,60 @@
1
+ # Hermes source and comparison context
2
+
3
+ ## Message times
4
+
5
+ `source_time` comes from the corresponding message's `source_time` or
6
+ `timestamp`. Missing or invalid values stay unknown. Capturing or processing
7
+ an event does not create a source time.
8
+
9
+ The legacy callback checks the final assistant and the nearest user. It has no
10
+ 32-message lookback limit. It does not borrow a matching user from an older turn.
11
+
12
+ Some Hermes versions persist a reply before adding their file-mutation verifier
13
+ footer. The Provider accepts that display transformation only when the installed
14
+ Hermes formatter reconstructs the entire footer from uniquely associated,
15
+ current-turn file-tool receipts and the full resulting reply matches. Arbitrary
16
+ prefixes, suffixes, unsupported formatters and ambiguous receipts remain unknown.
17
+ Tool receipts supply no times or memory facts. The displayed reply remains the
18
+ captured assistant content.
19
+
20
+ For legacy corrections, the Provider verifies the real user rows, native replay
21
+ scaffold and the exact full merged callback string. It never splits arbitrary
22
+ user text by a separator. A bounded chain must have a queued original admission;
23
+ then each source row is captured separately with its own time. It does not assign
24
+ the final correction's time to the combined user text. A repeated source delivery
25
+ is recognized before consuming another same-text admission. Host-v1 admission
26
+ snapshots remain the preferred interface for compaction and transformations that
27
+ legacy callbacks cannot verify. The list is copied at callback invocation; this
28
+ cannot recover a host snapshot already lost before the callback arrives.
29
+
30
+ ## Successful reads as comparison targets
31
+
32
+ Hermes uses the retrieval identity frozen at `on_turn_start` for final capture.
33
+ The assistant capture includes `retrieval_id` and `retrieval_turn_id`; the latter
34
+ must equal its `turn_id`. Core checks that the existing gate belongs to exactly
35
+ the declared source, session and turn. It never resolves a session's latest gate
36
+ on behalf of an older callback. Live search and read still reject stale tokens.
37
+
38
+ Core freezes only successfully read memory IDs into `comparison_context` on the
39
+ assistant event, including the immutable capture receipt and payload digest.
40
+ It accepts no caller-supplied list of IDs through this interface. The capsule is
41
+ comparison metadata, not factual evidence, and its token is not projected into
42
+ the model request. Automatic preparation gives these required targets priority
43
+ alongside explicit-write receipts before filling the bounded candidate budget.
44
+ A missing target or insufficient budget blocks preparation instead of silently
45
+ replacing or dropping the required target. No model call is added to capture.
46
+
47
+ A delayed callback can use its own unexpired gate even after a later turn starts.
48
+ Once the capsule is captured, gate expiry cannot erase it or break a duplicate
49
+ capture. If Inbox was saved but receipt persistence failed, retries first recover
50
+ the capsule from Inbox before consulting a live gate. An uncaptured, expired or
51
+ mismatched gate cannot supply comparison context.
52
+
53
+ The extraction prompt also requires structured deadline maintenance before
54
+ NO_CHANGE when current evidence makes an existing unstructured deadline
55
+ verifiable. Unchanged wording does not mean a missing due_date is complete.
56
+ This rule advances the semantic protocol to v11.
57
+
58
+ This supplies the model with the old targets. It does not force semantic UPDATE,
59
+ merge similar titles, or automatically alter historical memories. Recovery of
60
+ existing production runs and deleted records is a separate operation.
@@ -0,0 +1,26 @@
1
+ # 任务办结与永久遗忘
2
+
3
+ MCP `update_memory` 用于完成、取消或重新打开现有事项。它调用 Core 的 revision-bound 更新接口,保留原正文、未修改字段及历史版本,不调用模型。任务办结不等于删除记忆。
4
+
5
+ 先通过 `search` / `list_todos` 和 `read` 确认目标。使用 `read` 返回的 `revision`,而不是分页用的 `version`:
6
+
7
+ ```json
8
+ {
9
+ "memory_id": "mem-example",
10
+ "expected_revision": "<read 返回的 revision>",
11
+ "patch": {"status": "completed"}
12
+ }
13
+ ```
14
+
15
+ - 已完成或关闭的事项使用 `completed`,放弃的事项使用 `cancelled`。
16
+ - 本 MCP 接口仅接受 `patch.status`,不修改正文、类型或范围。
17
+ - 重新打开事项使用 `patch.status=active` 和 `reopen=true`,应有用户明确重新打开的要求。
18
+ - 只有明确知道事项的完成事件时间时才传 `source_time`(带时区的 ISO 时间);完成时会保存为 `completed_at`。未知时省略,不使用当前执行时间补造完成时间。
19
+ - 相同请求可以安全重试,不重复创建历史。旧 revision 的不同修改会被拒绝,应重新读取目标。
20
+ - 混合了项目事实和待办的记忆,办结只改变状态,仍保留项目事实。
21
+
22
+ `forget_memory` 与 `forget_about` 永久删除选中的记忆及其历史,没有“删除后收档”的历史副本。两个 MCP 工具均要求 `confirm_delete=true`,缺失或不是布尔值 `true` 时服务器拒绝执行。
23
+
24
+ 这个参数只能表示调用方确认用户明确要求永久遗忘。完成、关闭、取消或归档任务不能作为设置它的理由。布尔参数本身不是用户自然语言授权的证明,也不能保证模型永远正确选择工具。需要撤销任务完成状态时,应通过 `update_memory` 重新打开;已永久删除的内容不能用此接口恢复。
25
+
26
+ 这是 MCP 删除接口的兼容性变更:旧客户端应只在已有明确永久遗忘要求时补传 `confirm_delete=true`。Python Core 的 `forget_memory` / `forget_about` 原有删除语义及参数保持不变。
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memleaf"
7
- version = "0.2.83"
7
+ version = "0.2.84"
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.83"
3
+ __version__ = "0.2.84"
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
@@ -199,6 +199,7 @@ def _append_event(
199
199
  final: bool | None,
200
200
  tool_evidence: list[dict[str, str]] | None = None,
201
201
  explicit_input: dict[str, Any] | None = None,
202
+ comparison_context: dict[str, Any] | None = None,
202
203
  ) -> str:
203
204
  if not existing:
204
205
  existing = _new_session_text(source, session_id, captured_at)
@@ -232,6 +233,8 @@ def _append_event(
232
233
  metadata["final"] = final
233
234
  if explicit_input is not None:
234
235
  metadata["explicit_input"] = explicit_input
236
+ if comparison_context is not None:
237
+ metadata["comparison_context"] = comparison_context
235
238
  if tool_evidence:
236
239
  metadata["tool_evidence"] = [dict(item) for item in tool_evidence]
237
240
  metadata_line = json.dumps(metadata, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
@@ -359,6 +362,8 @@ def _payload_digest(metadata: Mapping[str, Any], content: str) -> str:
359
362
  if metadata.get("explicit_input") is not None:
360
363
  from .explicit_text_source import validate_origin
361
364
  value["explicit_input"] = validate_origin(metadata["explicit_input"])
365
+ if metadata.get("comparison_context") is not None:
366
+ value["comparison_context"] = metadata["comparison_context"]
362
367
  value["content"] = content.rstrip("\n")
363
368
  return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True,
364
369
  separators=(",", ":")).encode("utf-8")).hexdigest()
@@ -414,7 +419,7 @@ def recover_capture_receipts_unlocked(vault: Vault, processed: dict, path: Path)
414
419
  turn_key_value=metadata["turn_key"], turn_index=metadata["turn_index"],
415
420
  message_id=metadata["message_id"], message_revision=metadata["message_revision"],
416
421
  )
417
- entry = {field: metadata[field] for field in (*_IMMUTABLE_EVENT_FIELDS, "turn_id", "turn_index", "captured_at", "explicit_input")
422
+ entry = {field: metadata[field] for field in (*_IMMUTABLE_EVENT_FIELDS, "turn_id", "turn_index", "captured_at", "explicit_input", "comparison_context")
418
423
  if field in metadata}
419
424
  entry.update(event_key=key, payload_digest=digest)
420
425
  events[key] = entry
@@ -447,6 +452,8 @@ def capture_event(
447
452
  previous_message_id: Optional[str] = None,
448
453
  final: Optional[bool] = None,
449
454
  _explicit_input: dict[str, Any] | None = None,
455
+ retrieval_id: Optional[str] = None,
456
+ retrieval_turn_id: Optional[str] = None,
450
457
  ) -> CaptureResult:
451
458
  """Capture one visible event; all persisted text is redacted first."""
452
459
 
@@ -478,6 +485,9 @@ def capture_event(
478
485
  # Opting into source envelopes does not implicitly certify completion.
479
486
  # Old callers with no source metadata retain their legacy boundary.
480
487
  final = False
488
+ if (retrieval_id is None) != (retrieval_turn_id is None) or (
489
+ retrieval_id is not None and (role != "assistant" or retrieval_turn_id != turn_id)):
490
+ raise ValueError("invalid_comparison_binding")
481
491
  resolved_event_id, resolved_message_id, resolved_event_key = _event_identity(
482
492
  source, session_id, turn_id, role, event_id, message_id, message_revision
483
493
  )
@@ -488,6 +498,31 @@ def capture_event(
488
498
  if not visible or role not in ("user", "assistant"):
489
499
  return CaptureResult(resolved_event_id, stored=False, duplicate=False, content=safe_content)
490
500
 
501
+ comparison_context = None
502
+ if retrieval_id is not None:
503
+ # Never hold the vault lock while acquiring the retrieval lock: gated
504
+ # reads acquire them in the opposite order. Durable retries reuse their
505
+ # immutable receipt even after the short-lived gate has expired.
506
+ with vault.lock():
507
+ recovered = _read_processed(vault.processed_state_path)
508
+ from .recording_policy import recording_allowed
509
+ comparison_permitted = record and recording_allowed(recovered, source, session_id, resolved_turn_key)
510
+ if comparison_permitted:
511
+ path = vault.session_path(source, session_id)
512
+ if path.is_symlink():
513
+ raise ValueError("unsafe inbox session path")
514
+ if recover_capture_receipts_unlocked(vault, recovered, path):
515
+ atomic_write_json(vault.processed_state_path, recovered)
516
+ receipt = recovered.get("events", {}).get(resolved_event_key, {})
517
+ saved = receipt.get("comparison_context") if isinstance(receipt, Mapping) else None
518
+ if (isinstance(saved, dict) and saved.get("retrieval_id") == retrieval_id
519
+ and saved.get("turn_id") == retrieval_turn_id):
520
+ comparison_context = saved
521
+ elif comparison_permitted:
522
+ from .retrieval_gate import snapshot_read_context
523
+ comparison_context = snapshot_read_context(vault, retrieval_id,
524
+ source=source, session_id=session_id, turn_id=retrieval_turn_id)
525
+
491
526
  with vault.lock():
492
527
  processed = _read_processed(vault.processed_state_path)
493
528
  if _explicit_input is not None:
@@ -526,6 +561,8 @@ def capture_event(
526
561
  "previous_message_revision": previous_message_revision, "source_sequence": source_sequence,
527
562
  "previous_message_id": previous_message_id, "source_time": source_time, "final": final,
528
563
  }
564
+ if retrieval_id is not None:
565
+ incoming["comparison_context"] = comparison_context
529
566
  if _explicit_input is not None:
530
567
  incoming["explicit_input"] = _explicit_input
531
568
  digest = _payload_digest(incoming, safe_content)
@@ -584,6 +621,7 @@ def capture_event(
584
621
  final=final,
585
622
  tool_evidence=safe_tool_evidence,
586
623
  explicit_input=_explicit_input,
624
+ comparison_context=comparison_context,
587
625
  )
588
626
  atomic_write_text(path, updated)
589
627
 
@@ -29,6 +29,118 @@ def _capture_assistant_text(value: str) -> str:
29
29
  flags=re.IGNORECASE)
30
30
 
31
31
 
32
+ def _assistant_source_matches(messages: Any, content: str) -> bool:
33
+ """Accept exact text or an exactly reconstructed native verifier footer.
34
+
35
+ No generic prefix/suffix matching. Tool receipts are used only to check
36
+ Hermes' display transformation, never as message times or memory facts.
37
+ Unsupported host formats fail closed.
38
+ """
39
+ if not isinstance(messages, list) or not messages:
40
+ return False
41
+ final = messages[-1]
42
+ if not isinstance(final, Mapping) or final.get("role") != "assistant" or final.get("tool_calls"):
43
+ return False
44
+ raw = final.get("content")
45
+ if raw == content:
46
+ return True
47
+ if not isinstance(raw, str) or not raw.strip():
48
+ return False
49
+ try:
50
+ from agent.turn_explainers import TurnExplainersMixin
51
+ from agent.tool_dispatch_helpers import _extract_error_preview, _extract_file_mutation_targets
52
+ from agent.tool_result_classification import file_mutation_result_landed
53
+ # The last real user bounds the supported single-message legacy turn.
54
+ anchor = max(i for i, row in enumerate(messages[:-1])
55
+ if isinstance(row, Mapping) and row.get("role") == "user")
56
+ tail = messages[anchor:-1]
57
+ calls, results = _visible_tool_calls(tail), _visible_tool_results(tail)
58
+ failed = {}
59
+ for call in calls:
60
+ name, args = call.get("name"), call.get("arguments")
61
+ if name not in {"write_file", "patch"} or not isinstance(args, Mapping):
62
+ continue
63
+ matches = [row for row in results if call.get("call_id")
64
+ and row.get("call_id") == call["call_id"]]
65
+ if not matches:
66
+ # Hermes' persisted transcript can omit tool_call_id. Accept
67
+ # only one call immediately followed by its named tool row.
68
+ for index, row in enumerate(tail[:-1]):
69
+ row_calls = _visible_tool_calls([row])
70
+ following = tail[index + 1]
71
+ if (len(row_calls) == 1 and row_calls[0] == call
72
+ and isinstance(following, Mapping) and following.get("role") == "tool"
73
+ and not following.get("tool_call_id")
74
+ and following.get("name", following.get("tool_name")) == name):
75
+ matches.append({"payload": following.get("content")})
76
+ if len(matches) != 1:
77
+ return False
78
+ payload = matches[0]["payload"]
79
+ decoded = _decode_tool_value(payload)
80
+ if not isinstance(decoded, Mapping):
81
+ return False
82
+ targets = _extract_file_mutation_targets(name, dict(args))
83
+ if decoded.get("error") and not file_mutation_result_landed(name, payload):
84
+ for path in targets:
85
+ failed.setdefault(path, {"tool": name, "error_preview": _extract_error_preview(payload)})
86
+ else:
87
+ for path in targets:
88
+ failed.pop(path, None)
89
+ footer = TurnExplainersMixin._format_file_mutation_failure_footer(failed)
90
+ return bool(footer) and content == raw.rstrip() + "\n\n" + footer
91
+ except (ImportError, AttributeError, TypeError, ValueError, KeyError):
92
+ return False
93
+
94
+
95
+ def _legacy_correction_users(messages: Any, merged: str) -> Optional[list[Mapping[str, Any]]]:
96
+ """Check the actual bounded correction rows against Hermes' merge format.
97
+
98
+ Never split user text by a delimiter. Admission rows and the native replay
99
+ scaffold identify corrections; full reconstruction must match uniquely.
100
+ """
101
+ if not isinstance(messages, list) or not messages:
102
+ return None
103
+ users = []
104
+ for row in reversed(messages[:-1]):
105
+ if not isinstance(row, Mapping) or row.get("role") != "user":
106
+ continue
107
+ if not isinstance(row.get("content"), str) or not row["content"].strip():
108
+ return None
109
+ users.append(row)
110
+ if len(users) > 63:
111
+ return None
112
+ api = row.get("api_content")
113
+ correction = (isinstance(api, str) and api.startswith(
114
+ "[Context from the interrupted assistant response]\n[This response was interrupted by a user correction.]")
115
+ and api.endswith("\n\n" + row["content"]))
116
+ if not correction:
117
+ break
118
+ if len(users) < 2:
119
+ return None
120
+ users.reverse()
121
+ reconstructed = users[0]["content"]
122
+ for row in users[1:]:
123
+ reconstructed += "\n\nUser correction during the turn: " + row["content"]
124
+ return users if reconstructed == merged else None
125
+
126
+
127
+ def _sync_delivery_digest(messages: Any, users: Any, user_content: str, assistant_content: str) -> str:
128
+ """Disambiguate identical text deliveries using verified host source rows."""
129
+ identities = []
130
+ if _assistant_source_matches(messages, assistant_content):
131
+ for row in [*(users or []), messages[-1]]:
132
+ uid = row.get("message_uid") or row.get("message_id")
133
+ timestamp = _independent_assistant_time(row, "").get("assistant", {}).get("source_time")
134
+ if not uid and not timestamp:
135
+ identities = []
136
+ break
137
+ identities.append((uid, timestamp))
138
+ payload = f"{user_content}\x00{_capture_assistant_text(assistant_content)}"
139
+ if identities:
140
+ payload += "\x00" + json.dumps(identities, ensure_ascii=False, separators=(",", ":"))
141
+ return sha256(payload.encode()).hexdigest()[:16]
142
+
143
+
32
144
  def _sync_source_metadata(messages: Any, user_content: str, assistant_content: str) -> dict[str, dict[str, Any]]:
33
145
  """Read optional host metadata only from the exact visible tail pair.
34
146
 
@@ -43,8 +155,7 @@ def _sync_source_metadata(messages: Any, user_content: str, assistant_content: s
43
155
  logger.info("memleaf source-metadata reason=messages_missing")
44
156
  return {}
45
157
  assistant = messages[-1]
46
- if (not isinstance(assistant, Mapping) or assistant.get("role") != "assistant"
47
- or assistant.get("content") != assistant_content or assistant.get("tool_calls")):
158
+ if not _assistant_source_matches(messages, assistant_content):
48
159
  logger.info("memleaf source-metadata reason=assistant_mismatch")
49
160
  return {}
50
161
  # Tool rounds do not define a turn boundary. Stop at the nearest user,
@@ -149,7 +260,7 @@ def _host_visible_events(messages: Any, context: Any, session_id: str,
149
260
  return None
150
261
  final = messages[-1]
151
262
  if (not isinstance(final, Mapping) or final.get("role") != "assistant"
152
- or final.get("content") != assistant_content or final.get("tool_calls")):
263
+ or not _assistant_source_matches(messages, assistant_content)):
153
264
  return None
154
265
  completed_uid = context.get("completed_assistant_message_id")
155
266
  completed = completed_uid is not None
@@ -195,7 +306,7 @@ def _host_visible_events(messages: Any, context: Any, session_id: str,
195
306
  final_metadata = {}
196
307
  for _, item in found:
197
308
  user = dict(item, message_id=item["message_uid"])
198
- pair = _sync_source_metadata([user, assistant], user["content"], assistant_content)
309
+ pair = _sync_source_metadata([user, assistant], user["content"], assistant["content"])
199
310
  events.append({"role": "user", "content": user["content"], "metadata": pair.get("user", {})})
200
311
  final_metadata = pair.get("assistant", {})
201
312
  # Never manufacture a sequence by assuming a turn always has two events.
@@ -1040,8 +1151,9 @@ class MemleafMemoryProvider(MemoryProvider):
1040
1151
  turn_number: Optional[int],
1041
1152
  user_content: str,
1042
1153
  assistant_content: str,
1154
+ delivery_digest: Optional[str] = None,
1043
1155
  ) -> str:
1044
- pair_digest = sha256(f"{user_content}\x00{assistant_content}".encode("utf-8")).hexdigest()[:16]
1156
+ pair_digest = delivery_digest or sha256(f"{user_content}\x00{assistant_content}".encode("utf-8")).hexdigest()[:16]
1045
1157
  pair_key = (session_id, pair_digest)
1046
1158
  existing = self._turn_ids_by_pair.get(pair_key)
1047
1159
  if existing is not None and turn_number is None:
@@ -1481,6 +1593,8 @@ class MemleafMemoryProvider(MemoryProvider):
1481
1593
  source_sequence: int | None = None,
1482
1594
  final: bool | None = None,
1483
1595
  source_metadata: Mapping[str, Any] | None = None,
1596
+ retrieval_id: str | None = None,
1597
+ retrieval_turn_id: str | None = None,
1484
1598
  ) -> bool:
1485
1599
  metadata = {
1486
1600
  key: value for key, value in (source_metadata or {}).items()
@@ -1500,6 +1614,8 @@ class MemleafMemoryProvider(MemoryProvider):
1500
1614
  **({"source_sequence": source_sequence} if source_sequence is not None else {}),
1501
1615
  **({"final": final} if final is not None else {}),
1502
1616
  **metadata,
1617
+ **({"retrieval_id": retrieval_id, "retrieval_turn_id": retrieval_turn_id}
1618
+ if retrieval_id and retrieval_turn_id and role == "assistant" else {}),
1503
1619
  "record": True,
1504
1620
  "visible": True,
1505
1621
  },
@@ -1783,6 +1899,10 @@ class MemleafMemoryProvider(MemoryProvider):
1783
1899
  return
1784
1900
 
1785
1901
  effective_session = self._canonical_session_id(session_id or self._session_id)
1902
+ # Snapshot the list at invocation; subsequent tool/turn appends cannot
1903
+ # change which source rows this callback verifies.
1904
+ import copy
1905
+ messages = copy.deepcopy(messages)
1786
1906
  # Hermes serializes provider sync work, but this lock also protects
1787
1907
  # direct/plugin-level concurrent calls and makes process one-shot per
1788
1908
  # captured turn within this provider instance.
@@ -1798,30 +1918,60 @@ class MemleafMemoryProvider(MemoryProvider):
1798
1918
  if turn_context is not None and host_events is None:
1799
1919
  logger.info("memleaf source-metadata reason=host_event_mismatch")
1800
1920
  return
1921
+ correction_users = (_legacy_correction_users(messages, user_content)
1922
+ if host_events is None else None)
1923
+ queued_content = correction_users[0]["content"] if correction_users else user_content
1924
+ source_users = correction_users
1925
+ if source_users is None and isinstance(messages, list):
1926
+ nearest = next((row for row in reversed(messages[:-1])
1927
+ if isinstance(row, Mapping) and row.get("role") == "user"), None)
1928
+ source_users = [nearest] if nearest and nearest.get("content") == user_content else None
1929
+ delivery_digest = _sync_delivery_digest(messages, source_users, user_content, raw_assistant_content)
1801
1930
  resolved_turn_number = turn_number
1802
1931
  if bound_turn_id and type(turn_context.get("turn_number")) is int:
1803
1932
  resolved_turn_number = turn_context["turn_number"]
1804
1933
  if resolved_turn_number is None:
1805
- resolved_turn_number = self._take_turn_number(effective_session, user_content)
1934
+ # Check this source delivery before consuming another same-text
1935
+ # admission. Old callbacks must not take the next turn's gate.
1936
+ import re
1937
+ cached = self._turn_ids_by_pair.get((effective_session, delivery_digest), "")
1938
+ if cached:
1939
+ numbers = [number for (session, number), identity in self._gate_turn_ids.items()
1940
+ if session == effective_session and identity == cached]
1941
+ pair = sha256(f"{user_content}\x00{captured_assistant_content}".encode()).hexdigest()[:16]
1942
+ match = re.fullmatch(r"turn-([0-9]+)-" + pair, cached)
1943
+ if len(numbers) == 1:
1944
+ resolved_turn_number = numbers[0]
1945
+ elif match:
1946
+ resolved_turn_number = int(match[1])
1947
+ elif (messages is None or source_users and _assistant_source_matches(messages, raw_assistant_content)):
1948
+ resolved_turn_number = self._take_turn_number(effective_session, queued_content)
1806
1949
  elif isinstance(resolved_turn_number, int) and not isinstance(resolved_turn_number, bool):
1807
- queued_content = host_events[0]["content"] if host_events else user_content
1950
+ queued_content = host_events[0]["content"] if host_events else queued_content
1808
1951
  self._discard_turn_number(effective_session, queued_content, resolved_turn_number)
1809
1952
  turn_id = self._resolve_turn_id(
1810
1953
  effective_session,
1811
1954
  resolved_turn_number,
1812
1955
  user_content,
1813
1956
  captured_assistant_content,
1957
+ delivery_digest=delivery_digest,
1814
1958
  )
1815
- source_metadata = ({} if host_events is not None else
1959
+ source_metadata = ({} if host_events is not None or correction_users else
1816
1960
  _sync_source_metadata(messages, user_content, raw_assistant_content))
1817
1961
  user_source_id = source_metadata.get("user", {}).get("message_id")
1962
+ retrieval_id = self._gate_id_for_turn(effective_session, resolved_turn_number)
1963
+ retrieval_turn_id = self._gate_turn_id(effective_session, resolved_turn_number)
1818
1964
  if bound_turn_id:
1819
1965
  turn_id = bound_turn_id
1966
+ elif retrieval_id and retrieval_turn_id:
1967
+ # Reuse the identity frozen at on_turn_start. Comparison
1968
+ # reads and capture must refer to the identical host turn.
1969
+ turn_id = retrieval_turn_id
1820
1970
  elif user_source_id:
1821
1971
  # Stable host identity survives a revised body/final reply.
1822
1972
  turn_id = "source-" + sha256(user_source_id.encode("utf-8")).hexdigest()[:24]
1823
1973
  source_metadata["assistant"].setdefault("previous_message_id", user_source_id)
1824
- retrieval_id = self._gate_id_for_turn(effective_session, resolved_turn_number)
1974
+ self._turn_ids_by_pair[(effective_session, delivery_digest)] = turn_id
1825
1975
  # A background callback must never attach itself to a newer turn.
1826
1976
  if self._gate_enabled:
1827
1977
  audit_state: dict[str, Any] = {}
@@ -1850,6 +2000,21 @@ class MemleafMemoryProvider(MemoryProvider):
1850
2000
  for role, content in visible_events]
1851
2001
  sequence_base = (resolved_turn_number * 2
1852
2002
  if not bound_turn_id and isinstance(resolved_turn_number, int) else None)
2003
+ if correction_users and resolved_turn_number is not None and _assistant_source_matches(messages, raw_assistant_content):
2004
+ # Capture each verified source row separately. A correction's
2005
+ # timestamp never becomes the timestamp of the merged text.
2006
+ capture_events = []
2007
+ for index, row in enumerate(correction_users):
2008
+ pair = _sync_source_metadata([row, messages[-1]], row["content"], messages[-1]["content"])
2009
+ metadata = pair.get("user", {})
2010
+ metadata["message_id"] = row.get("message_uid") or f"{turn_id}/user-{index}"
2011
+ capture_events.append({"role": "user", "content": row["content"], "metadata": metadata})
2012
+ assistant_metadata = pair.get("assistant", {})
2013
+ assistant_metadata["previous_message_id"] = capture_events[-1]["metadata"]["message_id"]
2014
+ capture_events.append({"role": "assistant", "content": captured_assistant_content,
2015
+ "metadata": assistant_metadata})
2016
+ sequence_base = None
2017
+ logger.info("memleaf source-metadata contract=legacy-corrections user_events=%d", len(correction_users))
1853
2018
  for offset, event in enumerate(capture_events):
1854
2019
  role = event["role"]
1855
2020
  if not self._capture_visible(
@@ -1858,6 +2023,8 @@ class MemleafMemoryProvider(MemoryProvider):
1858
2023
  source_sequence=(sequence_base + offset if sequence_base is not None else None),
1859
2024
  final=True if role == "assistant" else None,
1860
2025
  source_metadata=event["metadata"],
2026
+ retrieval_id=retrieval_id if role == "assistant" else None,
2027
+ retrieval_turn_id=retrieval_turn_id if role == "assistant" else None,
1861
2028
  ):
1862
2029
  return
1863
2030
  if not self._auto_process:
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.83
2
+ version: 0.2.84
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -62,6 +62,7 @@ class InboxEvent:
62
62
  tool_evidence: tuple[dict[str, str], ...] = ()
63
63
  legacy: bool = False
64
64
  explicit_input: dict[str, Any] | None = None
65
+ comparison_context: dict[str, Any] | None = None
65
66
 
66
67
  @property
67
68
  def processable(self) -> bool:
@@ -306,6 +307,8 @@ def parse_inbox_text(
306
307
  timestamp=source_time if isinstance(source_time, str) and source_time else None,
307
308
  tool_evidence=_bounded_tool_evidence(metadata.get("tool_evidence")),
308
309
  explicit_input=explicit_input,
310
+ comparison_context=(metadata.get("comparison_context")
311
+ if isinstance(metadata.get("comparison_context"), dict) else None),
309
312
  )
310
313
  )
311
314