memleaf 0.2.34__tar.gz → 0.2.35__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 (188) hide show
  1. {memleaf-0.2.34 → memleaf-0.2.35}/CHANGELOG.md +8 -0
  2. {memleaf-0.2.34/src/memleaf.egg-info → memleaf-0.2.35}/PKG-INFO +1 -1
  3. memleaf-0.2.35/docs/processing-quality-acceptance.md +73 -0
  4. {memleaf-0.2.34 → memleaf-0.2.35}/pyproject.toml +1 -1
  5. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/__init__.py +1 -1
  6. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/config.py +25 -0
  7. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/llm/base.py +8 -0
  9. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/llm/router.py +10 -0
  10. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/memory_planner.py +179 -92
  11. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/model_execution.py +164 -15
  12. memleaf-0.2.35/src/memleaf/parallel_model.py +89 -0
  13. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/process_jobs.py +117 -2
  14. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/processing.py +14 -2
  15. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/prompts.py +28 -3
  16. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/turn_plan.py +6 -3
  17. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/update_coordinator.py +318 -173
  18. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/update_review.py +162 -76
  19. {memleaf-0.2.34 → memleaf-0.2.35/src/memleaf.egg-info}/PKG-INFO +1 -1
  20. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf.egg-info/SOURCES.txt +4 -0
  21. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_automatic_duplicate_noop_collision.py +4 -1
  22. memleaf-0.2.35/tests/test_processing_observability_concurrency.py +357 -0
  23. memleaf-0.2.35/tests/test_revision_digest.py +49 -0
  24. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_update_review.py +10 -9
  25. {memleaf-0.2.34 → memleaf-0.2.35}/IMPLEMENTATION_PLAN.md +0 -0
  26. {memleaf-0.2.34 → memleaf-0.2.35}/LICENSE +0 -0
  27. {memleaf-0.2.34 → memleaf-0.2.35}/MANIFEST.in +0 -0
  28. {memleaf-0.2.34 → memleaf-0.2.35}/README.en.md +0 -0
  29. {memleaf-0.2.34 → memleaf-0.2.35}/README.md +0 -0
  30. {memleaf-0.2.34 → memleaf-0.2.35}/RELEASE_CHECKLIST.md +0 -0
  31. {memleaf-0.2.34 → memleaf-0.2.35}/docs/capture-budget-design.md +0 -0
  32. {memleaf-0.2.34 → memleaf-0.2.35}/docs/config-migrations.md +0 -0
  33. {memleaf-0.2.34 → memleaf-0.2.35}/docs/core-refactor.md +0 -0
  34. {memleaf-0.2.34 → memleaf-0.2.35}/docs/evidence-retention.md +0 -0
  35. {memleaf-0.2.34 → memleaf-0.2.35}/docs/gate-evidence-boundary.md +0 -0
  36. {memleaf-0.2.34 → memleaf-0.2.35}/docs/general-processing.md +0 -0
  37. {memleaf-0.2.34 → memleaf-0.2.35}/docs/hermes-mcp-runtime.md +0 -0
  38. {memleaf-0.2.34 → memleaf-0.2.35}/docs/performance.md +0 -0
  39. {memleaf-0.2.34 → memleaf-0.2.35}/docs/v0.2.26-processing-status.md +0 -0
  40. {memleaf-0.2.34 → memleaf-0.2.35}/examples/README.md +0 -0
  41. {memleaf-0.2.34 → memleaf-0.2.35}/examples/basic_usage.py +0 -0
  42. {memleaf-0.2.34 → memleaf-0.2.35}/examples/live_core_lifecycle_acceptance.py +0 -0
  43. {memleaf-0.2.34 → memleaf-0.2.35}/examples/live_processing_acceptance.py +0 -0
  44. {memleaf-0.2.34 → memleaf-0.2.35}/examples/mcp_stdio.ndjson +0 -0
  45. {memleaf-0.2.34 → memleaf-0.2.35}/install.ps1 +0 -0
  46. {memleaf-0.2.34 → memleaf-0.2.35}/install.sh +0 -0
  47. {memleaf-0.2.34 → memleaf-0.2.35}/setup.cfg +0 -0
  48. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/__main__.py +0 -0
  49. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/adapters/__init__.py +0 -0
  50. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/adapters/antigravity.py +0 -0
  51. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/adapters/base.py +0 -0
  52. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/adapters/codex.py +0 -0
  53. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/adapters/hermes.py +0 -0
  54. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/admission.py +0 -0
  55. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/budget.py +0 -0
  56. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/capture.py +0 -0
  57. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/cli.py +0 -0
  58. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/compaction.py +0 -0
  59. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/create_coordinator.py +0 -0
  60. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/credentials.py +0 -0
  61. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/evidence_budget.py +0 -0
  62. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/evidence_policy.py +0 -0
  63. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/frontmatter.py +0 -0
  64. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/hermes_provider/README.md +0 -0
  65. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/hermes_provider/__init__.py +0 -0
  66. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
  67. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/hermes_runtime.py +0 -0
  68. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/host_events.py +0 -0
  69. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/host_runtime.py +0 -0
  70. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/inbox.py +0 -0
  71. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/index.py +0 -0
  72. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/inspection.py +0 -0
  73. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/installer.py +0 -0
  74. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/llm/__init__.py +0 -0
  75. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/llm/claude_compatible.py +0 -0
  76. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/llm/gemini.py +0 -0
  77. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/llm/openai_compatible.py +0 -0
  78. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/locking.py +0 -0
  79. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/mcp_server.py +0 -0
  80. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/memory_commit.py +0 -0
  81. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/memory_writer.py +0 -0
  82. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/model_discovery.py +0 -0
  83. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/models.py +0 -0
  84. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/native_index.py +0 -0
  85. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/native_registration.py +0 -0
  86. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/planning_context.py +0 -0
  87. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/process_common.py +0 -0
  88. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/process_journal.py +0 -0
  89. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/process_owner.py +0 -0
  90. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/provenance.py +0 -0
  91. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/recording_policy.py +0 -0
  92. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/redaction.py +0 -0
  93. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/retention.py +0 -0
  94. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/retrieval.py +0 -0
  95. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/retrieval_gate.py +0 -0
  96. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/scope_maintenance.py +0 -0
  97. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/scope_state.py +0 -0
  98. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/service.py +0 -0
  99. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/source_policy.py +0 -0
  100. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/state_layout.py +0 -0
  101. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/target_reconciliation.py +0 -0
  102. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/turn_audit.py +0 -0
  103. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/validation.py +0 -0
  104. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf/vault.py +0 -0
  105. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf.egg-info/dependency_links.txt +0 -0
  106. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf.egg-info/entry_points.txt +0 -0
  107. {memleaf-0.2.34 → memleaf-0.2.35}/src/memleaf.egg-info/top_level.txt +0 -0
  108. {memleaf-0.2.34 → memleaf-0.2.35}/tests/__init__.py +0 -0
  109. {memleaf-0.2.34 → memleaf-0.2.35}/tests/semantic_fixtures.py +0 -0
  110. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_admission_noise.py +0 -0
  111. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_candidate_polarity.py +0 -0
  112. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_codex_install.py +0 -0
  113. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_codex_native_cli.py +0 -0
  114. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_config_migrations_v028.py +0 -0
  115. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_context_budget.py +0 -0
  116. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_conversation_only.py +0 -0
  117. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_credential_safety.py +0 -0
  118. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_cross_host_acceptance.py +0 -0
  119. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_cross_turn_dedupe_regressions.py +0 -0
  120. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_due_date_grounding.py +0 -0
  121. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_due_date_grounding_retry.py +0 -0
  122. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_email_actionable_coverage.py +0 -0
  123. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_evidence_budget.py +0 -0
  124. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_evidence_retention_policy.py +0 -0
  125. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_external_source_dates.py +0 -0
  126. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_extraction_quality_regressions.py +0 -0
  127. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_gate_capacity.py +0 -0
  128. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_gate_schema_repair.py +0 -0
  129. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_general_evidence_admission.py +0 -0
  130. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_general_tool_provenance.py +0 -0
  131. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_global_todo_acceptance.py +0 -0
  132. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_global_todo_query_no_write.py +0 -0
  133. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_global_todo_retrieval.py +0 -0
  134. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_hermes_native_registration.py +0 -0
  135. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_hermes_provider.py +0 -0
  136. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_hermes_runtime_install.py +0 -0
  137. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_hermes_stdio_transport.py +0 -0
  138. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_hermes_transport_evidence.py +0 -0
  139. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_host_events.py +0 -0
  140. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_host_runtime_contract.py +0 -0
  141. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_inspection_state_v028.py +0 -0
  142. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_install.py +0 -0
  143. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_long_run_hygiene.py +0 -0
  144. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_maintenance_v2.py +0 -0
  145. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_model_discovery.py +0 -0
  146. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_model_owned_fields.py +0 -0
  147. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_new_scope_source_grounding.py +0 -0
  148. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_partial_retry_idempotency.py +0 -0
  149. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_phase2_model_decisions.py +0 -0
  150. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_process_jobs.py +0 -0
  151. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_process_owner_locking.py +0 -0
  152. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_processing_contract_v026.py +0 -0
  153. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_pypi_install.py +0 -0
  154. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_read_only_deferred_isolation.py +0 -0
  155. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_retrieval_gate.py +0 -0
  156. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_retrieval_v2.py +0 -0
  157. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_review_source_context.py +0 -0
  158. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_session_lineage.py +0 -0
  159. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_shared_memory_refactor.py +0 -0
  160. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_source_neutral_todos_v028.py +0 -0
  161. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_a.py +0 -0
  162. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_b1.py +0 -0
  163. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_b2a.py +0 -0
  164. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_b2b.py +0 -0
  165. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_b3a_commit.py +0 -0
  166. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_b3a_contract.py +0 -0
  167. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_b3b_native_context.py +0 -0
  168. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_b3b_native_index.py +0 -0
  169. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_b3b_scope.py +0 -0
  170. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_b3c_retrieval.py +0 -0
  171. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  172. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_c1_mcp.py +0 -0
  173. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_c2_init.py +0 -0
  174. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_stage_c3_packaging.py +0 -0
  175. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_state_layout_v028.py +0 -0
  176. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_summary_date_grounding_integration.py +0 -0
  177. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_target_reconciliation.py +0 -0
  178. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_target_reconciliation_integration.py +0 -0
  179. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_todo_state_recovery.py +0 -0
  180. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_update_target_recovery.py +0 -0
  181. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_upgrade_preserves_vault.py +0 -0
  182. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_v023_scope_correction.py +0 -0
  183. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_v2_gate_limits.py +0 -0
  184. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_v2_host_flow.py +0 -0
  185. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_v2_mcp_flow.py +0 -0
  186. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_v2_nomatch_semantics.py +0 -0
  187. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_v2_search_gate_acceptance.py +0 -0
  188. {memleaf-0.2.34 → memleaf-0.2.35}/tests/test_whole_unit_bindings.py +0 -0
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.35 — 2026-09-08
6
+
7
+ - Add safe structural model-call telemetry across successful, deferred, and failed processing: call/retry/failure counts, model-request and stage wall-clock timing, input/output lengths, and maximum in-flight concurrency are retained without prompts, responses, memory bodies, credentials, provider secrets, URLs, or raw exception text.
8
+ - Keep Gate admission serial, then allow bounded candidate-summary and final semantic-review concurrency only on explicitly parallel-safe model transports. `process.model_concurrency` defaults to 3 and is bounded to 1..8; host callbacks and caller-owned HTTP openers remain serial. Same-target UPDATE work stays ordered, target reconciliation happens before parallel work, and model calls never run while a Vault write lock is held.
9
+ - Tighten semantic completeness so admitted memories preserve meaning-defining named subjects/entities, deliverables, concrete actions or states, required business/workstream context, and each number/code with its source-stated role. Final review now treats omission or over-generalization as a quality failure, preserves uncertainty, and must not invent owners, deadlines, statuses, completion meaning, or numeric roles.
10
+ - Separate customer/project ownership from product/platform/system implementation context. Current-turn evidence remains authoritative for new relationships; existing memories are comparison context only and cannot create a new ownership or project-affiliation fact. Independent work remains source-neutrally split, while unresolved aggregate candidates defer instead of being silently discarded.
11
+ - Validation for this release: deterministic regression, same-input synthetic serial-vs-parallel comparison, Linux Python 3.11/3.12/3.13, Windows Python 3.11/3.12/3.13, macOS Python 3.11/3.13, wheel/sdist, installed entry points, and native Codex Windows/macOS gates passed. One Windows Python 3.12 full-regression attempt had an isolated failure and its targeted rerun passed. Authorized real-model replay, production-conversation acceptance, and same-input real-provider latency comparison were not run, so this release does not claim a specific replacement for the previously observed 433-second session.
12
+
5
13
  ## 0.2.34 — 2026-09-08
6
14
 
7
15
  - Restrict automatic memory extraction to the current turn's visible user input and final assistant reply. Raw tool output, attachments, web/file/terminal payloads and legacy tool-evidence bodies are not new source evidence; existing or retrieved memory remains comparison context rather than source authority. Explicit memory-write prohibitions still fail closed.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.34
3
+ Version: 0.2.35
4
4
  Summary: A local-first Markdown memory core for AI agents
5
5
  Author: memleaf contributors
6
6
  License-Expression: MIT
@@ -0,0 +1,73 @@
1
+ # Processing performance and semantic-quality acceptance
2
+
3
+ This document defines acceptance for automatic memory extraction without treating one validation layer as proof of another.
4
+
5
+ ## Invariants
6
+
7
+ - Markdown under `knowledge/` remains the active-memory source of truth; `history/` remains historical state.
8
+ - Gate stays serial. Model work must not run while a Vault write lock is held.
9
+ - Candidate/review parallelism is bounded and opt-in through an explicitly parallel-safe backend. The default maximum is 3.
10
+ - Same-target updates are coordinated before independent final reviews. Revision checks, idempotency and atomic commit remain authoritative.
11
+ - Performance improvements must not remove Gate, summarization, reconciliation or semantic-review checks that are required for the same input.
12
+ - Prompt/review rules are source-neutral. No mail-, calendar-, customer-, product- or other application-specific extraction rule is used.
13
+
14
+ ## Safe model telemetry
15
+
16
+ `process()` returns `model_metrics`, and detached process-job results retain the same bounded projection. Metrics contain only structural numbers:
17
+
18
+ - call count, retry count and failed-call count;
19
+ - cumulative model-request duration and stage wall-clock duration;
20
+ - input/output character and UTF-8 byte counts;
21
+ - maximum in-flight model calls;
22
+ - stage buckets for Gate, summarize, semantic review, coordination and target reconciliation.
23
+
24
+ Prompts, responses, memory bodies, source text, credentials and provider secrets are not retained by these metrics. Existing optional diagnostic logging keeps its separate bounded structural contract.
25
+
26
+ ## Semantic acceptance contract
27
+
28
+ A memory must remain understandable without reopening its source. For one admitted future-use topic, the summary/reviewer must preserve source-supported information that defines the item's meaning: the explicit subject or named entity, object/deliverable, concrete action or state, necessary business/workstream context, and any number or code together with its source-stated meaning when that meaning is required for interpretation.
29
+
30
+ Minimality does not permit semantic generalization. A concrete requirement cannot become only “related matters” or an umbrella coordination item. Scope metadata is not a substitute for a named subject in the title/body when that subject distinguishes the item. A customer/project that owns an item and a broader product/platform in which it is implemented are separate relationships; one must not silently replace the other. Existing memories are comparison context and cannot supply a new ownership/project relationship. If attribution, a number's role, ownership, deadline or status is not established, preserve uncertainty or defer rather than guess.
31
+
32
+ Independent deliverables that can be executed, tracked or closed independently remain independent memories. Same-turn aggregate reconciliation may merge only one future-use topic; otherwise the item remains deferred for reprocessing rather than being silently discarded as no change.
33
+
34
+ ## Acceptance layers
35
+
36
+ ### 1. Deterministic unit/regression tests
37
+
38
+ Required checks include:
39
+
40
+ - telemetry contains only allowlisted structural fields and counts retries correctly;
41
+ - unsafe Host/callable routes remain serial; a parallel-safe API route respects `process.model_concurrency`;
42
+ - final review concurrency preserves request/result ordering and does not mutate audit/Vault state from worker threads;
43
+ - semantic-review contracts require both non-invention and preservation of meaning-defining facts;
44
+ - existing negative-context, evidence-span, scope-operation/native-shadow, aggregate-splitting and Windows process-owner regressions continue to pass.
45
+
46
+ Passing this layer proves only the deterministic contracts under test.
47
+
48
+ ### 2. Cross-platform CI and MCP/background-runtime verification
49
+
50
+ Run the repository's supported OS matrix and the process-job/MCP tests. Verify that detached worker status exposes the bounded metrics, reruns aggregate metrics safely, and no model call is made while the Vault write lock is held.
51
+
52
+ Passing this layer proves the tested runtime paths and platforms. It does not prove that a real model will always extract every business fact.
53
+
54
+ ### 3. Authorized real-model replay in an isolated Vault
55
+
56
+ Use an explicitly authorized conversation and the same configured model/provider, but point the replay at an isolated temporary Vault. Record only the safe structural metrics plus the resulting test memories. Check at least:
57
+
58
+ - omissions of subject/customer, concrete requirement, business context, and numeric meaning;
59
+ - incorrect project/customer attribution or product-as-owner substitution;
60
+ - incorrect todo state, owner, date or completion inference;
61
+ - independent-item splitting and aggregate deferral/reprocessing;
62
+ - duplicate/update behavior under repeated identical input;
63
+ - sensitive-data boundaries and the configured external-provider authorization.
64
+
65
+ For a before/after performance comparison, use the same captured input and equivalent model configuration. Compare model call count, retries, stage request-duration totals, stage wall-clock duration, maximum concurrency and final-memory quality. Do not claim a latency regression or improvement from different source input.
66
+
67
+ ### 4. Production conversation observation
68
+
69
+ After release, compare one or more normal authorized production sessions against the same semantic checklist. Production observations are separate evidence from an isolated replay because model/service latency and real conversation structure can differ.
70
+
71
+ ## Delivery reporting
72
+
73
+ Every delivery report must identify each layer as **passed**, **failed**, or **not run**. Never promote “unit tests passed” or “CI passed” into a claim that real-model semantic acceptance passed. If the real conversation content is not authorized for a particular external model/service, do not send it merely to complete acceptance; report that layer as not run.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memleaf"
7
- version = "0.2.34"
7
+ version = "0.2.35"
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.34"
3
+ __version__ = "0.2.35"
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
@@ -16,6 +16,9 @@ from .scope_state import ScopeError, validate_scope_registry
16
16
  DEFAULT_REQUEST_TIMEOUT = 120
17
17
  MIN_REQUEST_TIMEOUT = 1
18
18
  MAX_REQUEST_TIMEOUT = 240
19
+ DEFAULT_MODEL_CONCURRENCY = 3
20
+ MIN_MODEL_CONCURRENCY = 1
21
+ MAX_MODEL_CONCURRENCY = 8
19
22
 
20
23
 
21
24
  def _normalize_request_timeout(value: Any) -> int | float:
@@ -30,6 +33,14 @@ def _normalize_request_timeout(value: Any) -> int | float:
30
33
  return int(parsed) if parsed.is_integer() else parsed
31
34
 
32
35
 
36
+ def _normalize_model_concurrency(value: Any) -> int:
37
+ if isinstance(value, bool) or not isinstance(value, int):
38
+ raise ValueError("invalid memleaf process.model_concurrency")
39
+ if not MIN_MODEL_CONCURRENCY <= value <= MAX_MODEL_CONCURRENCY:
40
+ raise ValueError("invalid memleaf process.model_concurrency")
41
+ return value
42
+
43
+
33
44
  DEFAULT_CONFIG: dict[str, Any] = {
34
45
  "vault": "~/.memleaf",
35
46
  "agents": {"codex": True, "hermes": True, "antigravity": False},
@@ -40,6 +51,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
40
51
  "memory_compact_candidate_ratio": 0.30,
41
52
  "inbox_cleanup_hours": 24,
42
53
  "closed_todo_retention_days": 30,
54
+ "model_concurrency": DEFAULT_MODEL_CONCURRENCY,
43
55
  },
44
56
  "history": {
45
57
  "policy": "bounded",
@@ -157,6 +169,11 @@ def load_config(path: Path | str, *, vault: Path | str | None = None) -> dict[st
157
169
  closed_todo_days = process.get("closed_todo_retention_days") if isinstance(process, Mapping) else None
158
170
  if type(closed_todo_days) is not int or closed_todo_days < 0:
159
171
  raise ValueError("invalid memleaf process.closed_todo_retention_days")
172
+ _normalize_model_concurrency(
173
+ process.get("model_concurrency", DEFAULT_MODEL_CONCURRENCY)
174
+ if isinstance(process, Mapping)
175
+ else DEFAULT_MODEL_CONCURRENCY
176
+ )
160
177
  history = merged.get("history")
161
178
  if not isinstance(history, Mapping):
162
179
  raise ValueError("invalid memleaf history settings")
@@ -199,6 +216,14 @@ def save_config(path: Path | str, config: Mapping[str, Any]) -> None:
199
216
  validate_native_sources(normalized.get("native_sources", {}), base_dir=Path(path).parent)
200
217
  except NativeConfigError as error:
201
218
  raise ValueError("invalid memleaf native_sources") from error
219
+ process = normalized.get("process")
220
+ if not isinstance(process, Mapping):
221
+ raise ValueError("invalid memleaf process settings")
222
+ normalized_process = dict(process)
223
+ normalized_process["model_concurrency"] = _normalize_model_concurrency(
224
+ normalized_process.get("model_concurrency", DEFAULT_MODEL_CONCURRENCY)
225
+ )
226
+ normalized["process"] = normalized_process
202
227
  llm = normalized.get("llm")
203
228
  if not isinstance(llm, Mapping):
204
229
  raise ValueError("invalid memleaf llm settings")
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.34
2
+ version: 0.2.35
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -142,6 +142,7 @@ class ModelUnavailable(ModelError):
142
142
  class ModelBackend(Protocol):
143
143
  provider: str
144
144
  model: str
145
+ parallel_safe: bool
145
146
 
146
147
  def complete(
147
148
  self,
@@ -158,6 +159,9 @@ class CallableBackend:
158
159
  """Adapt an explicitly injected host/test callback to ``ModelBackend``."""
159
160
 
160
161
  provider = "host"
162
+ # Host callbacks can close over arbitrary mutable state. Never assume a
163
+ # caller-owned callback is thread-safe merely because candidate work is.
164
+ parallel_safe = False
161
165
 
162
166
  def __init__(self, callback: Callable[..., str], *, model: str = "host"):
163
167
  if not callable(callback):
@@ -220,6 +224,7 @@ class HTTPModelBackend:
220
224
  """Common standard-library HTTP transport for compatible adapters."""
221
225
 
222
226
  provider = "api"
227
+ parallel_safe = False
223
228
 
224
229
  def __init__(
225
230
  self,
@@ -241,6 +246,9 @@ class HTTPModelBackend:
241
246
  self.model = model
242
247
  self.timeout = normalize_request_timeout(timeout)
243
248
  self._opener = opener or urllib.request.urlopen
249
+ # The built-in stateless urllib transport can be used concurrently.
250
+ # An injected opener is caller-owned and therefore defaults to serial.
251
+ self.parallel_safe = opener is None
244
252
 
245
253
  @staticmethod
246
254
  def _is_timeout_reason(value: Any) -> bool:
@@ -52,6 +52,16 @@ class ModelRouter:
52
52
  mode = kwargs.pop("mode", llm_config.get("mode", "auto"))
53
53
  return cls(mode=mode, config=llm_config, **kwargs)
54
54
 
55
+ @property
56
+ def parallel_safe(self) -> bool:
57
+ """Expose concurrency only when routing cannot fall through a host callback."""
58
+
59
+ if self.mode == "api":
60
+ return getattr(self.api, "parallel_safe", False) is True
61
+ if self.mode == "auto" and self.host is None:
62
+ return getattr(self.api, "parallel_safe", False) is True
63
+ return False
64
+
55
65
  @staticmethod
56
66
  def _coerce_host(value: Any) -> Optional[ModelBackend]:
57
67
  if value is None:
@@ -14,6 +14,7 @@ from .create_coordinator import CreateCoordinator
14
14
  from .update_coordinator import UpdateCoordinator
15
15
  from .target_reconciliation import reconcile_candidate_target
16
16
  from .evidence_policy import retain_tool_evidence
17
+ from .parallel_model import run_ordered_keyed_jobs
17
18
  from .prompts import COVERAGE_ALREADY_COMPLETED_CORRECTION, COVERAGE_CORRECTION, GATE_SYSTEM, SUMMARIZE_SYSTEM, gate_prompt, summarize_prompt
18
19
  from .retrieval import normalize_term
19
20
  from .scope_state import ScopeError, normalize_scopes
@@ -1008,6 +1009,18 @@ class MemoryPlanner:
1008
1009
  seen_candidates: set[tuple[Any, ...]] = set()
1009
1010
  seen_duplicate_targets: set[str] = set()
1010
1011
  admitted_candidates: dict[str, dict[str, Any]] = {}
1012
+ summary_jobs: list[dict[str, Any]] = []
1013
+ request_slots: list[dict[str, Any]] = []
1014
+
1015
+ def observe_scopes(values: Iterable[Any]) -> None:
1016
+ for observed_scope in values:
1017
+ if (
1018
+ isinstance(observed_scope, str)
1019
+ and observed_scope != "unscoped"
1020
+ and observed_scope not in observed_scopes
1021
+ ):
1022
+ observed_scopes.append(observed_scope)
1023
+
1011
1024
  for candidate in gate["candidates"]:
1012
1025
  candidate = dict(candidate)
1013
1026
  duplicate_target = candidate.get("duplicate_memory_id")
@@ -1266,18 +1279,17 @@ class MemoryPlanner:
1266
1279
  )
1267
1280
  continue
1268
1281
  if correction_plan is not None and correction_plan.get("survivor_memory_id"):
1269
- requests.append(
1270
- self.inputs._scope_correction_request(
1282
+ request_slots.append({
1283
+ "kind": "request",
1284
+ "request": self.inputs._scope_correction_request(
1271
1285
  candidate,
1272
1286
  turn,
1273
1287
  correction_plan,
1274
1288
  conversation_title=title,
1275
1289
  native_refs=candidate_native_refs,
1276
- )
1277
- )
1278
- for observed_scope in candidate.get("scopes", []):
1279
- if isinstance(observed_scope, str) and observed_scope != "unscoped" and observed_scope not in observed_scopes:
1280
- observed_scopes.append(observed_scope)
1290
+ ),
1291
+ "scopes": list(candidate.get("scopes", [])),
1292
+ })
1281
1293
  continue
1282
1294
  candidate_native_ids = [item["native_id"] for item in candidate_native_refs]
1283
1295
  all_candidate_memory_ids: list[str] = []
@@ -1335,13 +1347,7 @@ class MemoryPlanner:
1335
1347
  ),
1336
1348
  [],
1337
1349
  )
1338
- for observed_scope in duplicate_scopes:
1339
- if (
1340
- isinstance(observed_scope, str)
1341
- and observed_scope != "unscoped"
1342
- and observed_scope not in observed_scopes
1343
- ):
1344
- observed_scopes.append(observed_scope)
1350
+ request_slots.append({"kind": "observe", "scopes": list(duplicate_scopes)})
1345
1351
  self.audit._record_disposition(
1346
1352
  turn_ref,
1347
1353
  candidate,
@@ -1378,75 +1384,160 @@ class MemoryPlanner:
1378
1384
  admitted_summary_keys = tuple(dict.fromkeys(event["event_key"] for event in admitted_summary_events))
1379
1385
  grounded_summary_dates = _grounded_due_dates(turn, evidence_events=admitted_summary_events)
1380
1386
  summary_target = self.inputs._active_memory_by_id(gate_update_target) if gate_update_target else None
1381
- try:
1382
- def parse_summary(raw: str) -> dict[str, Any]:
1383
- parsed = parse_summarize_output(
1384
- _normalize_summary_dates(raw, turn, candidate),
1385
- current_event_keys=admitted_summary_keys,
1386
- related_native_ids=candidate_native_ids,
1387
- related_memory_ids=same_type_update_memory_ids,
1388
- scope_registry=validation_scope_registry,
1389
- expected_scopes=candidate["scopes"],
1390
- expected_scope_source=candidate["scope_source"],
1391
- allowed_due_dates=grounded_summary_dates,
1392
- allow_no_change=True,
1393
- # The summarize stage may not reinterpret a gate
1394
- # candidate, including CREATE candidates. Updates
1395
- # additionally retain the active target's immutable
1396
- # type below.
1397
- expected_type=candidate.get("type"),
1398
- allow_update_target=gate_update_target is not None,
1399
- expected_update_memory_id=gate_update_target,
1400
- expected_target_type=gate_target_type,
1401
- )
1402
- if _summary_date_grounding_violations(
1403
- parsed,
1404
- grounded_dates=grounded_summary_dates,
1405
- source_texts=[event.get("content", "") for event in admitted_summary_events if event.get("role") in {"user", "assistant"}],
1406
- preserved_texts=(summary_target.title, summary_target.body, summary_target.due_date) if summary_target else (),
1407
- ):
1408
- raise ModelOutputError("summary contains a date absent from its admitted evidence", validation_detail="relative_time")
1409
- return parsed
1410
-
1411
- target_revisions = {}
1412
- for related_item in candidate_related:
1413
- if isinstance(related_item.get("memory_id"), str):
1414
- target_memory = self.inputs._active_memory_by_id(related_item["memory_id"])
1415
- if target_memory is not None:
1416
- target_revisions[target_memory.memory_id] = revision_digest(target_memory)
1417
- summary_candidate = dict(candidate)
1418
- model_candidate_id = summary_candidate.pop("_model_candidate_id", None)
1419
- if isinstance(model_candidate_id, str) and model_candidate_id:
1420
- summary_candidate["candidate_id"] = model_candidate_id
1421
- summary = self.model._complete_json_stage(
1422
- backend,
1423
- summarize_prompt(
1424
- summary_candidate,
1425
- admitted_summary_events,
1426
- related_memories=candidate_related,
1427
- scope_background=candidate_scope_background,
1428
- scope_registry=scope_registry,
1429
- ),
1430
- system=SUMMARIZE_SYSTEM,
1431
- purpose="summarize",
1432
- parser=parse_summary,
1433
- diagnostic_context={
1434
- "source": turn.source,
1435
- "session_id": turn.session_id,
1436
- "turn_index": turn.turn_index,
1437
- },
1387
+
1388
+ def parse_summary(
1389
+ raw: str,
1390
+ *,
1391
+ candidate_value: Mapping[str, Any] = candidate,
1392
+ admitted_keys_value: tuple[str, ...] = admitted_summary_keys,
1393
+ native_ids_value: list[str] = candidate_native_ids,
1394
+ update_ids_value: list[str] = same_type_update_memory_ids,
1395
+ grounded_dates_value: set[str] = grounded_summary_dates,
1396
+ admitted_events_value: list[dict[str, Any]] = admitted_summary_events,
1397
+ summary_target_value: Any = summary_target,
1398
+ gate_update_target_value: Any = gate_update_target,
1399
+ gate_target_type_value: Any = gate_target_type,
1400
+ ) -> dict[str, Any]:
1401
+ parsed = parse_summarize_output(
1402
+ _normalize_summary_dates(raw, turn, candidate_value),
1403
+ current_event_keys=admitted_keys_value,
1404
+ related_native_ids=native_ids_value,
1405
+ related_memory_ids=update_ids_value,
1406
+ scope_registry=validation_scope_registry,
1407
+ expected_scopes=candidate_value["scopes"],
1408
+ expected_scope_source=candidate_value["scope_source"],
1409
+ allowed_due_dates=grounded_dates_value,
1410
+ allow_no_change=True,
1411
+ # The summarize stage may not reinterpret a gate
1412
+ # candidate, including CREATE candidates. Updates
1413
+ # additionally retain the active target's immutable
1414
+ # type below.
1415
+ expected_type=candidate_value.get("type"),
1416
+ allow_update_target=gate_update_target_value is not None,
1417
+ expected_update_memory_id=gate_update_target_value,
1418
+ expected_target_type=gate_target_type_value,
1438
1419
  )
1439
- except ModelOutputError as error:
1440
- if getattr(error, "validation_detail", None) not in {
1441
- "relative_time",
1442
- "due_date_not_grounded",
1443
- }:
1444
- raise
1420
+ if _summary_date_grounding_violations(
1421
+ parsed,
1422
+ grounded_dates=grounded_dates_value,
1423
+ source_texts=[
1424
+ event.get("content", "")
1425
+ for event in admitted_events_value
1426
+ if event.get("role") in {"user", "assistant"}
1427
+ ],
1428
+ preserved_texts=(
1429
+ summary_target_value.title,
1430
+ summary_target_value.body,
1431
+ summary_target_value.due_date,
1432
+ ) if summary_target_value else (),
1433
+ ):
1434
+ raise ModelOutputError(
1435
+ "summary contains a date absent from its admitted evidence",
1436
+ validation_detail="relative_time",
1437
+ )
1438
+ return parsed
1439
+
1440
+ target_revisions = {}
1441
+ for related_item in candidate_related:
1442
+ if isinstance(related_item.get("memory_id"), str):
1443
+ target_memory = self.inputs._active_memory_by_id(related_item["memory_id"])
1444
+ if target_memory is not None:
1445
+ target_revisions[target_memory.memory_id] = revision_digest(target_memory)
1446
+ summary_candidate = dict(candidate)
1447
+ model_candidate_id = summary_candidate.pop("_model_candidate_id", None)
1448
+ if isinstance(model_candidate_id, str) and model_candidate_id:
1449
+ summary_candidate["candidate_id"] = model_candidate_id
1450
+ summary_prompt_value = summarize_prompt(
1451
+ summary_candidate,
1452
+ admitted_summary_events,
1453
+ related_memories=candidate_related,
1454
+ scope_background=candidate_scope_background,
1455
+ scope_registry=scope_registry,
1456
+ )
1457
+ diagnostic_context = {
1458
+ "source": turn.source,
1459
+ "session_id": turn.session_id,
1460
+ "turn_index": turn.turn_index,
1461
+ }
1462
+
1463
+ def run_summary(
1464
+ *,
1465
+ prompt_value: str = summary_prompt_value,
1466
+ parser_value: Any = parse_summary,
1467
+ diagnostic_value: Mapping[str, Any] = diagnostic_context,
1468
+ ) -> dict[str, Any]:
1469
+ try:
1470
+ return {
1471
+ "status": "ok",
1472
+ "summary": self.model._complete_json_stage(
1473
+ backend,
1474
+ prompt_value,
1475
+ system=SUMMARIZE_SYSTEM,
1476
+ purpose="summarize",
1477
+ parser=parser_value,
1478
+ diagnostic_context=diagnostic_value,
1479
+ ),
1480
+ }
1481
+ except ModelOutputError as error:
1482
+ if getattr(error, "validation_detail", None) not in {
1483
+ "relative_time",
1484
+ "due_date_not_grounded",
1485
+ }:
1486
+ raise
1487
+ return {"status": "relative_time"}
1488
+
1489
+ target_key = (
1490
+ f"update:{gate_update_target.casefold()}"
1491
+ if isinstance(gate_update_target, str) and gate_update_target
1492
+ else f"create:{str(candidate['candidate_id']).casefold()}"
1493
+ )
1494
+ job_index = len(summary_jobs)
1495
+ summary_jobs.append({
1496
+ "key": target_key,
1497
+ "call": run_summary,
1498
+ "candidate": dict(candidate),
1499
+ "candidate_related": candidate_related,
1500
+ "candidate_native_refs": candidate_native_refs,
1501
+ "correction_plan": correction_plan,
1502
+ "gate_update_target": gate_update_target,
1503
+ "target_revisions": target_revisions,
1504
+ })
1505
+ request_slots.append({"kind": "summary", "job_index": job_index})
1506
+
1507
+ summary_outcomes = run_ordered_keyed_jobs(
1508
+ self.model,
1509
+ backend,
1510
+ [(job["key"], job["call"]) for job in summary_jobs],
1511
+ )
1512
+
1513
+ for slot in request_slots:
1514
+ kind = slot.get("kind")
1515
+ if kind == "observe":
1516
+ observe_scopes(slot.get("scopes", []))
1517
+ continue
1518
+ if kind == "request":
1519
+ request = slot.get("request")
1520
+ if isinstance(request, dict):
1521
+ requests.append(request)
1522
+ observe_scopes(slot.get("scopes", []))
1523
+ continue
1524
+ if kind != "summary":
1525
+ continue
1526
+ job_index = slot.get("job_index")
1527
+ if not isinstance(job_index, int) or isinstance(job_index, bool):
1528
+ raise ProcessingError("invalid prepared summary job")
1529
+ job = summary_jobs[job_index]
1530
+ outcome = summary_outcomes[job_index]
1531
+ candidate = job["candidate"]
1532
+ candidate_related = job["candidate_related"]
1533
+ candidate_native_refs = job["candidate_native_refs"]
1534
+ correction_plan = job["correction_plan"]
1535
+ gate_update_target = job["gate_update_target"]
1536
+ target_revisions = job["target_revisions"]
1537
+ if outcome.get("status") == "relative_time":
1445
1538
  # The candidate's source turn remains in inbox for an
1446
- # explicit retry. Other candidates from this same turn may
1447
- # still commit safely in the same transaction. The existing
1448
- # relative_time audit reason covers both unresolved relative
1449
- # text and an ungrounded explicit due_date.
1539
+ # explicit retry. Other candidates from this same turn may
1540
+ # still commit safely in the same transaction.
1450
1541
  self.audit._defer_candidate(
1451
1542
  turn_ref,
1452
1543
  candidate,
@@ -1454,6 +1545,10 @@ class MemoryPlanner:
1454
1545
  scopes=candidate["scopes"],
1455
1546
  )
1456
1547
  continue
1548
+ summary = outcome.get("summary")
1549
+ if not isinstance(summary, Mapping):
1550
+ raise ProcessingError("invalid prepared summary result")
1551
+ summary = dict(summary)
1457
1552
  if summary.get("decision") == NO_CHANGE_DECISION:
1458
1553
  self.audit._record_disposition(
1459
1554
  turn_ref,
@@ -1470,7 +1565,6 @@ class MemoryPlanner:
1470
1565
  if gate_update_target is not None:
1471
1566
  summary_update_target = summary.get("update_memory_id")
1472
1567
  if summary_update_target is None:
1473
- summary = dict(summary)
1474
1568
  summary["update_memory_id"] = gate_update_target
1475
1569
  elif (
1476
1570
  not isinstance(summary_update_target, str)
@@ -1481,12 +1575,10 @@ class MemoryPlanner:
1481
1575
  validation_detail="invalid_update_target",
1482
1576
  )
1483
1577
  else:
1484
- summary = dict(summary)
1485
1578
  summary["update_memory_id"] = gate_update_target
1486
- target = self.service.read(gate_update_target, include_history=False)
1579
+ self.service.read(gate_update_target, include_history=False)
1487
1580
  # The summary is the complete model-proposed current value.
1488
1581
  # Do not concatenate old/new bodies using business keywords.
1489
- pass
1490
1582
  if summary["scopes"] == ["unscoped"] or summary.get("scope_source") == "insufficient_context":
1491
1583
  self.audit._defer_candidate(
1492
1584
  turn_ref,
@@ -1547,13 +1639,8 @@ class MemoryPlanner:
1547
1639
  else pending_request["memory_id"]
1548
1640
  ),
1549
1641
  )
1550
- for observed_scope in summary["scopes"]:
1551
- if (
1552
- isinstance(observed_scope, str)
1553
- and observed_scope != "unscoped"
1554
- and observed_scope not in observed_scopes
1555
- ):
1556
- observed_scopes.append(observed_scope)
1642
+ observe_scopes(summary["scopes"])
1643
+
1557
1644
  requests = CreateCoordinator(self.model, self.audit).resolve(
1558
1645
  requests, candidates=admitted_candidates, evidence_units=planning_evidence_units, events=events,
1559
1646
  backend=backend, scope_registry=scope_registry, validation_scope_registry=validation_scope_registry)