memleaf 0.2.77__tar.gz → 0.2.79__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 (174) hide show
  1. {memleaf-0.2.77 → memleaf-0.2.79}/CHANGELOG.md +23 -0
  2. {memleaf-0.2.77/src/memleaf.egg-info → memleaf-0.2.79}/PKG-INFO +2 -2
  3. {memleaf-0.2.77 → memleaf-0.2.79}/README.en.md +1 -1
  4. {memleaf-0.2.77 → memleaf-0.2.79}/README.md +1 -1
  5. {memleaf-0.2.77 → memleaf-0.2.79}/pyproject.toml +1 -1
  6. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/_provider.py +221 -31
  8. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/_shared.py +6 -1
  9. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  10. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_commit.py +7 -0
  11. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_dates.py +2 -69
  12. memleaf-0.2.79/src/memleaf/incremental_explicit.py +211 -0
  13. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_journal.py +4 -0
  14. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_preview.py +15 -1
  15. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_prompts.py +1 -0
  16. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_protocol.py +13 -16
  17. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_recovery.py +9 -1
  18. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/mcp_server.py +2 -1
  19. {memleaf-0.2.77 → memleaf-0.2.79/src/memleaf.egg-info}/PKG-INFO +2 -2
  20. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf.egg-info/SOURCES.txt +1 -0
  21. {memleaf-0.2.77 → memleaf-0.2.79}/LICENSE +0 -0
  22. {memleaf-0.2.77 → memleaf-0.2.79}/MANIFEST.in +0 -0
  23. {memleaf-0.2.77 → memleaf-0.2.79}/docs/acceptance-evidence-guide.md +0 -0
  24. {memleaf-0.2.77 → memleaf-0.2.79}/docs/automatic-processing-route.md +0 -0
  25. {memleaf-0.2.77 → memleaf-0.2.79}/docs/body-compaction.md +0 -0
  26. {memleaf-0.2.77 → memleaf-0.2.79}/docs/candidate-reconciliation.md +0 -0
  27. {memleaf-0.2.77 → memleaf-0.2.79}/docs/capture-budget-design.md +0 -0
  28. {memleaf-0.2.77 → memleaf-0.2.79}/docs/config-migrations.md +0 -0
  29. {memleaf-0.2.77 → memleaf-0.2.79}/docs/core-refactor.md +0 -0
  30. {memleaf-0.2.77 → memleaf-0.2.79}/docs/development-closeout.md +0 -0
  31. {memleaf-0.2.77 → memleaf-0.2.79}/docs/evidence-retention.md +0 -0
  32. {memleaf-0.2.77 → memleaf-0.2.79}/docs/explicit-memory-update.md +0 -0
  33. {memleaf-0.2.77 → memleaf-0.2.79}/docs/extraction-latency.md +0 -0
  34. {memleaf-0.2.77 → memleaf-0.2.79}/docs/gate-evidence-boundary.md +0 -0
  35. {memleaf-0.2.77 → memleaf-0.2.79}/docs/general-processing.md +0 -0
  36. {memleaf-0.2.77 → memleaf-0.2.79}/docs/hermes-mcp-runtime.md +0 -0
  37. {memleaf-0.2.77 → memleaf-0.2.79}/docs/hermes-provider-compatibility.md +0 -0
  38. {memleaf-0.2.77 → memleaf-0.2.79}/docs/host-acceptance-runbook.md +0 -0
  39. {memleaf-0.2.77 → memleaf-0.2.79}/docs/immutable-release-assets.md +0 -0
  40. {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-commit-contract.md +0 -0
  41. {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-execution.md +0 -0
  42. {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-native-comparison.md +0 -0
  43. {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-partial-recovery.md +0 -0
  44. {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-planner-preview.md +0 -0
  45. {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-runtime-contract.md +0 -0
  46. {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-scope-registration.md +0 -0
  47. {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-selected-retention.md +0 -0
  48. {memleaf-0.2.77 → memleaf-0.2.79}/docs/installed-artifact-verification.md +0 -0
  49. {memleaf-0.2.77 → memleaf-0.2.79}/docs/memory-state-contract.md +0 -0
  50. {memleaf-0.2.77 → memleaf-0.2.79}/docs/migration-preflight.md +0 -0
  51. {memleaf-0.2.77 → memleaf-0.2.79}/docs/processing-quality-acceptance.md +0 -0
  52. {memleaf-0.2.77 → memleaf-0.2.79}/docs/public-query-integrity.md +0 -0
  53. {memleaf-0.2.77 → memleaf-0.2.79}/docs/query-performance.md +0 -0
  54. {memleaf-0.2.77 → memleaf-0.2.79}/docs/remember-incremental-route.md +0 -0
  55. {memleaf-0.2.77 → memleaf-0.2.79}/docs/runtime-state-retention.md +0 -0
  56. {memleaf-0.2.77 → memleaf-0.2.79}/docs/semantic-extraction-protocol.md +0 -0
  57. {memleaf-0.2.77 → memleaf-0.2.79}/docs/source-work-contract.md +0 -0
  58. {memleaf-0.2.77 → memleaf-0.2.79}/docs/v0.2.26-processing-status.md +0 -0
  59. {memleaf-0.2.77 → memleaf-0.2.79}/examples/README.md +0 -0
  60. {memleaf-0.2.77 → memleaf-0.2.79}/examples/basic_usage.py +0 -0
  61. {memleaf-0.2.77 → memleaf-0.2.79}/examples/incremental_acceptance.json +0 -0
  62. {memleaf-0.2.77 → memleaf-0.2.79}/examples/mcp_stdio.ndjson +0 -0
  63. {memleaf-0.2.77 → memleaf-0.2.79}/examples/query_benchmark.py +0 -0
  64. {memleaf-0.2.77 → memleaf-0.2.79}/install.ps1 +0 -0
  65. {memleaf-0.2.77 → memleaf-0.2.79}/install.sh +0 -0
  66. {memleaf-0.2.77 → memleaf-0.2.79}/setup.cfg +0 -0
  67. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/__main__.py +0 -0
  68. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/acceptance.py +0 -0
  69. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/adapters/__init__.py +0 -0
  70. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/adapters/antigravity.py +0 -0
  71. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/adapters/base.py +0 -0
  72. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/adapters/codex.py +0 -0
  73. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/adapters/hermes.py +0 -0
  74. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/admission.py +0 -0
  75. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/batch_review.py +0 -0
  76. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/budget.py +0 -0
  77. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/capture.py +0 -0
  78. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/cli.py +0 -0
  79. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/compaction.py +0 -0
  80. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/config.py +0 -0
  81. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/create_coordinator.py +0 -0
  82. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/credentials.py +0 -0
  83. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/evidence_budget.py +0 -0
  84. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/evidence_policy.py +0 -0
  85. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/evidence_structure.py +0 -0
  86. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/evidence_syntax.py +0 -0
  87. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/explicit_text_source.py +0 -0
  88. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/extraction_budget.py +0 -0
  89. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/extraction_capability.py +0 -0
  90. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/extraction_work_state.py +0 -0
  91. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/frontmatter.py +0 -0
  92. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/README.md +0 -0
  93. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/__init__.py +0 -0
  94. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
  95. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
  96. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/provider_compatibility.py +0 -0
  97. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_runtime.py +0 -0
  98. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/host_events.py +0 -0
  99. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/host_runtime.py +0 -0
  100. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/inbox.py +0 -0
  101. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_execution.py +0 -0
  102. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_native.py +0 -0
  103. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_partial.py +0 -0
  104. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_run_state.py +0 -0
  105. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_runtime.py +0 -0
  106. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_scopes.py +0 -0
  107. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_selection.py +0 -0
  108. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/index.py +0 -0
  109. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/inspection.py +0 -0
  110. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/installer.py +0 -0
  111. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/legacy_update_journal.py +0 -0
  112. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/__init__.py +0 -0
  113. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/base.py +0 -0
  114. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/claude_compatible.py +0 -0
  115. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/gemini.py +0 -0
  116. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/openai_compatible.py +0 -0
  117. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/router.py +0 -0
  118. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/thinking.py +0 -0
  119. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/locking.py +0 -0
  120. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/memory_commit.py +0 -0
  121. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/memory_planner.py +0 -0
  122. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/memory_retraction.py +0 -0
  123. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/memory_update.py +0 -0
  124. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/memory_writer.py +0 -0
  125. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/migration.py +0 -0
  126. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/model_capabilities.py +0 -0
  127. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/model_discovery.py +0 -0
  128. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/model_execution.py +0 -0
  129. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/models.py +0 -0
  130. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/native_index.py +0 -0
  131. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/native_registration.py +0 -0
  132. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/parallel_model.py +0 -0
  133. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/planning_context.py +0 -0
  134. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/process_common.py +0 -0
  135. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/process_jobs.py +0 -0
  136. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/process_journal.py +0 -0
  137. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/process_owner.py +0 -0
  138. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/processing.py +0 -0
  139. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/processing_route.py +0 -0
  140. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/prompts.py +0 -0
  141. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/provenance.py +0 -0
  142. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/provider_compatibility.py +0 -0
  143. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/query_clock.py +0 -0
  144. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/query_progress.py +0 -0
  145. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/query_scan.py +0 -0
  146. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/receipt_codec.py +0 -0
  147. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/recording_policy.py +0 -0
  148. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/redaction.py +0 -0
  149. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/remember_route.py +0 -0
  150. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/retention.py +0 -0
  151. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/retrieval.py +0 -0
  152. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/retrieval_gate.py +0 -0
  153. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/runtime_retention.py +0 -0
  154. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/scope_maintenance.py +0 -0
  155. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/scope_state.py +0 -0
  156. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/semantic_maintenance.py +0 -0
  157. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/semantic_protocol.py +0 -0
  158. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/service.py +0 -0
  159. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/single_pass_memory_planner.py +0 -0
  160. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/single_pass_plan.py +0 -0
  161. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/source_policy.py +0 -0
  162. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/state_layout.py +0 -0
  163. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/subprocess_flags.py +0 -0
  164. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/summary_batch.py +0 -0
  165. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/target_reconciliation.py +0 -0
  166. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/turn_audit.py +0 -0
  167. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/turn_plan.py +0 -0
  168. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/update_coordinator.py +0 -0
  169. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/update_review.py +0 -0
  170. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/validation.py +0 -0
  171. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/vault.py +0 -0
  172. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf.egg-info/dependency_links.txt +0 -0
  173. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf.egg-info/entry_points.txt +0 -0
  174. {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf.egg-info/top_level.txt +0 -0
@@ -4,6 +4,29 @@ All notable changes to memleaf are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.2.79 — 2026-09-29
8
+
9
+ ### Model-owned content dates
10
+
11
+ - Remove evidence-grounding rejection for dates in memory titles and bodies;
12
+ date content is now judged by the extraction model, without a replacement
13
+ date-format gate.
14
+ - Retain structural protocol checks and source-time calendar conversion.
15
+ - Advance the incremental semantic protocol to v8 for the changed contract.
16
+
17
+ ## 0.2.78 — 2026-09-29
18
+
19
+ ### Hermes host-turn provenance and explicit-write context
20
+
21
+ - Capture corrected user messages as separate events from host admission
22
+ snapshots; reject incomplete or mismatched turn context instead of inferring
23
+ event identity from composed text.
24
+ - Bind external tool calls to verified host/session/turn identity, and keep
25
+ explicit-save receipts tied to their source evidence and target revision
26
+ across planning and recovery.
27
+ - Keep MCP retrieval authorization enforced at execution while allowing hosts
28
+ and bare clients to supply the retrieval token through their own transport.
29
+
7
30
  ## 0.2.77 — 2026-09-29
8
31
 
9
32
  ### Hermes source metadata pairing
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.77
3
+ Version: 0.2.79
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.77。**
26
+ > **版本:0.2.79。**
27
27
  > 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
28
28
  > 提炼契约现在只有一份中文语义契约(旧版英文 B3 提示词已删除),首轮、主题选择、候选修复和 `semantic_maintenance` 使用同一套保留价值判断;同一件事只写一次。
29
29
  > Core 不再因为归属、日期、任务依据或跨项目而丢弃整条候选:归属缺失会归一到证据里唯一被点名的项目、否则落 `global`;日期只接受能在本候选证据里找到出处、且能锚定成 ISO 的写法(`明天`、`周五`、`月底` 都会被解析),无法解析时只丢掉日期字段,记忆照常写入;未获授权的改归属沿用目标原归属。
@@ -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.77.**
7
+ > **Version: 0.2.79.**
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
  > A single Chinese semantic contract is sent for extraction, topic selection, candidate repair, and `semantic_maintenance`; the older English B3 prompt has been deleted and B3 remains only as a compatibility read format. One topic is written once.
10
10
  > Core no longer discards a whole candidate over ownership, dates, task basis or cross-project wording. A missing owner normalizes to the single project named by that candidate's own evidence, otherwise `global`. A deadline is accepted only when it appears in the candidate's own evidence and can be anchored to ISO (`明天`, `周五`, `月底` all resolve); when it cannot, only the date field is dropped and the memory is still written. An unauthorized ownership change keeps the target's existing scope.
@@ -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.77。**
7
+ > **版本:0.2.79。**
8
8
  > 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
9
9
  > 提炼契约现在只有一份中文语义契约(旧版英文 B3 提示词已删除),首轮、主题选择、候选修复和 `semantic_maintenance` 使用同一套保留价值判断;同一件事只写一次。
10
10
  > Core 不再因为归属、日期、任务依据或跨项目而丢弃整条候选:归属缺失会归一到证据里唯一被点名的项目、否则落 `global`;日期只接受能在本候选证据里找到出处、且能锚定成 ISO 的写法(`明天`、`周五`、`月底` 都会被解析),无法解析时只丢掉日期字段,记忆照常写入;未获授权的改归属沿用目标原归属。
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memleaf"
7
- version = "0.2.77"
7
+ version = "0.2.79"
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.77"
3
+ __version__ = "0.2.79"
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
@@ -34,7 +34,7 @@ def _sync_source_metadata(messages: Any, user_content: str, assistant_content: s
34
34
 
35
35
  Hermes' base API does not guarantee IDs or timestamps. Do not consult tools,
36
36
  attachments, older matching text, or a local clock to invent missing fields.
37
- Strings supplied to sync_turn remain the only captured conversation bodies.
37
+ This legacy helper supplies metadata only; host-v1 uses admission snapshots.
38
38
  """
39
39
  from datetime import datetime
40
40
  from math import isfinite
@@ -55,10 +55,10 @@ def _sync_source_metadata(messages: Any, user_content: str, assistant_content: s
55
55
  if isinstance(item, Mapping) and item.get("role") == "user"), None)
56
56
  if user is None:
57
57
  logger.info("memleaf source-metadata reason=user_missing")
58
- return {}
58
+ return _independent_assistant_time(assistant, assistant_content)
59
59
  if user.get("content") != user_content:
60
60
  logger.info("memleaf source-metadata reason=user_mismatch")
61
- return {}
61
+ return _independent_assistant_time(assistant, assistant_content)
62
62
  result: dict[str, dict[str, Any]] = {}
63
63
  for role, item in (("user", user), ("assistant", assistant)):
64
64
  row: dict[str, Any] = {}
@@ -101,6 +101,115 @@ def _sync_source_metadata(messages: Any, user_content: str, assistant_content: s
101
101
  return result
102
102
 
103
103
 
104
+ def _independent_assistant_time(assistant: Mapping[str, Any], content: str) -> dict[str, dict[str, Any]]:
105
+ from datetime import datetime
106
+ from math import isfinite
107
+ value = assistant.get("source_time")
108
+ if value is None:
109
+ value = assistant.get("timestamp")
110
+ parsed = None
111
+ try:
112
+ if type(value) in (int, float) and isfinite(value) and 0 <= value < 253402300800:
113
+ parsed = datetime.fromtimestamp(value).astimezone()
114
+ elif isinstance(value, str) and len(value) <= 80:
115
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
116
+ if parsed is not None and parsed.tzinfo is not None and parsed.utcoffset() is not None:
117
+ return {"assistant": {"source_time": parsed.isoformat()}}
118
+ except (ValueError, OSError, OverflowError):
119
+ pass
120
+ return {}
121
+
122
+
123
+ def _host_turn_id(context: Any) -> Optional[str]:
124
+ if not isinstance(context, Mapping) or context.get("version") != 1:
125
+ return None
126
+ raw = context.get("turn_id")
127
+ if not isinstance(raw, str) or not raw or len(raw) > 800 or any(c in raw for c in "\x00\r\n"):
128
+ return None
129
+ return "host-" + sha256(("v1:" + raw).encode()).hexdigest()[:24]
130
+
131
+
132
+ def _host_visible_events(messages: Any, context: Any, session_id: str,
133
+ assistant_content: str) -> Optional[list[dict[str, Any]]]:
134
+ """Resolve the host's enumerated visible user events, never split model text."""
135
+ if (_host_turn_id(context) is None or context.get("session_id") != session_id
136
+ or not isinstance(messages, list) or not messages):
137
+ return None
138
+ if type(context.get("turn_number")) is not int or context["turn_number"] <= 0:
139
+ return None
140
+ ids = context.get("user_message_ids")
141
+ if (not isinstance(ids, list) or not 1 <= len(ids) < 64
142
+ or any(not isinstance(i, str) or not i or len(i) > 800 for i in ids)
143
+ or len(set(ids)) != len(ids)):
144
+ return None
145
+ snapshots = context.get("user_events")
146
+ if (not isinstance(snapshots, list) or len(snapshots) != len(ids)
147
+ or any(not isinstance(row, Mapping) or row.get("role") != "user"
148
+ or row.get("message_uid") != uid for row, uid in zip(snapshots, ids))):
149
+ return None
150
+ final = messages[-1]
151
+ if (not isinstance(final, Mapping) or final.get("role") != "assistant"
152
+ or final.get("content") != assistant_content or final.get("tool_calls")):
153
+ return None
154
+ completed_uid = context.get("completed_assistant_message_id")
155
+ completed = completed_uid is not None
156
+ if completed and (not isinstance(completed_uid, str) or not completed_uid
157
+ or completed_uid != final.get("message_uid")):
158
+ return None
159
+ found = []
160
+ for uid, snapshot in zip(ids, snapshots):
161
+ matches = [(index, row) for index, row in enumerate(messages[:-1])
162
+ if isinstance(row, Mapping) and row.get("message_uid") == uid]
163
+ if len(matches) > 1 or (matches and matches[0][1].get("role") != "user"):
164
+ return None
165
+ if not matches and not completed:
166
+ return None
167
+ index = matches[0][0] if matches else None
168
+ # Live API content can contain host/model scaffolding. Only the clean
169
+ # admission snapshot supplies user facts and timestamps; live rows prove
170
+ # UID/role/order membership, not their subsequently modified text.
171
+ visible = _visible_message_text(snapshot)
172
+ if not visible.strip():
173
+ return None
174
+ item = {key: snapshot[key] for key in ("message_uid", "source_time", "timestamp", "source_sequence") if key in snapshot}
175
+ found.append((index, dict(item, role="user", content=visible)))
176
+ positions = [i for i, row in found if i is not None]
177
+ if positions != sorted(positions):
178
+ return None
179
+ # Every actual user after the anchor must belong to this turn. A stale
180
+ # context cannot capture a subsequent turn or borrow an old matching body.
181
+ if not completed:
182
+ actual = [row.get("message_uid") for row in messages[positions[0]:-1]
183
+ if isinstance(row, Mapping) and row.get("role") == "user"]
184
+ if actual != ids:
185
+ return None
186
+ # A completed host context is bound to this final UID and was frozen while
187
+ # its turn was active. Its admission ledger survives lossy compaction; API
188
+ # summary carriers are not new user events and never supply captured facts.
189
+ if not isinstance(final.get("message_uid"), str) or not final["message_uid"]:
190
+ return None
191
+ assistant = dict(final)
192
+ if isinstance(final.get("message_uid"), str):
193
+ assistant["message_id"] = final["message_uid"]
194
+ events = []
195
+ final_metadata = {}
196
+ for _, item in found:
197
+ user = dict(item, message_id=item["message_uid"])
198
+ pair = _sync_source_metadata([user, assistant], user["content"], assistant_content)
199
+ events.append({"role": "user", "content": user["content"], "metadata": pair.get("user", {})})
200
+ final_metadata = pair.get("assistant", {})
201
+ # Never manufacture a sequence by assuming a turn always has two events.
202
+ if not all(type(row.get("source_sequence")) is int for row in [r for _, r in found] + [final]):
203
+ for event in events:
204
+ event["metadata"].pop("source_sequence", None)
205
+ final_metadata.pop("source_sequence", None)
206
+ if events[-1]["metadata"].get("message_id"):
207
+ final_metadata["previous_message_id"] = events[-1]["metadata"]["message_id"]
208
+ events.append({"role": "assistant", "content": _capture_assistant_text(assistant_content),
209
+ "metadata": final_metadata})
210
+ return events
211
+
212
+
104
213
  def _capture_policy_status(config: Mapping[str, Any], vault: Path) -> dict[str, Any]:
105
214
  """Read the effective policy through the public Core MCP stats result.
106
215
 
@@ -196,6 +305,7 @@ class MemleafMemoryProvider(MemoryProvider):
196
305
  # bounded session -> job mapping so a later turn can poll the prior
197
306
  # result and request a rerun on the same job while it is active.
198
307
  self._process_jobs_by_session: "OrderedDict[str, str]" = OrderedDict()
308
+ self._bound_retrieval_calls: "OrderedDict[Tuple[str, str, str, str], str]" = OrderedDict()
199
309
  self._last_retrieval_observation = "unknown"
200
310
  self._last_retrieval_audit = "SEARCH_UNKNOWN"
201
311
 
@@ -842,14 +952,14 @@ class MemleafMemoryProvider(MemoryProvider):
842
952
  )
843
953
  self._version_warning_emitted = True
844
954
 
845
- def _queue_turn_number(self, turn_number: Any, message: Any) -> None:
955
+ def _queue_turn_number(self, turn_number: Any, message: Any, *, session_id: Optional[str] = None) -> None:
846
956
  if isinstance(turn_number, bool) or not isinstance(turn_number, int) or turn_number <= 0:
847
957
  return
848
958
  visible_user = _visible_message_text(message)
849
959
  if not visible_user.strip():
850
960
  return
851
961
  fingerprint = _visible_fingerprint(visible_user)
852
- session_id = self._canonical_session_id(self._session_id)
962
+ session_id = self._canonical_session_id(session_id or self._session_id)
853
963
  with self._sync_lock:
854
964
  queue = self._pending_turn_numbers.get(fingerprint)
855
965
  if queue is None:
@@ -950,7 +1060,11 @@ class MemleafMemoryProvider(MemoryProvider):
950
1060
  def on_turn_start(self, turn_number: Any, message: Any = None, **kwargs: Any) -> None:
951
1061
  """Remember only a bounded user-text fingerprint for later ``sync_turn``."""
952
1062
 
953
- del kwargs
1063
+ host_context = kwargs.get("turn_context")
1064
+ host_session = host_context.get("session_id") if isinstance(host_context, Mapping) else None
1065
+ turn_session = self._canonical_session_id(
1066
+ host_session if _host_turn_id(host_context) and isinstance(host_session, str) and host_session
1067
+ else self._session_id)
954
1068
  if not self._write_enabled:
955
1069
  return
956
1070
  if self._gate_enabled:
@@ -958,15 +1072,15 @@ class MemleafMemoryProvider(MemoryProvider):
958
1072
  # The token is created by the MCP server during this turn's
959
1073
  # scope_catalog call. Clearing the active value here prevents
960
1074
  # a skipped prefetch from reusing the previous turn's token.
961
- self._active_turn_numbers[self._session_id] = turn_number
962
- self._active_turn_numbers.move_to_end(self._session_id)
963
- self._active_retrieval_ids[self._session_id] = None
964
- self._active_retrieval_ids.move_to_end(self._session_id)
1075
+ self._active_turn_numbers[turn_session] = turn_number
1076
+ self._active_turn_numbers.move_to_end(turn_session)
1077
+ self._active_retrieval_ids[turn_session] = None
1078
+ self._active_retrieval_ids.move_to_end(turn_session)
965
1079
  visible_user = _visible_message_text(message)
966
- self._gate_turn_ids[(self._session_id, turn_number)] = (
967
- f"turn-{turn_number:06d}-{_visible_fingerprint(visible_user)}"
1080
+ self._gate_turn_ids[(turn_session, turn_number)] = (
1081
+ _host_turn_id(host_context) or f"turn-{turn_number:06d}-{_visible_fingerprint(visible_user)}"
968
1082
  )
969
- self._gate_turn_ids.move_to_end((self._session_id, turn_number))
1083
+ self._gate_turn_ids.move_to_end((turn_session, turn_number))
970
1084
  while len(self._active_turn_numbers) > _MAX_PENDING_TURN_NUMBERS:
971
1085
  self._active_turn_numbers.popitem(last=False)
972
1086
  while len(self._active_retrieval_ids) > _MAX_PENDING_TURN_NUMBERS:
@@ -974,7 +1088,7 @@ class MemleafMemoryProvider(MemoryProvider):
974
1088
  while len(self._gate_turn_ids) > _MAX_PENDING_TURN_NUMBERS:
975
1089
  self._gate_turn_ids.popitem(last=False)
976
1090
  self._last_retrieval_observation = "not_observed"
977
- self._queue_turn_number(turn_number, message)
1091
+ self._queue_turn_number(turn_number, message, session_id=turn_session)
978
1092
 
979
1093
  def on_session_switch(
980
1094
  self,
@@ -1413,6 +1527,7 @@ class MemleafMemoryProvider(MemoryProvider):
1413
1527
  vault_root: Optional[Path] = None,
1414
1528
  seen_call_keys: Any = None,
1415
1529
  audit_state: Optional[dict[str, Any]] = None,
1530
+ bound_retrieval_calls: Optional[Mapping[Tuple[str, str, str, str], str]] = None,
1416
1531
  ) -> str:
1417
1532
  """Observe explicit host MCP calls in public messages.
1418
1533
 
@@ -1433,6 +1548,9 @@ class MemleafMemoryProvider(MemoryProvider):
1433
1548
  continue
1434
1549
  search_ordinal += 1
1435
1550
  arguments = call.get("arguments")
1551
+ bound_token = (bound_retrieval_calls or {}).get((session_id, turn_id, call["name"], call.get("call_id", "")))
1552
+ if bound_token == retrieval_id and isinstance(retrieval_id, str):
1553
+ arguments = {"retrieval_id": bound_token}
1436
1554
  if not isinstance(arguments, Mapping) or arguments.get("retrieval_id") != retrieval_id:
1437
1555
  continue
1438
1556
  payload = _tool_result_for_call(call, calls, results, search_results_used)
@@ -1455,6 +1573,9 @@ class MemleafMemoryProvider(MemoryProvider):
1455
1573
  continue
1456
1574
  read_ordinal += 1
1457
1575
  arguments = call.get("arguments")
1576
+ bound_token = (bound_retrieval_calls or {}).get((session_id, turn_id, call["name"], call.get("call_id", "")))
1577
+ if bound_token == retrieval_id and isinstance(retrieval_id, str):
1578
+ arguments = {"retrieval_id": bound_token}
1458
1579
  retrieval_present = isinstance(arguments, Mapping) and "retrieval_id" in arguments
1459
1580
  retrieval_match = bool(
1460
1581
  retrieval_present
@@ -1611,6 +1732,7 @@ class MemleafMemoryProvider(MemoryProvider):
1611
1732
  catalog,
1612
1733
  retrieval_id=retrieval_id,
1613
1734
  scope_hint=_unique_query_scope(query, catalog),
1735
+ host_bound=bool((self._gate_turn_id(safe_session, turn_number) or "").startswith("host-")),
1614
1736
  )
1615
1737
  if not context:
1616
1738
  notices = [
@@ -1642,13 +1764,13 @@ class MemleafMemoryProvider(MemoryProvider):
1642
1764
  session_id: str = "",
1643
1765
  messages: Optional[List[Dict[str, Any]]] = None,
1644
1766
  turn_number: Optional[int] = None,
1767
+ turn_context: Optional[Mapping[str, Any]] = None,
1645
1768
  ) -> None:
1646
1769
  # ``messages`` may contain system prompts, tool calls/results, and
1647
- # attachment parts. Hermes already supplies the visible user and
1648
- # assistant strings separately; derive only a bounded search status
1649
- # from an explicit public memleaf tool result below. Optional source
1650
- # metadata is copied only from an exactly matched visible tail pair.
1651
- # Never capture the raw message list as business conversation content.
1770
+ # attachment parts. Host-v1 captures clean admission snapshots after
1771
+ # checking message membership; legacy input uses the supplied visible
1772
+ # strings and exact tail metadata. Tool results supply search status,
1773
+ # never user facts. Never capture the raw API message list as content.
1652
1774
  if not self._write_enabled or self._client is None:
1653
1775
  return
1654
1776
  raw_assistant_content = assistant_content
@@ -1666,26 +1788,41 @@ class MemleafMemoryProvider(MemoryProvider):
1666
1788
  # captured turn within this provider instance.
1667
1789
  with self._sync_lock:
1668
1790
  try:
1791
+ capture_context = turn_context
1792
+ if (isinstance(turn_context, Mapping) and isinstance(turn_context.get("session_id"), str)
1793
+ and self._canonical_session_id(turn_context["session_id"]) == effective_session):
1794
+ capture_context = {**turn_context, "session_id": effective_session}
1795
+ host_events = _host_visible_events(messages, capture_context, effective_session, raw_assistant_content)
1796
+ bound_turn_id = (_host_turn_id(capture_context) if isinstance(capture_context, Mapping)
1797
+ and capture_context.get("session_id") == effective_session else None)
1798
+ if turn_context is not None and host_events is None:
1799
+ logger.info("memleaf source-metadata reason=host_event_mismatch")
1800
+ return
1669
1801
  resolved_turn_number = turn_number
1802
+ if bound_turn_id and type(turn_context.get("turn_number")) is int:
1803
+ resolved_turn_number = turn_context["turn_number"]
1670
1804
  if resolved_turn_number is None:
1671
1805
  resolved_turn_number = self._take_turn_number(effective_session, user_content)
1672
1806
  elif isinstance(resolved_turn_number, int) and not isinstance(resolved_turn_number, bool):
1673
- self._discard_turn_number(effective_session, user_content, resolved_turn_number)
1807
+ queued_content = host_events[0]["content"] if host_events else user_content
1808
+ self._discard_turn_number(effective_session, queued_content, resolved_turn_number)
1674
1809
  turn_id = self._resolve_turn_id(
1675
1810
  effective_session,
1676
1811
  resolved_turn_number,
1677
1812
  user_content,
1678
1813
  captured_assistant_content,
1679
1814
  )
1680
- source_metadata = _sync_source_metadata(messages, user_content, raw_assistant_content)
1815
+ source_metadata = ({} if host_events is not None else
1816
+ _sync_source_metadata(messages, user_content, raw_assistant_content))
1681
1817
  user_source_id = source_metadata.get("user", {}).get("message_id")
1682
- if user_source_id:
1818
+ if bound_turn_id:
1819
+ turn_id = bound_turn_id
1820
+ elif user_source_id:
1683
1821
  # Stable host identity survives a revised body/final reply.
1684
1822
  turn_id = "source-" + sha256(user_source_id.encode("utf-8")).hexdigest()[:24]
1685
1823
  source_metadata["assistant"].setdefault("previous_message_id", user_source_id)
1686
1824
  retrieval_id = self._gate_id_for_turn(effective_session, resolved_turn_number)
1687
- if retrieval_id is None and resolved_turn_number is None:
1688
- retrieval_id = self._current_gate_id(effective_session)
1825
+ # A background callback must never attach itself to a newer turn.
1689
1826
  if self._gate_enabled:
1690
1827
  audit_state: dict[str, Any] = {}
1691
1828
  observation = self._observe_search_messages(
@@ -1696,22 +1833,31 @@ class MemleafMemoryProvider(MemoryProvider):
1696
1833
  vault_root=_resolve_vault(self._config()),
1697
1834
  seen_call_keys=self._observed_tool_call_keys,
1698
1835
  audit_state=audit_state,
1836
+ bound_retrieval_calls=self._bound_retrieval_calls,
1699
1837
  )
1700
1838
  while len(self._observed_tool_call_keys) > _MAX_OBSERVED_TOOL_CALL_KEYS:
1701
1839
  self._observed_tool_call_keys.popitem(last=False)
1702
1840
  self._last_retrieval_observation = observation
1703
1841
  self._last_retrieval_audit = str(audit_state.get("status") or "SEARCH_UNKNOWN")
1704
1842
  lineage_ready = self._retry_pending_lineage(effective_session)
1705
- sequence_base = resolved_turn_number * 2 if isinstance(resolved_turn_number, int) else None
1706
- for offset, (role, content) in enumerate(visible_events):
1843
+ if host_events is not None:
1844
+ logger.info("memleaf source-metadata contract=host-v1 user_events=%d source=hermes session=%s turn=%s",
1845
+ len(host_events) - 1, effective_session, turn_id)
1846
+ capture_events = host_events
1847
+ sequence_base = None
1848
+ else:
1849
+ capture_events = [{"role": role, "content": content, "metadata": source_metadata.get(role)}
1850
+ for role, content in visible_events]
1851
+ sequence_base = (resolved_turn_number * 2
1852
+ if not bound_turn_id and isinstance(resolved_turn_number, int) else None)
1853
+ for offset, event in enumerate(capture_events):
1854
+ role = event["role"]
1707
1855
  if not self._capture_visible(
1708
- session_id=effective_session,
1709
- turn_id=turn_id,
1710
- role=role,
1711
- content=content,
1856
+ session_id=effective_session, turn_id=turn_id, role=role,
1857
+ content=event["content"],
1712
1858
  source_sequence=(sequence_base + offset if sequence_base is not None else None),
1713
1859
  final=True if role == "assistant" else None,
1714
- source_metadata=source_metadata.get(role),
1860
+ source_metadata=event["metadata"],
1715
1861
  ):
1716
1862
  return
1717
1863
  if not self._auto_process:
@@ -1734,6 +1880,50 @@ class MemleafMemoryProvider(MemoryProvider):
1734
1880
  _error_type(error),
1735
1881
  )
1736
1882
 
1883
+ def bind_external_tool_call(self, tool_name: str, args: Dict[str, Any], *,
1884
+ turn_context: Any = None, tool_call_id: str = "") -> Optional[Dict[str, Any]]:
1885
+ """Bind the existing MCP surface at the trusted host dispatch boundary."""
1886
+ prefix = "mcp__memleaf__"
1887
+ if not tool_name.startswith(prefix):
1888
+ return None
1889
+ name = tool_name[len(prefix):]
1890
+ if name not in {"search", "read", "list_todos", "scope_catalog", "remember"}:
1891
+ return None
1892
+ turn_id = _host_turn_id(turn_context)
1893
+ session = turn_context.get("session_id") if isinstance(turn_context, Mapping) else None
1894
+ ordinal = turn_context.get("turn_number") if isinstance(turn_context, Mapping) else None
1895
+ if (not turn_id or not isinstance(session, str) or not session
1896
+ or type(ordinal) is not int or ordinal <= 0):
1897
+ raise ValueError("memory_host_identity_missing")
1898
+ session = self._canonical_session_id(session)
1899
+ if self._gate_turn_id(session, ordinal) != turn_id:
1900
+ raise ValueError("memory_host_turn_mismatch")
1901
+ bound = dict(args)
1902
+ if name == "remember":
1903
+ if not tool_call_id or tool_call_id.endswith("/"):
1904
+ raise ValueError("memory_host_operation_missing")
1905
+ bound.update(source="hermes", session_id=session, turn_id=turn_id,
1906
+ intent_id="host-op-" + sha256((turn_id + "/" + tool_call_id + "/" + name).encode()).hexdigest())
1907
+ bound.pop("event_id", None)
1908
+ # No source timestamp is assigned to an assistant-written composite
1909
+ # explicit request; only the submitted text itself is its evidence.
1910
+ bound.pop("source_time", None)
1911
+ elif name == "scope_catalog":
1912
+ bound.update(source="hermes", session_id=session, turn_id=turn_id)
1913
+ else:
1914
+ token = self._gate_id_for_turn(session, ordinal)
1915
+ if token is None:
1916
+ raise ValueError("memory_host_retrieval_unavailable")
1917
+ bound["retrieval_id"] = token
1918
+ call_id = tool_call_id.rsplit("/", 1)[-1]
1919
+ if call_id:
1920
+ key = (session, turn_id, tool_name, call_id)
1921
+ self._bound_retrieval_calls[key] = token
1922
+ self._bound_retrieval_calls.move_to_end(key)
1923
+ while len(self._bound_retrieval_calls) > _MAX_OBSERVED_TOOL_CALL_KEYS:
1924
+ self._bound_retrieval_calls.popitem(last=False)
1925
+ return bound
1926
+
1737
1927
  def get_tool_schemas(self) -> List[Dict[str, Any]]:
1738
1928
  # memleaf-mcp remains configured separately for deliberate search,
1739
1929
  # remember, and forget operations. The native provider owns automatic
@@ -285,6 +285,7 @@ def _scope_context(
285
285
  *,
286
286
  retrieval_id: Optional[str] = None,
287
287
  scope_hint: Optional[str] = None,
288
+ host_bound: bool = False,
288
289
  ) -> tuple[str, int]:
289
290
  """Render a bounded Scope Map without per-memory identifiers or text."""
290
291
 
@@ -313,7 +314,11 @@ def _scope_context(
313
314
  "Search/list_todos return directories; read only the selected memory when needed for ordinary "
314
315
  "relevance queries; for global todo queries, read every matching todo item. A no-match result is valid.\n"
315
316
  )
316
- if isinstance(retrieval_id, str) and retrieval_id:
317
+ if host_bound:
318
+ prefix += ("Hermes binds retrieval and retention identity at tool dispatch. "
319
+ "Supply business parameters only; omit retrieval_id, source, session_id, "
320
+ "turn_id, event_id and intent_id.\n")
321
+ elif isinstance(retrieval_id, str) and retrieval_id:
317
322
  prefix = (
318
323
  prefix
319
324
  + f"For this turn, pass retrieval_id={retrieval_id} to memleaf "
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.77
2
+ version: 0.2.79
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -151,6 +151,9 @@ def _source_valid(service, processed, work):
151
151
  return False
152
152
  try:
153
153
  turn, window = _window(service, work["source"], work["session_id"], work["turn_key"])
154
+ from .incremental_explicit import bindings_current
155
+ if not bindings_current(service, processed, work):
156
+ return False
154
157
  except (OSError, ValueError):
155
158
  return False
156
159
  return window == work["source_window"] and input_digest(turn) == work["source_digest"]
@@ -365,6 +368,10 @@ def apply_incremental(service: Any, *, response: str, expected_snapshot: str, in
365
368
  "selected_fragments": sum("native" in t for t in snapshot.state()["targets"].values()),
366
369
  "selection": "bounded_candidates", "read_only": True},
367
370
  "index_status": "dirty" if any(op["action"] in {"CREATE", "UPDATE"} and op["state"] == "prepared" for op in operations) else "current"}
371
+ if snapshot.state().get("explicit_writes"):
372
+ from .incremental_explicit import freeze_bindings
373
+ work["explicit_write_bindings"] = freeze_bindings(
374
+ snapshot.state()["explicit_writes"], snapshot.state()["targets"])
368
375
  if recovery_run is not None:
369
376
  work["recovery_parent"] = parent["work_id"]
370
377
  save_work(service, processed, work)
@@ -78,8 +78,8 @@ def remove_source_time_provenance_date(body: str, events: list[Mapping[str, Any]
78
78
 
79
79
  The source chain is stored by Core. A message timestamp is not evidence for
80
80
  a date in the memory's facts or deadline, even when a model copies that day
81
- into a decorative ``来源:`` line. Leave every other date for the strict
82
- content-date guard to validate.
81
+ into a decorative ``来源:`` line. Leave every other date unchanged; provenance matching does not
82
+ authorize or reject business dates.
83
83
  """
84
84
  source_days = {
85
85
  anchor.date().isoformat()
@@ -109,73 +109,6 @@ def remove_source_time_provenance_date(body: str, events: list[Mapping[str, Any]
109
109
  return "".join(lines)
110
110
 
111
111
 
112
- def content_date_violations(fields: Mapping[str, Any], events: list[Mapping[str, Any]],
113
- preserved: Mapping[str, Any] | None = None) -> list[dict[str, Any]]:
114
- """Locate unsupported dates without weakening the existing grounding guard.
115
-
116
- Only cited sources and the selected update target may ground new wording.
117
- Existing memory wording is preserved without a new source claim.
118
- """
119
- from .process_common import _summary_date_grounding_violations
120
-
121
- source_texts = [reading_text(str(event["text"])) for event in events]
122
- grounded: set[str] = set()
123
- clock_dates: dict[str, set[str]] = {}
124
- for event, source in zip(events, source_texts):
125
- anchor = parse_source_time(event.get("source_time"))
126
- local_day = anchor.date() if anchor is not None else None
127
- normalized = normalize_relative_calendar_text(source, local_day) if local_day else None
128
- grounded.update(token.canonical for token in calendar_tokens(source, local_day) if token.canonical)
129
- if normalized is not None:
130
- grounded.update(token.canonical for token in calendar_tokens(normalized, local_day) if token.canonical)
131
- for match in _RELATIVE_CALENDAR_EXPRESSION.finditer(source):
132
- clock = _CLOCK_AFTER_DATE.match(source, match.end())
133
- if clock:
134
- resolved = normalize_relative_calendar_text(match.group(), local_day)
135
- if resolved:
136
- clock_dates.setdefault(clock.group(1), set()).add(resolved)
137
- # An explicit source date at the same clock makes a relative binding
138
- # ambiguous; in that case the guard must not claim which event it is.
139
- for date_value, clock_value in _dated_clocks(source):
140
- clock_dates.setdefault(clock_value, set()).add(date_value)
141
- prior = preserved or {}
142
- texts = {key: fields[key] for key in ("title", "body") if key in fields}
143
- violations = []
144
- anchors = [{key: event[key] for key in ("ref", "source_time") if key in event}
145
- for event in events]
146
- for field, value in texts.items():
147
- unsupported = set(_summary_date_grounding_violations(
148
- {field: value}, grounded_dates=grounded, source_texts=source_texts,
149
- preserved_texts=[prior.get("title"), prior.get("body")]))
150
- for token in calendar_tokens(value):
151
- # A yearless literal cannot borrow a year merely for display.
152
- identity = (token.canonical if token.has_year else None) or token.raw
153
- if identity in unsupported:
154
- violations.append({"field": field, "start": token.start, "end": token.end,
155
- "text": token.raw, "normalized": token.canonical or token.monthday,
156
- "reason": "not_in_cited_evidence_or_target", "anchors": anchors})
157
- preserved_pairs = set().union(*(_dated_clocks(text) for text in (prior.get("title"), prior.get("body"))
158
- if isinstance(text, str)))
159
- for field, value in texts.items():
160
- for token in calendar_tokens(value):
161
- clock = _CLOCK_AFTER_DATE.match(value, token.end)
162
- if not token.canonical or clock is None:
163
- continue
164
- date_value, clock_value = token.canonical, clock.group(1)
165
- expected = clock_dates.get(clock_value, set())
166
- if len(expected) == 1 and date_value not in expected and (date_value, clock_value) not in preserved_pairs:
167
- violations.append({"field": field, "start": token.start, "end": token.end,
168
- "text": token.raw, "normalized": date_value,
169
- "reason": "relative_clock_date_mismatch",
170
- "expected_dates": sorted(expected), "anchors": anchors})
171
- return violations
172
-
173
-
174
- def invalid_content_dates(fields: Mapping[str, Any], events: list[Mapping[str, Any]],
175
- preserved: Mapping[str, Any] | None = None) -> bool:
176
- return bool(content_date_violations(fields, events, preserved))
177
-
178
-
179
112
  def selected_calendar(text: str, evidence: Mapping[str, Any]) -> dict[str, Any]:
180
113
  """Return a lossless text/anchor projection plus an optional calendar date.
181
114