memleaf 0.2.90__tar.gz → 0.2.92__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 (186) hide show
  1. {memleaf-0.2.90 → memleaf-0.2.92}/CHANGELOG.md +52 -0
  2. {memleaf-0.2.90/src/memleaf.egg-info → memleaf-0.2.92}/PKG-INFO +5 -4
  3. {memleaf-0.2.90 → memleaf-0.2.92}/README.en.md +4 -3
  4. {memleaf-0.2.90 → memleaf-0.2.92}/README.md +4 -3
  5. {memleaf-0.2.90 → memleaf-0.2.92}/pyproject.toml +1 -1
  6. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/compaction.py +15 -3
  8. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/extraction_work_state.py +48 -1
  9. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  10. memleaf-0.2.92/src/memleaf/historical_context.py +83 -0
  11. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_preview.py +32 -0
  12. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_prompts.py +1 -0
  13. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_protocol.py +10 -0
  14. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_recovery.py +6 -0
  15. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_semantics.py +26 -0
  16. memleaf-0.2.92/src/memleaf/lifecycle_maintenance.py +195 -0
  17. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/process_jobs.py +15 -3
  18. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/processing_route.py +4 -1
  19. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/remember_route.py +4 -1
  20. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/retention.py +22 -18
  21. {memleaf-0.2.90 → memleaf-0.2.92/src/memleaf.egg-info}/PKG-INFO +5 -4
  22. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf.egg-info/SOURCES.txt +2 -0
  23. {memleaf-0.2.90 → memleaf-0.2.92}/LICENSE +0 -0
  24. {memleaf-0.2.90 → memleaf-0.2.92}/MANIFEST.in +0 -0
  25. {memleaf-0.2.90 → memleaf-0.2.92}/docs/acceptance-evidence-guide.md +0 -0
  26. {memleaf-0.2.90 → memleaf-0.2.92}/docs/automatic-processing-route.md +0 -0
  27. {memleaf-0.2.90 → memleaf-0.2.92}/docs/body-compaction.md +0 -0
  28. {memleaf-0.2.90 → memleaf-0.2.92}/docs/candidate-reconciliation.md +0 -0
  29. {memleaf-0.2.90 → memleaf-0.2.92}/docs/capture-budget-design.md +0 -0
  30. {memleaf-0.2.90 → memleaf-0.2.92}/docs/config-migrations.md +0 -0
  31. {memleaf-0.2.90 → memleaf-0.2.92}/docs/core-refactor.md +0 -0
  32. {memleaf-0.2.90 → memleaf-0.2.92}/docs/development-closeout.md +0 -0
  33. {memleaf-0.2.90 → memleaf-0.2.92}/docs/evidence-retention.md +0 -0
  34. {memleaf-0.2.90 → memleaf-0.2.92}/docs/explicit-memory-update.md +0 -0
  35. {memleaf-0.2.90 → memleaf-0.2.92}/docs/extraction-latency.md +0 -0
  36. {memleaf-0.2.90 → memleaf-0.2.92}/docs/failed-run-recovery.md +0 -0
  37. {memleaf-0.2.90 → memleaf-0.2.92}/docs/gate-evidence-boundary.md +0 -0
  38. {memleaf-0.2.90 → memleaf-0.2.92}/docs/general-processing.md +0 -0
  39. {memleaf-0.2.90 → memleaf-0.2.92}/docs/hermes-mcp-runtime.md +0 -0
  40. {memleaf-0.2.90 → memleaf-0.2.92}/docs/hermes-provider-compatibility.md +0 -0
  41. {memleaf-0.2.90 → memleaf-0.2.92}/docs/hermes-source-comparison-context.md +0 -0
  42. {memleaf-0.2.90 → memleaf-0.2.92}/docs/host-acceptance-runbook.md +0 -0
  43. {memleaf-0.2.90 → memleaf-0.2.92}/docs/immutable-release-assets.md +0 -0
  44. {memleaf-0.2.90 → memleaf-0.2.92}/docs/incremental-commit-contract.md +0 -0
  45. {memleaf-0.2.90 → memleaf-0.2.92}/docs/incremental-execution.md +0 -0
  46. {memleaf-0.2.90 → memleaf-0.2.92}/docs/incremental-native-comparison.md +0 -0
  47. {memleaf-0.2.90 → memleaf-0.2.92}/docs/incremental-partial-recovery.md +0 -0
  48. {memleaf-0.2.90 → memleaf-0.2.92}/docs/incremental-planner-preview.md +0 -0
  49. {memleaf-0.2.90 → memleaf-0.2.92}/docs/incremental-runtime-contract.md +0 -0
  50. {memleaf-0.2.90 → memleaf-0.2.92}/docs/incremental-scope-registration.md +0 -0
  51. {memleaf-0.2.90 → memleaf-0.2.92}/docs/incremental-selected-retention.md +0 -0
  52. {memleaf-0.2.90 → memleaf-0.2.92}/docs/installed-artifact-verification.md +0 -0
  53. {memleaf-0.2.90 → memleaf-0.2.92}/docs/memory-lifecycle-tools.md +0 -0
  54. {memleaf-0.2.90 → memleaf-0.2.92}/docs/memory-state-contract.md +0 -0
  55. {memleaf-0.2.90 → memleaf-0.2.92}/docs/migration-preflight.md +0 -0
  56. {memleaf-0.2.90 → memleaf-0.2.92}/docs/processing-quality-acceptance.md +0 -0
  57. {memleaf-0.2.90 → memleaf-0.2.92}/docs/public-query-integrity.md +0 -0
  58. {memleaf-0.2.90 → memleaf-0.2.92}/docs/query-performance.md +0 -0
  59. {memleaf-0.2.90 → memleaf-0.2.92}/docs/remember-incremental-route.md +0 -0
  60. {memleaf-0.2.90 → memleaf-0.2.92}/docs/runtime-state-retention.md +0 -0
  61. {memleaf-0.2.90 → memleaf-0.2.92}/docs/semantic-extraction-protocol.md +0 -0
  62. {memleaf-0.2.90 → memleaf-0.2.92}/docs/source-work-contract.md +0 -0
  63. {memleaf-0.2.90 → memleaf-0.2.92}/docs/v0.2.26-processing-status.md +0 -0
  64. {memleaf-0.2.90 → memleaf-0.2.92}/examples/README.md +0 -0
  65. {memleaf-0.2.90 → memleaf-0.2.92}/examples/basic_usage.py +0 -0
  66. {memleaf-0.2.90 → memleaf-0.2.92}/examples/incremental_acceptance.json +0 -0
  67. {memleaf-0.2.90 → memleaf-0.2.92}/examples/mcp_stdio.ndjson +0 -0
  68. {memleaf-0.2.90 → memleaf-0.2.92}/examples/query_benchmark.py +0 -0
  69. {memleaf-0.2.90 → memleaf-0.2.92}/install.ps1 +0 -0
  70. {memleaf-0.2.90 → memleaf-0.2.92}/install.sh +0 -0
  71. {memleaf-0.2.90 → memleaf-0.2.92}/setup.cfg +0 -0
  72. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/__main__.py +0 -0
  73. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/acceptance.py +0 -0
  74. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/adapters/__init__.py +0 -0
  75. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/adapters/antigravity.py +0 -0
  76. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/adapters/base.py +0 -0
  77. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/adapters/codex.py +0 -0
  78. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/adapters/hermes.py +0 -0
  79. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/admission.py +0 -0
  80. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/batch_review.py +0 -0
  81. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/budget.py +0 -0
  82. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/capture.py +0 -0
  83. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/cli.py +0 -0
  84. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/config.py +0 -0
  85. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/create_coordinator.py +0 -0
  86. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/credentials.py +0 -0
  87. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/evidence_budget.py +0 -0
  88. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/evidence_policy.py +0 -0
  89. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/evidence_structure.py +0 -0
  90. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/evidence_syntax.py +0 -0
  91. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/explicit_text_source.py +0 -0
  92. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/extraction_budget.py +0 -0
  93. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/extraction_capability.py +0 -0
  94. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/frontmatter.py +0 -0
  95. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/hermes_provider/README.md +0 -0
  96. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/hermes_provider/__init__.py +0 -0
  97. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
  98. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/hermes_provider/_provider.py +0 -0
  99. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/hermes_provider/_shared.py +0 -0
  100. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
  101. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/hermes_provider/provider_compatibility.py +0 -0
  102. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/hermes_runtime.py +0 -0
  103. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/host_events.py +0 -0
  104. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/host_retention.py +0 -0
  105. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/host_runtime.py +0 -0
  106. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/host_turn_identity.py +0 -0
  107. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/inbox.py +0 -0
  108. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_commit.py +0 -0
  109. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_dates.py +0 -0
  110. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_execution.py +0 -0
  111. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_explicit.py +0 -0
  112. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_failed_recovery.py +0 -0
  113. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_journal.py +0 -0
  114. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_merge.py +0 -0
  115. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_native.py +0 -0
  116. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_partial.py +0 -0
  117. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_run_state.py +0 -0
  118. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_runtime.py +0 -0
  119. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_scopes.py +0 -0
  120. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/incremental_selection.py +0 -0
  121. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/index.py +0 -0
  122. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/inspection.py +0 -0
  123. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/installer.py +0 -0
  124. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/legacy_update_journal.py +0 -0
  125. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/llm/__init__.py +0 -0
  126. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/llm/base.py +0 -0
  127. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/llm/claude_compatible.py +0 -0
  128. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/llm/gemini.py +0 -0
  129. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/llm/openai_compatible.py +0 -0
  130. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/llm/router.py +0 -0
  131. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/llm/thinking.py +0 -0
  132. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/locking.py +0 -0
  133. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/mcp_server.py +0 -0
  134. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/memory_commit.py +0 -0
  135. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/memory_planner.py +0 -0
  136. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/memory_retraction.py +0 -0
  137. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/memory_update.py +0 -0
  138. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/memory_writer.py +0 -0
  139. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/migration.py +0 -0
  140. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/model_capabilities.py +0 -0
  141. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/model_discovery.py +0 -0
  142. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/model_execution.py +0 -0
  143. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/models.py +0 -0
  144. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/native_index.py +0 -0
  145. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/native_registration.py +0 -0
  146. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/parallel_model.py +0 -0
  147. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/planning_candidates.py +0 -0
  148. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/planning_context.py +0 -0
  149. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/process_common.py +0 -0
  150. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/process_journal.py +0 -0
  151. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/process_owner.py +0 -0
  152. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/processing.py +0 -0
  153. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/prompts.py +0 -0
  154. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/provenance.py +0 -0
  155. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/provider_compatibility.py +0 -0
  156. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/query_clock.py +0 -0
  157. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/query_progress.py +0 -0
  158. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/query_scan.py +0 -0
  159. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/receipt_codec.py +0 -0
  160. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/recording_policy.py +0 -0
  161. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/redaction.py +0 -0
  162. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/retrieval.py +0 -0
  163. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/retrieval_gate.py +0 -0
  164. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/retrieval_lifecycle.py +0 -0
  165. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/runtime_retention.py +0 -0
  166. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/scope_maintenance.py +0 -0
  167. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/scope_state.py +0 -0
  168. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/semantic_maintenance.py +0 -0
  169. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/semantic_protocol.py +0 -0
  170. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/service.py +0 -0
  171. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/single_pass_memory_planner.py +0 -0
  172. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/single_pass_plan.py +0 -0
  173. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/source_policy.py +0 -0
  174. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/state_layout.py +0 -0
  175. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/subprocess_flags.py +0 -0
  176. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/summary_batch.py +0 -0
  177. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/target_reconciliation.py +0 -0
  178. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/turn_audit.py +0 -0
  179. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/turn_plan.py +0 -0
  180. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/update_coordinator.py +0 -0
  181. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/update_review.py +0 -0
  182. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/validation.py +0 -0
  183. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf/vault.py +0 -0
  184. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf.egg-info/dependency_links.txt +0 -0
  185. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf.egg-info/entry_points.txt +0 -0
  186. {memleaf-0.2.90 → memleaf-0.2.92}/src/memleaf.egg-info/top_level.txt +0 -0
@@ -4,6 +4,58 @@ All notable changes to memleaf are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+
8
+ ## 0.2.92 — 2026-10-05
9
+
10
+ ### Preserve unresolved review outcomes and schedule bounded lifecycle maintenance
11
+
12
+ - Retain source-bound DEFERRED when review rejects UPDATE, MERGE, or explicitly
13
+ requested CREATE, even if other NO_CHANGE candidates survive. Only a validated
14
+ same-target correction covering the evidence and fields can resolve a rejected
15
+ UPDATE. Unrelated candidates cannot consume an unresolved host retention intent.
16
+ - Run deterministic history/provenance maintenance after normal process and
17
+ remember operations. Apply bounded version/age retention or keep_all, and
18
+ protect pending mutation and recovery dependencies without replaying old work.
19
+ - Schedule at most one optional body-compaction request per processing batch
20
+ after the configured threshold is reached. Use a freshly completed turn's
21
+ remaining durable allowance, with extraction and maintenance sharing its
22
+ five-request limit; never reopen completed budgets or allocate a new allowance.
23
+ - Persist recent input fingerprints and reservations before dispatch, including
24
+ no-op, failed and interrupted requests. Preserve compaction identity and all
25
+ metadata, serialize model ownership, recheck dependencies at commit, and report
26
+ maintenance status and observed token usage independently of committed facts.
27
+ - Ordinary turns below the threshold and cached replays add no model calls.
28
+ No new user-facing tool or model-output protocol field is introduced.
29
+ - Validation: 175 focused checks and seven isolated replay scenarios passed,
30
+ including the frozen real draft under approval and veto, host-intent retention,
31
+ process exit/restart, budget exhaustion, concurrent mutation protection, and
32
+ 41 public-process updates retaining one identity, 32 historical versions and
33
+ cumulative source accounting. External model calls were zero; new live-model
34
+ acceptance remains separate.
35
+
36
+ ## 0.2.91 — 2026-10-05
37
+
38
+ ### Preserve historical reads in incremental comparison
39
+
40
+ - Accept successfully read historical versions as read-only comparison context
41
+ instead of blocking the whole turn with required_target_unavailable. Preserve
42
+ every required read identity and include its current head only through a
43
+ validated exact link; missing or conflicting links remain unknown.
44
+ - Keep historical versions outside operation targets and source evidence. Their
45
+ scopes do not grant write authority, and current heads retain existing scope
46
+ boundaries. Ordinary turns continue to scan current knowledge only.
47
+ - Protect historical content and lifecycle links in planning snapshots and
48
+ preserve them through partial recovery with stable current-target references.
49
+ Existing snapshots without historical context retain their original digest.
50
+ - No new model stage, model output field or tool is added. Only required archive
51
+ versions enter model input; a short prompt clarification distinguishes them
52
+ from current records and new evidence.
53
+ - Validation: 189 focused checks passed, including 19 new history-context
54
+ regressions, plus isolated replay of the original 19-read real turn. Six
55
+ existing legacy-suite failures reproduce on 0.2.90 and are recorded locally.
56
+ Replay used supplied responses with zero external model calls and left the
57
+ formal Vault unchanged; new live-model acceptance remains separate.
58
+
7
59
  ## 0.2.90 — 2026-10-05
8
60
 
9
61
  ### Expose withdrawal state in explicit historical retrieval
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.90
3
+ Version: 0.2.92
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.90。**
26
+ > **版本:0.2.92。**
27
27
  > 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
28
28
  > 提炼先比较旧事项中的编号、目标、进展、责任和期限;补全未知信息也应维护原事项,另建独立待办不能替代项目事实更新。确认重复的记忆在现有处理请求中合并,保留历史与来源;项目和独立任务保持各自生命周期,Core 不按标题强行合并。
29
29
  > 新请求兼容等价的字段表示;未知执行人保持 `null`,新增或变更负责人和期限需要真实来源;等待条件在正文维护,不再提炼 `waiting_on`。行动及相关字段经过有界语义复核,提炼与复核共用每份工作的最多五次模型请求额度,成功即停止;复核失败不写入。来源时间用于解析相对日期,不补造事实日期。
@@ -462,8 +462,9 @@ history:
462
462
  - active Markdown 每条最多保留 16 个来源明细,同时记录累计 `source_count`、`source_digest` 和省略数量,避免 UPDATE 让 `sources` 无限膨胀;
463
463
  - 已完成/已取消 todo 保留在 `knowledge/` 中作为稳定当前身份,默认 active 清单不展示,但 `list_todos(status=completed|cancelled|all)` 和后续维护仍可按原 ID 定位;`closed_todo_retention_days` 仅作为旧配置兼容项保留;
464
464
  - `history.policy: bounded` 默认每个稳定记忆身份最多保留 32 个完整历史版本,且超过 3650 天的版本可被维护流程淘汰;需要永久审计时可显式设置 `history.policy: keep_all`;
465
- - compaction 会保留一个既有 canonical `memory_id`,不会再为整理结果创建新的 `mem-compact-*` 身份;单条精简保持原 ID,多条合并选择一个稳定 survivor。
466
- - 这些维护动作在正常 `process()` / `remember()` / `compact()` 生命周期中执行,不需要 daemon;普通只读检索本身不触发维护写入。
465
+ - 正文 compaction 逐条精简,保持原 `memory_id` 和来源、责任、状态、期限,不合并独立身份;同一事项的语义合并由提炼协议的 `MERGE` 处理。
466
+ - 正常 `process()` / `remember()` 结束后执行确定性历史维护;有未完成写入或恢复依赖时推迟维护,保留恢复输入。普通只读检索不触发维护写入。
467
+ - 新完成的轮次达到正文压缩阈值时,每批最多一次压缩调用,与该轮提炼共用不超过 5 次的持久额度。低于阈值、缓存重放、近期相同输入已尝试时不增加调用;持久保留最近 64 个输入指纹,压缩无收益、失败或进程中断也计入去重。维护结果独立报告,不将已提交的事实回滚。显式 `compact()` 仍只执行正文压缩,不隐式清理历史;无需 daemon。
467
468
 
468
469
  默认目录尽量使用 `0700`,文件默认明文保存。memleaf 没有内置加密层,请自行保护 Vault、备份和模型调用凭证。
469
470
 
@@ -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.90.**
7
+ > **Version: 0.2.92.**
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. Confirmed duplicates can be merged in the existing processing requests with their sources and history preserved. Independent project and task lifecycles remain separate; Core does not merge matching titles.
10
10
  > New requests accept equivalent field representations. Unknown executors remain `null`; new or changed responsibility and deadlines require actual source evidence. Dependencies belong in the body; `waiting_on` is no longer extracted. Proposed actions and related fields undergo bounded semantic verification within a shared five-request limit per work item, stopping on success; failed verification blocks writes. Source time resolves relative dates without inventing factual dates.
@@ -442,8 +442,9 @@ history:
442
442
  - Each active Markdown memory retains at most 16 detailed provenance rows while tracking cumulative `source_count`, `source_digest`, and omitted rows, so repeated UPDATEs cannot grow `sources` without bound.
443
443
  - Completed/cancelled todos remain in `knowledge/` as stable current identities. The default active view hides them, while `list_todos(status=completed|cancelled|all)` and later maintenance can still resolve the original ID. `closed_todo_retention_days` remains only as a legacy configuration key.
444
444
  - With `history.policy: bounded`, each stable memory identity keeps at most 32 full historical versions, and versions older than 3650 days are eligible for pruning. Set `history.policy: keep_all` explicitly when permanent audit retention is required.
445
- - Compaction preserves an existing canonical `memory_id` instead of creating a new `mem-compact-*` identity; a single-memory rewrite keeps its ID and a multi-memory merge chooses one stable survivor.
446
- - Maintenance runs through normal `process()` / `remember()` / `compact()` lifecycle calls and requires no daemon. Ordinary read-only retrieval does not trigger maintenance writes.
445
+ - Body compaction shortens each memory independently, preserving its ID, provenance, responsibility, status and deadline. Semantic consolidation of the same matter uses the extraction protocol's `MERGE` operation.
446
+ - Normal `process()` / `remember()` calls perform deterministic history maintenance after processing. Pending mutations and recovery dependencies postpone maintenance and preserve recovery inputs. Read-only retrieval does not trigger maintenance writes.
447
+ - Freshly completed turns can schedule at most one compaction request per batch when the configured threshold is reached, sharing that turn's durable five-request allowance with extraction. Below-threshold turns, cached replays and recently attempted identical inputs add no calls. The latest 64 input fingerprints are persisted, including no-op, failed and interrupted attempts. Maintenance results are reported separately without undoing committed facts. Explicit `compact()` remains body-only and does not implicitly prune history. No daemon is required.
447
448
 
448
449
  Directories are normally created with mode `0700`, and files are stored as plaintext by default. memleaf has no built-in encryption layer; protect the Vault, backups, and model credentials yourself.
449
450
 
@@ -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.90。**
7
+ > **版本:0.2.92。**
8
8
  > 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
9
9
  > 提炼先比较旧事项中的编号、目标、进展、责任和期限;补全未知信息也应维护原事项,另建独立待办不能替代项目事实更新。确认重复的记忆在现有处理请求中合并,保留历史与来源;项目和独立任务保持各自生命周期,Core 不按标题强行合并。
10
10
  > 新请求兼容等价的字段表示;未知执行人保持 `null`,新增或变更负责人和期限需要真实来源;等待条件在正文维护,不再提炼 `waiting_on`。行动及相关字段经过有界语义复核,提炼与复核共用每份工作的最多五次模型请求额度,成功即停止;复核失败不写入。来源时间用于解析相对日期,不补造事实日期。
@@ -443,8 +443,9 @@ history:
443
443
  - active Markdown 每条最多保留 16 个来源明细,同时记录累计 `source_count`、`source_digest` 和省略数量,避免 UPDATE 让 `sources` 无限膨胀;
444
444
  - 已完成/已取消 todo 保留在 `knowledge/` 中作为稳定当前身份,默认 active 清单不展示,但 `list_todos(status=completed|cancelled|all)` 和后续维护仍可按原 ID 定位;`closed_todo_retention_days` 仅作为旧配置兼容项保留;
445
445
  - `history.policy: bounded` 默认每个稳定记忆身份最多保留 32 个完整历史版本,且超过 3650 天的版本可被维护流程淘汰;需要永久审计时可显式设置 `history.policy: keep_all`;
446
- - compaction 会保留一个既有 canonical `memory_id`,不会再为整理结果创建新的 `mem-compact-*` 身份;单条精简保持原 ID,多条合并选择一个稳定 survivor。
447
- - 这些维护动作在正常 `process()` / `remember()` / `compact()` 生命周期中执行,不需要 daemon;普通只读检索本身不触发维护写入。
446
+ - 正文 compaction 逐条精简,保持原 `memory_id` 和来源、责任、状态、期限,不合并独立身份;同一事项的语义合并由提炼协议的 `MERGE` 处理。
447
+ - 正常 `process()` / `remember()` 结束后执行确定性历史维护;有未完成写入或恢复依赖时推迟维护,保留恢复输入。普通只读检索不触发维护写入。
448
+ - 新完成的轮次达到正文压缩阈值时,每批最多一次压缩调用,与该轮提炼共用不超过 5 次的持久额度。低于阈值、缓存重放、近期相同输入已尝试时不增加调用;持久保留最近 64 个输入指纹,压缩无收益、失败或进程中断也计入去重。维护结果独立报告,不将已提交的事实回滚。显式 `compact()` 仍只执行正文压缩,不隐式清理历史;无需 daemon。
448
449
 
449
450
  默认目录尽量使用 `0700`,文件默认明文保存。memleaf 没有内置加密层,请自行保护 Vault、备份和模型调用凭证。
450
451
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memleaf"
7
- version = "0.2.90"
7
+ version = "0.2.92"
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.90"
3
+ __version__ = "0.2.92"
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
@@ -605,10 +605,13 @@ class Compactor:
605
605
  replacements: list[_Replacement],
606
606
  *,
607
607
  now: str,
608
+ before_commit: Any = None,
608
609
  ) -> tuple[list[str], list[str]]:
609
610
  replacement_ids = [replacement.memory.memory_id for replacement in replacements]
610
611
  selected_by_id = {candidate.memory.memory_id: candidate for candidate in selected}
611
612
  with self.service._mutation_boundary():
613
+ if before_commit is not None:
614
+ before_commit()
612
615
  current_scan = scan_memories(self.service.vault)
613
616
  if current_scan.issues:
614
617
  raise CompactionError("current-memory scan changed during compaction")
@@ -737,7 +740,8 @@ class Compactor:
737
740
  "history_written": [],
738
741
  }
739
742
 
740
- def _run(self, *, model: Any = None, router: Any = None, explicit: bool) -> dict[str, Any]:
743
+ def _run(self, *, model: Any = None, router: Any = None, explicit: bool,
744
+ admit: Any = None, before_commit: Any = None) -> dict[str, Any]:
741
745
  threshold, ratio = self._config()
742
746
  now = _clock_now(getattr(self.service, "clock", None))
743
747
  selected, all_active, active_tokens = self._snapshot(threshold, ratio)
@@ -758,6 +762,11 @@ class Compactor:
758
762
  raise CompactionError("compaction input exceeds size limit")
759
763
  result["request_bytes"] = request_bytes
760
764
  backend = self._resolve_backend(model=model, router=router)
765
+ if admit is not None:
766
+ reason = admit(selected, threshold, ratio)
767
+ if reason is not None:
768
+ result.update(status="deferred", code=reason)
769
+ return result
761
770
  try:
762
771
  result["backend_calls"] += 1
763
772
  raw = backend.complete(
@@ -788,6 +797,7 @@ class Compactor:
788
797
  all_active,
789
798
  replacements,
790
799
  now=now,
800
+ before_commit=before_commit,
791
801
  )
792
802
  with self.service.vault.lock():
793
803
  active_after = [record.memory for record in self.service._read_memories_unlocked("knowledge")]
@@ -826,9 +836,11 @@ class Compactor:
826
836
  def compact(self, *, model: Any = None, router: Any = None) -> dict[str, Any]:
827
837
  return self._run(model=model, router=router, explicit=True)
828
838
 
829
- def auto(self, *, model: Any = None, router: Any = None) -> dict[str, Any]:
839
+ def auto(self, *, model: Any = None, router: Any = None,
840
+ admit: Any = None, before_commit: Any = None) -> dict[str, Any]:
830
841
  try:
831
- return self._run(model=model, router=router, explicit=False)
842
+ return self._run(model=model, router=router, explicit=False,
843
+ admit=admit, before_commit=before_commit)
832
844
  except Exception as error:
833
845
  status = _error_kind(error)
834
846
  return {
@@ -28,6 +28,7 @@ _MAX_BUDGET_BYTES = 8 * 1024 * 1024
28
28
  _PROCESS_JOB_VERSION = 1
29
29
  _MAX_WORKS = 128
30
30
  _MAX_TURNS_PER_WORK = 64
31
+ _MAX_MAINTENANCE_SNAPSHOTS = 64
31
32
 
32
33
 
33
34
  class ExtractionWorkStateError(RuntimeError):
@@ -95,11 +96,15 @@ def _normalize_turn_state(value: Any) -> dict[str, Any]:
95
96
  completed = value.get("completed", False)
96
97
  if not isinstance(completed, bool):
97
98
  raise ExtractionWorkStateError("invalid extraction request budget completion")
99
+ maintenance_reserved = value.get("maintenance_reserved", False)
100
+ if type(maintenance_reserved) is not bool or (maintenance_reserved and not completed):
101
+ raise ExtractionWorkStateError("invalid maintenance request reservation")
98
102
  return {
99
103
  "requests": requests,
100
104
  "started_at_epoch": float(started) if started is not None else None,
101
105
  "request_limit_at_creation": stored_limit,
102
106
  "completed": completed,
107
+ **({"maintenance_reserved": True} if maintenance_reserved else {}),
103
108
  }
104
109
 
105
110
 
@@ -164,7 +169,13 @@ def _read_budget_state_unlocked(vault: Any) -> dict[str, Any]:
164
169
  retired_count = sum(w.get("retired", False) for w in normalized_works.values())
165
170
  if len(normalized_works) - retired_count > _MAX_WORKS or retired_count > _MAX_RETIRED_WORKS:
166
171
  raise ExtractionWorkStateError("extraction request budget state exceeds bound")
167
- return {"version": value["version"], "works": normalized_works, "order": normalized_order}
172
+ snapshots = value.get("maintenance_snapshots", [])
173
+ if (not isinstance(snapshots, list) or len(snapshots) > _MAX_MAINTENANCE_SNAPSHOTS
174
+ or any(not isinstance(s, str) or re.fullmatch(r"[0-9a-f]{64}", s) is None for s in snapshots)
175
+ or len(set(snapshots)) != len(snapshots)):
176
+ raise ExtractionWorkStateError("invalid maintenance snapshots")
177
+ return {"version": value["version"], "works": normalized_works, "order": normalized_order,
178
+ **({"maintenance_snapshots": snapshots} if snapshots else {})}
168
179
 
169
180
 
170
181
  def _save_budget_state_unlocked(vault: Any, state: dict[str, Any]) -> None:
@@ -401,6 +412,42 @@ def complete_turn_budget(vault: Any, *, work_id: str, turn_id: str) -> bool:
401
412
  return False
402
413
 
403
414
 
415
+ def _remember_maintenance_snapshot_unlocked(vault: Any, state: dict[str, Any], snapshot: str) -> None:
416
+ if not isinstance(snapshot, str) or re.fullmatch(r"[0-9a-f]{64}", snapshot) is None:
417
+ raise ExtractionWorkStateError("invalid maintenance snapshot")
418
+ snapshots = [s for s in state.get("maintenance_snapshots", []) if s != snapshot]
419
+ state["maintenance_snapshots"] = (snapshots + [snapshot])[-_MAX_MAINTENANCE_SNAPSHOTS:]
420
+ _save_budget_state_unlocked(vault, state)
421
+
422
+
423
+ def _reserve_maintenance_unlocked(vault: Any, *, work_id: str, turn_id: str,
424
+ snapshot: str, minimum_requests: int = 0) -> tuple[int | None, str | None]:
425
+ """Caller holds the lock and proves the linked run fully committed.
426
+
427
+ Only one optional maintenance call can use the remaining allowance of a
428
+ completed turn. Completion stays sealed; extraction can never resume from
429
+ this reservation. Count and input deduplication precede outbound IO in the
430
+ same atomic file write, including uncertain process exits.
431
+ """
432
+ state = _read_budget_state_unlocked(vault)
433
+ work = state["works"].get(work_id)
434
+ turn = work.get("turns", {}).get(turn_id) if work else None
435
+ if not turn or not turn["completed"] or work.get("retired"):
436
+ return None, "maintenance_budget_unavailable"
437
+ if turn["requests"] < minimum_requests:
438
+ raise ExtractionWorkStateError("maintenance budget evidence incomplete")
439
+ if snapshot in state.get("maintenance_snapshots", []):
440
+ return None, "maintenance_snapshot_already_attempted"
441
+ if turn.get("maintenance_reserved"):
442
+ return None, "maintenance_already_reserved"
443
+ if turn["requests"] >= min(turn["request_limit_at_creation"], MAX_INCREMENTAL_REQUESTS):
444
+ return None, "request_budget_exhausted"
445
+ turn["requests"] += 1
446
+ turn["maintenance_reserved"] = True
447
+ _remember_maintenance_snapshot_unlocked(vault, state, snapshot)
448
+ return turn["requests"], None
449
+
450
+
404
451
  __all__ = [
405
452
  "ExtractionWorkStateError",
406
453
  "active_background_work_id",
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.90
2
+ version: 0.2.92
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -0,0 +1,83 @@
1
+ """Read-only historical comparisons, separate from actionable memory targets."""
2
+ from collections.abc import Mapping
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from .models import Memory
7
+ from .retrieval_lifecycle import project_lifecycle
8
+ from .turn_plan import revision_digest
9
+ from .vault import safe_component
10
+
11
+
12
+ def freeze_history(value: Any, targets: Mapping[str, dict[str, Any]]) -> dict[str, Any]:
13
+ if value is None:
14
+ return {}
15
+ if not isinstance(value, Mapping):
16
+ raise ValueError("invalid_history_context")
17
+ current = {t["memory"]["memory_id"]: t for t in targets.values()}
18
+ identities = {identity.casefold() for identity in current}
19
+ result = {}
20
+ for identity, item in value.items():
21
+ if (not isinstance(identity, str) or not isinstance(item, Mapping)
22
+ or set(item) != {"memory", "lifecycle"}
23
+ or not isinstance(item["memory"], Memory)
24
+ or identity != item["memory"].memory_id):
25
+ raise ValueError("invalid_history_context")
26
+ safe_component(identity, "memory id")
27
+ if identity.casefold() in identities:
28
+ raise ValueError("duplicate_memory_id")
29
+ identities.add(identity.casefold())
30
+ life = project_lifecycle(item["lifecycle"])
31
+ if not life["historical"]:
32
+ raise ValueError("invalid_history_context")
33
+ head = current.get(life.get("active_memory_id"))
34
+ validity = head["memory"].get("validity", "valid") if head and "native" not in head else None
35
+ if life["current_validity"] != validity:
36
+ raise ValueError("invalid_history_context")
37
+ result[identity] = {"memory": item["memory"].to_dict(),
38
+ "revision": revision_digest(item["memory"]), "lifecycle": life}
39
+ return result
40
+
41
+
42
+ def thaw_history(value: Any) -> dict[str, Any] | None:
43
+ if value is None:
44
+ return None
45
+ if not isinstance(value, Mapping):
46
+ raise ValueError("invalid_history_context")
47
+ result = {}
48
+ for identity, item in value.items():
49
+ if (not isinstance(item, Mapping) or set(item) != {"memory", "revision", "lifecycle"}
50
+ or not isinstance(item["memory"], Mapping)):
51
+ raise ValueError("invalid_history_context")
52
+ memory = Memory.from_mapping(item["memory"])
53
+ if revision_digest(memory) != item["revision"]:
54
+ raise ValueError("invalid_history_context")
55
+ result[identity] = {"memory": memory, "lifecycle": item["lifecycle"]}
56
+ return result
57
+
58
+
59
+ def history_signature(state: Mapping[str, Any]) -> dict[str, Any]:
60
+ # Current validity is derived from targets, whose changes (including this
61
+ # work's own writes) are already checked by partial recovery.
62
+ return {identity: {"revision": item["revision"],
63
+ "lifecycle": {k: v for k, v in item["lifecycle"].items()
64
+ if k != "current_validity"}}
65
+ for identity, item in state.get("history_context", {}).items()}
66
+
67
+
68
+ def project_history(state: Mapping[str, Any], scope_refs: Mapping[str, str]) -> list[dict[str, Any]]:
69
+ targets = {t["memory"]["memory_id"]: ref for ref, t in state["targets"].items()
70
+ if "native" not in t}
71
+ result = []
72
+ for item in state.get("history_context", {}).values():
73
+ memory = item["memory"]
74
+ row = {key: deepcopy(memory[key]) for key in (
75
+ "type", "title", "body", "status", "validity", "actionable", "assignee",
76
+ "due_date", "due_text", "due_status", "completed_at") if key in memory}
77
+ scopes = memory.get("scopes", ["global"])
78
+ row["scope"] = scope_refs.get(scopes[0], scopes[0]) if len(scopes) == 1 else scopes
79
+ life = dict(item["lifecycle"])
80
+ row["current_target"] = targets.get(life.pop("active_memory_id", None))
81
+ row["lifecycle"] = life
82
+ result.append(row)
83
+ return result
@@ -155,6 +155,37 @@ def _prepare_incremental_unlocked(service: Any, *, source: str, session_id: str,
155
155
  read_ids.extend(identities)
156
156
  priority = list(dict.fromkeys(explicit_ids + read_ids + priority))
157
157
  priority += sorted(x for x in prior_ids if x.casefold() in records and x not in priority)
158
+ historical = {}
159
+ explicit_keys = {identity.casefold() for identity in explicit_ids}
160
+ if any(identity.casefold() not in records for identity in priority):
161
+ # Ordinary turns keep their knowledge-only scan. Read/caller-required
162
+ # identities that are absent there may be exact historical versions.
163
+ # Reuse the public scanner's duplicate, path and generation checks.
164
+ library = scan_memories(service.vault, include_history=True)
165
+ if library.ambiguous or native_keys.intersection(r.memory.memory_id.casefold() for r in library.records):
166
+ raise ValueError("duplicate_memory_id")
167
+ if library.issues:
168
+ raise ValueError("incomplete_library")
169
+ records = {r.memory.memory_id.casefold(): r.memory for r in library.area("knowledge")}
170
+ records.update({key.casefold(): memory for key, memory in native.memories.items()})
171
+ archives = {r.memory.memory_id.casefold(): r for r in library.area("history")}
172
+ from .retrieval_lifecycle import current_validities, lifecycle
173
+ current = current_validities(library)
174
+ current_priority = []
175
+ for identity in priority:
176
+ if identity.casefold() in records:
177
+ current_priority.append(identity)
178
+ continue
179
+ archive = archives.get(identity.casefold())
180
+ if archive is None or identity.casefold() in explicit_keys:
181
+ raise ValueError("required_target_unavailable")
182
+ life = lifecycle(archive, current)
183
+ historical[archive.memory.memory_id] = {"memory": archive.memory, "lifecycle": life}
184
+ # A stored unambiguous local link supplies comparison context, not
185
+ # new write authority; the existing scope boundary still applies.
186
+ if life["current_validity"] is not None:
187
+ current_priority.append(life["active_memory_id"])
188
+ priority = list(dict.fromkeys(current_priority))
158
189
  chosen = []
159
190
  for identity in priority:
160
191
  if identity.casefold() not in records:
@@ -202,6 +233,7 @@ def _prepare_incremental_unlocked(service: Any, *, source: str, session_id: str,
202
233
  return PlanningSnapshot.build(evidence=evidence, targets=targets, scopes=scope_refs,
203
234
  write_scopes=boundary, writable=writable,
204
235
  allow_new_scopes=allow_new_scopes, native_targets=native_targets,
236
+ history_context=historical,
205
237
  native_guard=native.guard if native.guard["sources"] else None,
206
238
  request_kind="explicit_remember" if selection else "automatic",
207
239
  retention_request=retention_request, scope_guard=scope_guard,
@@ -39,4 +39,5 @@ patch 只含 title/body/scope/status/actionable/assignee/deadline/validity;typ
39
39
  request_kind=explicit_remember:所选 new 有保留授权,仍按用户实际要求选择内容并整理去重,不得 NO_MEMORY;独立不被要求保留的内容不强行建项。
40
40
  仅 whole_host_turn=true 的宿主完整回合显式请求:遵守用户指定的保存/排除范围,同时按自动记忆标准处理整轮其余有价值的独立新信息;不要因已保存一项而跳过其他。助手建议仍不能变成用户义务。
41
41
  explicit_writes 是系统核验的已写目标关联,不是新证据或整轮已覆盖声明:先比较当前目标与 new,完整无变化 NO_CHANGE,同一事项新增 UPDATE,独立新事项仍 CREATE。不用旧回执覆盖较新目标。
42
+ history_context 是已读历史版本,只供比较,不是新事实或操作 target;current_target 指向当前候选,current_validity=null 表示未知。历史正文及当时状态不覆盖当前候选。
42
43
  """
@@ -287,6 +287,7 @@ class PlanningSnapshot:
287
287
  allow_new_scopes: bool = False, context_complete: bool = True,
288
288
  native_targets: Mapping[str, dict[str, Any]] | None = None,
289
289
  native_guard: dict[str, Any] | None = None,
290
+ history_context: Mapping[str, Any] | None = None,
290
291
  retention_request: str | None = None, scope_guard: dict[str, Any] | None = None,
291
292
  scope_aliases: Mapping[str, list[str]] | None = None,
292
293
  basis_statuses: Mapping[str, str] | None = None,
@@ -371,6 +372,10 @@ class PlanningSnapshot:
371
372
  state = {"protocol_version": PROTOCOL_VERSION, "evidence": evidence, "targets": target_values,
372
373
  "scopes": scopes, "write_scopes": write_scopes, "request_kind": request_kind,
373
374
  "allow_new_scopes": allow_new_scopes, "context_complete": context_complete}
375
+ from .historical_context import freeze_history
376
+ historical = freeze_history(history_context, target_values)
377
+ if historical:
378
+ state["history_context"] = historical
374
379
  if extraction_contract is not None:
375
380
  state["extraction_contract"] = extraction_contract
376
381
  from .incremental_explicit import validate_links
@@ -415,6 +420,9 @@ class PlanningSnapshot:
415
420
  state["targets"] = {ref: {"revision": item["revision"], "writable": item["writable"],
416
421
  **({"native": item["native"]} if "native" in item else {})}
417
422
  for ref, item in state["targets"].items()}
423
+ if "history_context" in state:
424
+ state["history_context"] = {identity: {"revision": item["revision"], "lifecycle": item["lifecycle"]}
425
+ for identity, item in state["history_context"].items()}
418
426
  return hashlib.sha256(_json(state).encode("utf-8")).hexdigest()
419
427
 
420
428
  def state(self) -> dict[str, Any]:
@@ -454,6 +462,7 @@ class PlanningSnapshot:
454
462
  projection["calendar_hints"] = hints
455
463
  projected_evidence.append(projection)
456
464
  from .incremental_explicit import project_links
465
+ from .historical_context import project_history
457
466
  return {
458
467
  "protocol_version": PROTOCOL_VERSION, "request_kind": state["request_kind"],
459
468
  **({"extraction_contract": state["extraction_contract"]} if "extraction_contract" in state else {}),
@@ -463,6 +472,7 @@ class PlanningSnapshot:
463
472
  if state.get("scope_aliases") else {}),
464
473
  "allow_new_scopes": state["allow_new_scopes"], "context_complete": state["context_complete"],
465
474
  "evidence": projected_evidence, "memories": memories,
475
+ **({"history_context": project_history(state, reverse_scope)} if state.get("history_context") else {}),
466
476
  **({"explicit_writes": project_links(state["explicit_writes"], state["targets"])}
467
477
  if state.get("explicit_writes") else {}),
468
478
  }
@@ -12,6 +12,7 @@ from typing import Any
12
12
  from .incremental_protocol import PlanningSnapshot, MAX_ITEMS
13
13
  from .models import Memory
14
14
  from .validation import parse_strict_json
15
+ from .historical_context import thaw_history, history_signature
15
16
 
16
17
  RECOVERY_SYSTEM = "\n本次仅重规划未结算的 new;settled 只读,不重出、不改写。新增前文和已有记忆仅帮助理解原事项,不据此新建无关事项。沿用 JSON 协议。\n"
17
18
 
@@ -36,6 +37,7 @@ def restore_snapshot(state: Any) -> PlanningSnapshot:
36
37
  vault_binding=state.get("vault_binding"),
37
38
  explicit_writes=state.get("explicit_writes"),
38
39
  extraction_contract=state.get("extraction_contract"),
40
+ history_context=thaw_history(state.get("history_context")),
39
41
  )
40
42
  except (KeyError, TypeError) as error:
41
43
  raise ValueError("invalid_partial_snapshot") from error
@@ -175,6 +177,7 @@ def context_changed(original: PlanningSnapshot, current: PlanningSnapshot, work:
175
177
  new_e = {e["event_key"]: {k: v for k, v in e.items() if k != "ref"} for e in new["evidence"]}
176
178
  from .incremental_scopes import guard_matches, applied_additions
177
179
  if (old_e != new_e or old.get("native_guard") != new.get("native_guard")
180
+ or history_signature(old) != history_signature(new)
178
181
  or not guard_matches(new.get("scope_guard"), old.get("scope_guard"), applied_additions(work))):
179
182
  return True
180
183
  expected = {t["memory"]["memory_id"]: t["revision"] for t in old["targets"].values()}
@@ -247,6 +250,8 @@ def recovery_snapshot(original: PlanningSnapshot, current: PlanningSnapshot, wor
247
250
  raise ValueError("repair_context_changed")
248
251
  if old.get("native_guard") != new.get("native_guard"):
249
252
  raise ValueError("repair_context_changed")
253
+ if history_signature(old) != history_signature(new):
254
+ raise ValueError("repair_context_changed")
250
255
  # A structural repair may not silently adapt any referenced target's
251
256
  # earlier business decision to a different complete target snapshot.
252
257
  for row in work.get("_repair_rows", []):
@@ -270,6 +275,7 @@ def recovery_snapshot(original: PlanningSnapshot, current: PlanningSnapshot, wor
270
275
  scope_guard=new.get("scope_guard"), scope_aliases=new.get("scope_aliases"),
271
276
  basis_statuses=statuses, vault_binding=new.get("vault_binding"),
272
277
  explicit_writes=explicit_links,
278
+ history_context=thaw_history(new.get("history_context")),
273
279
  extraction_contract=(old if mode == "repair" else new).get("extraction_contract"))
274
280
 
275
281
 
@@ -153,6 +153,15 @@ def apply_review(request, response):
153
153
  if set(review) - {"decisions", "updates"} or not isinstance(decisions, list) or len(decisions) != len(rows):
154
154
  raise ValueError
155
155
  positions = set()
156
+ rejected = []
157
+ def discard(row):
158
+ # A rejected maintenance proposal is not proof that the source
159
+ # needs no change. Keep its unresolved disposition even when other
160
+ # candidates survive the review. Ordinary rejected automatic
161
+ # CREATEs can still mean there was no durable fact to retain.
162
+ if (row.get("action") in {"UPDATE", "MERGE"}
163
+ or data["original_input"].get("request_kind") == "explicit_remember"):
164
+ rejected.append(deepcopy(row))
156
165
  for decision in decisions:
157
166
  if (not isinstance(decision, dict) or not {"item", "keep", "field_support"} <= set(decision)
158
167
  or set(decision) - {"item", "keep", "field_support", "replace"}):
@@ -206,6 +215,7 @@ def apply_review(request, response):
206
215
  if isinstance(row, dict) and row.get("action") in {"NO_CHANGE", "NO_MEMORY", "DEFERRED"}:
207
216
  continue # Review cannot erase an unresolved control disposition.
208
217
  if not decision["keep"] or any(not support.get(k, True) for k in _REQUIRED.get(row["action"].upper(), set())):
218
+ discard(row)
209
219
  rows[i] = None
210
220
  continue
211
221
  payload = row.get("memory" if row["action"].upper() == "CREATE" else "patch", {})
@@ -241,6 +251,7 @@ def apply_review(request, response):
241
251
  if name == "deadline":
242
252
  row.pop("deadline_decision", None)
243
253
  if row["action"].upper() == "UPDATE" and not payload:
254
+ discard(row)
244
255
  rows[i] = None
245
256
  rows = [r for r in rows if r is not None]
246
257
  additions = review.get("updates", [])
@@ -256,6 +267,21 @@ def apply_review(request, response):
256
267
  raise ValueError
257
268
  rows.append(row)
258
269
  used.add(row["target"])
270
+ for rejected_row in rejected:
271
+ # The same review can supply a complete, independently validated
272
+ # correction for this target. An unrelated NO_CHANGE cannot cover
273
+ # a rejected proposal, nor can UPDATE stand in for a rejected MERGE.
274
+ if rejected_row.get("action") == "UPDATE" and any(
275
+ row.get("action") == "UPDATE" and row.get("target") == rejected_row.get("target")
276
+ and set(rejected_row.get("evidence", [])) <= set(row.get("evidence", []))
277
+ and fields(rejected_row) <= fields(row)
278
+ for row in rows):
279
+ continue
280
+ rows.append({"action": "DEFERRED", "evidence": rejected_row.get("evidence", []),
281
+ "reason": "missing_context",
282
+ "need": "The proposed retention or maintenance was rejected by source review."})
283
+ if len(rows) > MAX_ITEMS:
284
+ raise ValueError
259
285
  if not rows:
260
286
  original = data["original_input"]
261
287
  if original.get("request_kind") == "explicit_remember":