memleaf 0.2.31__tar.gz → 0.2.32__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 (160) hide show
  1. {memleaf-0.2.31 → memleaf-0.2.32}/CHANGELOG.md +6 -0
  2. {memleaf-0.2.31/src/memleaf.egg-info → memleaf-0.2.32}/PKG-INFO +4 -4
  3. {memleaf-0.2.31 → memleaf-0.2.32}/README.en.md +3 -3
  4. {memleaf-0.2.31 → memleaf-0.2.32}/README.md +3 -3
  5. {memleaf-0.2.31 → memleaf-0.2.32}/docs/gate-evidence-boundary.md +18 -0
  6. {memleaf-0.2.31 → memleaf-0.2.32}/docs/general-processing.md +1 -1
  7. {memleaf-0.2.31 → memleaf-0.2.32}/pyproject.toml +1 -1
  8. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/__init__.py +1 -1
  9. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/admission.py +29 -13
  10. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  11. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/model_execution.py +37 -2
  12. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/process_common.py +7 -1
  13. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/process_journal.py +2 -1
  14. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/prompts.py +1 -1
  15. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/validation.py +122 -0
  16. {memleaf-0.2.31 → memleaf-0.2.32/src/memleaf.egg-info}/PKG-INFO +4 -4
  17. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_general_evidence_admission.py +128 -1
  18. {memleaf-0.2.31 → memleaf-0.2.32}/IMPLEMENTATION_PLAN.md +0 -0
  19. {memleaf-0.2.31 → memleaf-0.2.32}/LICENSE +0 -0
  20. {memleaf-0.2.31 → memleaf-0.2.32}/MANIFEST.in +0 -0
  21. {memleaf-0.2.31 → memleaf-0.2.32}/RELEASE_CHECKLIST.md +0 -0
  22. {memleaf-0.2.31 → memleaf-0.2.32}/docs/capture-budget-design.md +0 -0
  23. {memleaf-0.2.31 → memleaf-0.2.32}/docs/config-migrations.md +0 -0
  24. {memleaf-0.2.31 → memleaf-0.2.32}/docs/core-refactor.md +0 -0
  25. {memleaf-0.2.31 → memleaf-0.2.32}/docs/evidence-retention.md +0 -0
  26. {memleaf-0.2.31 → memleaf-0.2.32}/docs/hermes-mcp-runtime.md +0 -0
  27. {memleaf-0.2.31 → memleaf-0.2.32}/docs/performance.md +0 -0
  28. {memleaf-0.2.31 → memleaf-0.2.32}/docs/v0.2.26-processing-status.md +0 -0
  29. {memleaf-0.2.31 → memleaf-0.2.32}/examples/README.md +0 -0
  30. {memleaf-0.2.31 → memleaf-0.2.32}/examples/basic_usage.py +0 -0
  31. {memleaf-0.2.31 → memleaf-0.2.32}/examples/live_processing_acceptance.py +0 -0
  32. {memleaf-0.2.31 → memleaf-0.2.32}/examples/mcp_stdio.ndjson +0 -0
  33. {memleaf-0.2.31 → memleaf-0.2.32}/install.ps1 +0 -0
  34. {memleaf-0.2.31 → memleaf-0.2.32}/install.sh +0 -0
  35. {memleaf-0.2.31 → memleaf-0.2.32}/setup.cfg +0 -0
  36. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/__main__.py +0 -0
  37. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/adapters/__init__.py +0 -0
  38. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/adapters/antigravity.py +0 -0
  39. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/adapters/base.py +0 -0
  40. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/adapters/codex.py +0 -0
  41. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/adapters/hermes.py +0 -0
  42. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/budget.py +0 -0
  43. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/capture.py +0 -0
  44. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/cli.py +0 -0
  45. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/compaction.py +0 -0
  46. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/config.py +0 -0
  47. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/credentials.py +0 -0
  48. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/evidence_budget.py +0 -0
  49. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/evidence_policy.py +0 -0
  50. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/frontmatter.py +0 -0
  51. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/hermes_provider/README.md +0 -0
  52. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/hermes_provider/__init__.py +0 -0
  53. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
  54. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/hermes_runtime.py +0 -0
  55. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/host_events.py +0 -0
  56. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/host_runtime.py +0 -0
  57. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/inbox.py +0 -0
  58. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/index.py +0 -0
  59. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/inspection.py +0 -0
  60. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/installer.py +0 -0
  61. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/llm/__init__.py +0 -0
  62. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/llm/base.py +0 -0
  63. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/llm/claude_compatible.py +0 -0
  64. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/llm/gemini.py +0 -0
  65. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/llm/openai_compatible.py +0 -0
  66. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/llm/router.py +0 -0
  67. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/locking.py +0 -0
  68. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/mcp_server.py +0 -0
  69. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/memory_commit.py +0 -0
  70. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/memory_planner.py +0 -0
  71. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/memory_writer.py +0 -0
  72. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/model_discovery.py +0 -0
  73. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/models.py +0 -0
  74. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/native_index.py +0 -0
  75. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/native_registration.py +0 -0
  76. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/planning_context.py +0 -0
  77. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/process_owner.py +0 -0
  78. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/processing.py +0 -0
  79. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/provenance.py +0 -0
  80. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/recording_policy.py +0 -0
  81. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/redaction.py +0 -0
  82. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/retention.py +0 -0
  83. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/retrieval.py +0 -0
  84. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/retrieval_gate.py +0 -0
  85. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/scope_maintenance.py +0 -0
  86. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/scope_state.py +0 -0
  87. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/service.py +0 -0
  88. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/source_policy.py +0 -0
  89. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/state_layout.py +0 -0
  90. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/turn_audit.py +0 -0
  91. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/turn_plan.py +0 -0
  92. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/update_coordinator.py +0 -0
  93. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf/vault.py +0 -0
  94. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf.egg-info/SOURCES.txt +0 -0
  95. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf.egg-info/dependency_links.txt +0 -0
  96. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf.egg-info/entry_points.txt +0 -0
  97. {memleaf-0.2.31 → memleaf-0.2.32}/src/memleaf.egg-info/top_level.txt +0 -0
  98. {memleaf-0.2.31 → memleaf-0.2.32}/tests/__init__.py +0 -0
  99. {memleaf-0.2.31 → memleaf-0.2.32}/tests/semantic_fixtures.py +0 -0
  100. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_admission_noise.py +0 -0
  101. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_codex_install.py +0 -0
  102. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_codex_native_cli.py +0 -0
  103. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_config_migrations_v028.py +0 -0
  104. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_context_budget.py +0 -0
  105. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_credential_safety.py +0 -0
  106. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_cross_host_acceptance.py +0 -0
  107. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_cross_turn_dedupe_regressions.py +0 -0
  108. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_email_actionable_coverage.py +0 -0
  109. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_evidence_budget.py +0 -0
  110. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_evidence_retention_policy.py +0 -0
  111. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_extraction_quality_regressions.py +0 -0
  112. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_general_tool_provenance.py +0 -0
  113. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_global_todo_acceptance.py +0 -0
  114. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_global_todo_query_no_write.py +0 -0
  115. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_global_todo_retrieval.py +0 -0
  116. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_hermes_native_registration.py +0 -0
  117. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_hermes_provider.py +0 -0
  118. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_hermes_runtime_install.py +0 -0
  119. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_hermes_stdio_transport.py +0 -0
  120. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_host_events.py +0 -0
  121. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_host_runtime_contract.py +0 -0
  122. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_inspection_state_v028.py +0 -0
  123. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_install.py +0 -0
  124. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_long_run_hygiene.py +0 -0
  125. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_maintenance_v2.py +0 -0
  126. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_model_discovery.py +0 -0
  127. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_model_owned_fields.py +0 -0
  128. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_phase2_model_decisions.py +0 -0
  129. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_process_owner_locking.py +0 -0
  130. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_processing_contract_v026.py +0 -0
  131. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_pypi_install.py +0 -0
  132. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_retrieval_gate.py +0 -0
  133. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_retrieval_v2.py +0 -0
  134. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_session_lineage.py +0 -0
  135. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_shared_memory_refactor.py +0 -0
  136. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_source_neutral_todos_v028.py +0 -0
  137. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_a.py +0 -0
  138. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_b1.py +0 -0
  139. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_b2a.py +0 -0
  140. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_b2b.py +0 -0
  141. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_b3a_commit.py +0 -0
  142. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_b3a_contract.py +0 -0
  143. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_b3b_native_context.py +0 -0
  144. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_b3b_native_index.py +0 -0
  145. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_b3b_scope.py +0 -0
  146. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_b3c_retrieval.py +0 -0
  147. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  148. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_c1_mcp.py +0 -0
  149. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_c2_init.py +0 -0
  150. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_stage_c3_packaging.py +0 -0
  151. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_state_layout_v028.py +0 -0
  152. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_todo_state_recovery.py +0 -0
  153. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_update_target_recovery.py +0 -0
  154. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_upgrade_preserves_vault.py +0 -0
  155. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_v023_scope_correction.py +0 -0
  156. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_v2_gate_limits.py +0 -0
  157. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_v2_host_flow.py +0 -0
  158. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_v2_mcp_flow.py +0 -0
  159. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_v2_nomatch_semantics.py +0 -0
  160. {memleaf-0.2.31 → memleaf-0.2.32}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.32 — 2026-09-07
6
+
7
+ - Add bounded `unknown_unit` Gate diagnostics that identify the exact response field and retain only allowlisted type/length/digest and expected-set summaries; raw invalid values, legal ID lists and model output remain excluded from normal logs and failed state.
8
+ - Give bounded correction attempts the failed constraint, field path and immutable legal-ID inventory used by validation, so coverage and evidence-binding references are repaired by regeneration rather than host-side ID substitution or relaxed evidence authority.
9
+ - Keep `invalid_evidence` compatibility, source-neutral physical evidence boundaries, and watermark/cleanup safety unchanged: a persistently failed Gate does not advance the watermark or trigger cleanup.
10
+
5
11
  ## 0.2.31 — 2026-09-07
6
12
 
7
13
  - Unify Core and the copied Hermes provider on one idempotent UTF-8 evidence budget: at most 64 source records, 32 KiB per body, and 128 KiB of aggregate body text. Complete matched sources above the former 2,000-character/eight-record limits remain available within those bounds.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.31
3
+ Version: 0.2.32
4
4
  Summary: A local-first Markdown memory core for AI agents
5
5
  Author: memleaf contributors
6
6
  License-Expression: MIT
@@ -23,8 +23,8 @@ Dynamic: license-file
23
23
 
24
24
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
25
25
 
26
- > **版本:0.2.31。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。本版统一 Core 与 Hermes Provider 的 UTF-8 证据留存预算:最多 64 条 source records、每条正文 32 KiB、正文总量 128 KiB,并保留有界 loss markers 与幂等规范化;同时将 metadata 模式下成功捕获的超限 pending 证据按相同有效策略消费。Gate 继续使用 physical evidence projection、`candidates`、`coverage`、`evidence_bindings` 和 allowlisted `evidence_check` 诊断,保持 source-neutral 语义、Markdown 唯一事实源和无 SQLite 运行时依赖。真实模型语义效果仍需结合本地模型和代表性样本验收。
26
+ > **版本:0.2.32。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。本版在既有 UTF-8 证据留存预算和 metadata pending 消费策略上,为 Gate 的 `unknown_unit` 提供字段路径、类型/长度/摘要和期望集合摘要等受限诊断,并在同一合法 ID 清单约束下最多三次尝试、最多两次纠正;不记录原始值、不做 ID 猜测,持续失败不推进水位且不触发清理。保持 source-neutral 语义、Markdown 唯一事实源和无 SQLite 运行时依赖。真实模型语义效果仍需结合本地模型和代表性样本验收。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -490,7 +490,7 @@ MIT,见 [LICENSE](LICENSE)。
490
490
  *Your memories, in files you own.*
491
491
 
492
492
 
493
- ## 通用处理与只读验收(0.2.31)
493
+ ## 通用处理与只读验收(0.2.32)
494
494
 
495
495
  邮件、日历、工单、文件、浏览器与普通对话共用证据准入、覆盖检查和写入路径。
496
496
  自动摘要只能使用获准引用的原文;助手复述和旧记忆回读不能单独授权新增写入。
@@ -4,8 +4,8 @@
4
4
 
5
5
  [中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **Version: 0.2.31.**
8
- > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. This release unifies Core and the Hermes provider on an idempotent UTF-8 evidence budget: at most 64 source records, 32 KiB per body, and 128 KiB of aggregate body text, with bounded loss markers; metadata-mode pending evidence from an oversized observation is consumed after successful capture under the same effective policy. The Gate still uses the physical evidence projection, the `candidates`, `coverage`, and `evidence_bindings` contract, and allowlisted `evidence_check` diagnostics while preserving source-neutral semantics, Markdown as the sole source of truth, and zero SQLite runtime dependencies. Real-model semantics still require local acceptance with the selected model and representative inputs.
7
+ > **Version: 0.2.32.**
8
+ > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. Building on the existing UTF-8 evidence budget and metadata-mode pending-evidence policy, this release adds bounded `unknown_unit` Gate diagnostics for field path, type/length/digest and expected-set summaries, with at most three attempts and two corrections constrained by the same legal-ID inventory; it never logs the raw value or guesses an ID, and a persistently failed Gate does not advance the watermark or trigger cleanup. This preserves source-neutral semantics, Markdown as the sole source of truth, and zero SQLite runtime dependencies. Real-model semantics still require local acceptance with the selected model and representative inputs.
9
9
  > **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
10
10
 
11
11
  ## Project scope
@@ -474,7 +474,7 @@ MIT; see [LICENSE](LICENSE).
474
474
  *Your memories, in files you own.*
475
475
 
476
476
 
477
- ## General processing and read-only inspection (0.2.31)
477
+ ## General processing and read-only inspection (0.2.32)
478
478
 
479
479
  Dialogue, calendars, tickets, files, web results and other tools share the evidence, coverage and write path.
480
480
  Models interpret semantics; Core validates physical provenance and exact original quotations.
@@ -4,8 +4,8 @@
4
4
 
5
5
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **版本:0.2.31。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。本版统一 Core 与 Hermes Provider 的 UTF-8 证据留存预算:最多 64 条 source records、每条正文 32 KiB、正文总量 128 KiB,并保留有界 loss markers 与幂等规范化;同时将 metadata 模式下成功捕获的超限 pending 证据按相同有效策略消费。Gate 继续使用 physical evidence projection、`candidates`、`coverage`、`evidence_bindings` 和 allowlisted `evidence_check` 诊断,保持 source-neutral 语义、Markdown 唯一事实源和无 SQLite 运行时依赖。真实模型语义效果仍需结合本地模型和代表性样本验收。
7
+ > **版本:0.2.32。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。本版在既有 UTF-8 证据留存预算和 metadata pending 消费策略上,为 Gate 的 `unknown_unit` 提供字段路径、类型/长度/摘要和期望集合摘要等受限诊断,并在同一合法 ID 清单约束下最多三次尝试、最多两次纠正;不记录原始值、不做 ID 猜测,持续失败不推进水位且不触发清理。保持 source-neutral 语义、Markdown 唯一事实源和无 SQLite 运行时依赖。真实模型语义效果仍需结合本地模型和代表性样本验收。
9
9
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
10
10
 
11
11
  ## 项目定位
@@ -471,7 +471,7 @@ MIT,见 [LICENSE](LICENSE)。
471
471
  *Your memories, in files you own.*
472
472
 
473
473
 
474
- ## 通用处理与只读验收(0.2.31)
474
+ ## 通用处理与只读验收(0.2.32)
475
475
 
476
476
  邮件、日历、工单、文件、浏览器与普通对话共用证据准入、覆盖检查和写入路径。
477
477
  自动摘要只能使用获准引用的原文;助手复述和旧记忆回读不能单独授权新增写入。
@@ -62,6 +62,18 @@ Record a separate, allowlisted evidence-check identifier for the failing
62
62
  constraint. Diagnostics must not contain raw model responses, message text,
63
63
  credentials or arbitrary exception strings.
64
64
 
65
+ An unknown unit reference must identify the exact response field, such as
66
+ `coverage[2].unit_id` or `evidence_bindings[0].claims[1].unit_id`. Persist only
67
+ the field path, value type/length/digest and expected-set count/digest. The
68
+ invalid value and legal ID list do not belong in normal logs or failed state.
69
+
70
+ A bounded retry receives the failed constraint, exact field path and the legal
71
+ IDs from the same immutable model-visible inventory used by the validator.
72
+ These IDs are a reference constraint, not a suggested semantic decision. The
73
+ model must regenerate a consistent response; the host must not substitute a
74
+ nearby ID, guess a source, or relax evidence authority. Prompt examples must not
75
+ provide a literal placeholder that looks like a usable evidence reference.
76
+
65
77
  A failed Gate cannot advance the turn watermark or create a cleanup deadline.
66
78
  A successful no-change decision can advance the watermark and start the normal
67
79
  retention period. Incomplete physical observations remain deferred even if other
@@ -97,3 +109,9 @@ That adapter/capture limitation must be reported independently of Gate success.
97
109
  - Deterministic tests establish the protocol. Live synthetic tests establish
98
110
  behavior only for those samples. A synthetic success does not identify the
99
111
  cause of an earlier real-model failure; do not claim otherwise.
112
+
113
+ A successful replay is evidence about that new run. If an earlier raw response
114
+ was not retained, its precise invalid value cannot be recovered from an
115
+ `unknown_unit` category alone. Verify recovery with controlled invalid coverage
116
+ and binding references as well as ordinary live samples, and distinguish those
117
+ results from reproduction of the original incident.
@@ -1,4 +1,4 @@
1
- # General processing reliability contract — 0.2.30
1
+ # General processing reliability contract — 0.2.32
2
2
 
3
3
  This is source-neutral processing, not a mail extractor. Dialogue, documents,
4
4
  calendars, issue trackers and terminal/tool observations use the same admission
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memleaf"
7
- version = "0.2.31"
7
+ version = "0.2.32"
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.31"
3
+ __version__ = "0.2.32"
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
@@ -242,7 +242,7 @@ def validate_bindings(value: Any, units: Iterable[EvidenceUnit],
242
242
  evidence_check="binding_shape")
243
243
  result: dict[str, list[dict[str, Any]]] = {}
244
244
  allowed_roles = {"assertion", "source_excerpt", "user_confirmation"}
245
- for row in value:
245
+ for binding_index, row in enumerate(value):
246
246
  if not isinstance(row, dict) or set(row) != {"candidate_id", "claims"}:
247
247
  raise ModelOutputError("invalid evidence binding", validation_detail="invalid_evidence",
248
248
  evidence_check="binding_shape")
@@ -255,7 +255,7 @@ def validate_bindings(value: Any, units: Iterable[EvidenceUnit],
255
255
  raise ModelOutputError("empty evidence claims", validation_detail="invalid_evidence",
256
256
  evidence_check="binding_shape")
257
257
  checked = []
258
- for claim in claims:
258
+ for claim_index, claim in enumerate(claims):
259
259
  if not isinstance(claim, dict) or set(claim) not in (
260
260
  {"unit_id", "start", "end", "quote", "role"}, {"unit_id", "quote", "role"}):
261
261
  raise ModelOutputError("invalid evidence claim", validation_detail="invalid_evidence",
@@ -263,8 +263,13 @@ def validate_bindings(value: Any, units: Iterable[EvidenceUnit],
263
263
  claim = dict(claim)
264
264
  uid = claim["unit_id"]
265
265
  if not isinstance(uid, str) or uid not in by_unit:
266
- raise ModelOutputError("unknown evidence unit", validation_detail="invalid_evidence",
267
- evidence_check="unknown_unit")
266
+ error = ModelOutputError("unknown evidence unit", validation_detail="invalid_evidence",
267
+ evidence_check="unknown_unit")
268
+ raise error.with_evidence_context(
269
+ path=f"evidence_bindings[{binding_index}].claims[{claim_index}].unit_id",
270
+ actual=uid,
271
+ expected_ids=tuple(by_unit),
272
+ )
268
273
  unit = by_unit[uid]
269
274
  quote = claim["quote"]
270
275
  if "start" not in claim:
@@ -356,20 +361,27 @@ _DEFERRED_COVERAGE_REASONS = frozenset({
356
361
 
357
362
  def parse_coverage(value: Any, units: Iterable[EvidenceUnit], candidates: Iterable[Mapping[str, Any]], *, require_complete: bool = True) -> dict[str, dict[str, Any]]:
358
363
  """Validate accounting without trusting the model's evidence identities."""
364
+ units = tuple(units)
365
+ expected_units = tuple(dict.fromkeys(u.unit_id for u in units))
359
366
  units = {u.unit_id: u for u in units}
360
367
  candidates = {c["candidate_id"]: c for c in candidates}
361
368
  if not isinstance(value, list):
362
369
  raise ModelOutputError("coverage must be a list", validation_detail="invalid_evidence",
363
370
  evidence_check="coverage_shape")
364
371
  result = {}
365
- for row in value:
372
+ for row_index, row in enumerate(value):
366
373
  if not isinstance(row, dict) or set(row) - {"unit_id", "decision", "candidate_ids", "reason"}:
367
374
  raise ModelOutputError("invalid coverage row", validation_detail="invalid_evidence",
368
375
  evidence_check="coverage_shape")
369
376
  uid = row.get("unit_id")
370
377
  if not isinstance(uid, str) or uid not in units:
371
- raise ModelOutputError("invalid coverage unit", validation_detail="invalid_evidence",
372
- evidence_check="unknown_unit")
378
+ error = ModelOutputError("invalid coverage unit", validation_detail="invalid_evidence",
379
+ evidence_check="unknown_unit")
380
+ raise error.with_evidence_context(
381
+ path=f"coverage[{row_index}].unit_id",
382
+ actual=uid,
383
+ expected_ids=expected_units,
384
+ )
373
385
  if uid in result:
374
386
  raise ModelOutputError("duplicate coverage unit", validation_detail="invalid_evidence",
375
387
  evidence_check="duplicate_coverage")
@@ -466,9 +478,10 @@ def evidence_prompt(units: Iterable[EvidenceUnit]) -> str:
466
478
  "candidates, coverage, and evidence_bindings. "
467
479
  "Coverage must contain exactly one row for EVERY supplied evidence unit. "
468
480
  "A response with coverage omitted or with coverage=[] is complete only when no units are supplied. "
469
- "Each row is "
470
- '{"unit_id":"supplied id","decision":"CANDIDATE","candidate_ids":["id"]} or '
471
- '{"unit_id":"supplied id","decision":"NO_CHANGE or DEFERRED","reason":"reason"}. '
481
+ "For each row, copy unit_id character-for-character from the supplied evidence list. "
482
+ "Use decision=CANDIDATE with candidate_ids, or decision=NO_CHANGE/DEFERRED with reason. "
483
+ "The words in this schema description are labels only; never return a placeholder, event key, "
484
+ "call ID, or digest as unit_id. "
472
485
  'Allowed reasons: ' + ', '.join(sorted(COVERAGE_REASONS)) + '. '
473
486
  'Use NO_CHANGE only with reasons: ' + ', '.join(sorted(_NO_CHANGE_COVERAGE_REASONS)) + '. '
474
487
  'Use DEFERRED only with reasons: ' + ', '.join(sorted(_DEFERRED_COVERAGE_REASONS)) + '. '
@@ -489,9 +502,12 @@ def evidence_prompt(units: Iterable[EvidenceUnit]) -> str:
489
502
 
490
503
 
491
504
  SEMANTIC_BINDING_INSTRUCTIONS = """
492
- For every worth=true candidate, also return top-level evidence_bindings:
493
- [{"candidate_id":"existing candidate id","claims":[{"unit_id":"supplied id",
494
- "start":0,"end":10,"quote":"exact substring","role":"assertion"}]}].
505
+ For every worth=true candidate, also return top-level evidence_bindings. Each
506
+ binding must name a candidate_id copied from the candidates list and claims
507
+ whose unit_id is copied character-for-character from the supplied evidence
508
+ list. Do not return schema labels, placeholders, event keys, call IDs or
509
+ digests as unit_id values. Each claim contains an exact quote and role, and
510
+ may include start/end offsets.
495
511
  Offsets are relative to the supplied unit text. Roles: assertion (a current
496
512
  statement of fact or change), source_excerpt (actual quoted material, not a
497
513
  demonstration), user_confirmation (explicit adoption of a uniquely identified
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.31
2
+ version: 0.2.32
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -7,7 +7,7 @@ from .llm import MODEL_VALIDATION_REASONS, CallableBackend, ModelError, ModelUna
7
7
  from .models import utc_now
8
8
  from .prompts import COVERAGE_CORRECTION, DUPLICATE_TARGET_CORRECTION, GATE_TYPE_CORRECTION, JSON_CORRECTION, MIXED_FUTURE_USE_CORRECTION, MIXED_PROJECT_SCOPES_CORRECTION, RELATIVE_TIME_CORRECTION, SCOPE_GROUNDING_CORRECTION, SUMMARY_SCOPE_CORRECTION, SUMMARY_TARGET_CORRECTION, SUMMARY_TYPE_CORRECTION, TARGET_RELEVANCE_CORRECTION, UPDATE_TARGET_TYPE_CORRECTION
9
9
  from .validation import MODEL_VALIDATION_DETAILS, ModelOutputError
10
- from .process_common import _DIAGNOSTIC_FILENAME, _DIAGNOSTIC_MAX_BYTES, _failure_metadata, _model_output_statistics, _safe_evidence_check
10
+ from .process_common import _DIAGNOSTIC_FILENAME, _DIAGNOSTIC_MAX_BYTES, _failure_metadata, _model_output_statistics, _safe_evidence_check, _safe_evidence_diagnostics
11
11
 
12
12
 
13
13
  class ModelExecutor:
@@ -114,7 +114,8 @@ class ModelExecutor:
114
114
  if stage == "gate" and hint == "target_not_relevant":
115
115
  return TARGET_RELEVANCE_CORRECTION
116
116
  if stage == "gate" and hint == "invalid_evidence":
117
- return COVERAGE_CORRECTION
117
+ context = ModelExecutor._evidence_correction_context(error)
118
+ return COVERAGE_CORRECTION if context is None else COVERAGE_CORRECTION + "\n" + context
118
119
  if stage == "summarize" and hint == "scope_drift":
119
120
  return SUMMARY_SCOPE_CORRECTION
120
121
  if hint == "relative_time":
@@ -128,6 +129,39 @@ class ModelExecutor:
128
129
  return None
129
130
 
130
131
 
132
+ @staticmethod
133
+ def _evidence_correction_context(error: BaseException) -> Optional[str]:
134
+ """Build a bounded Gate repair hint from validator-owned context only."""
135
+
136
+ if getattr(error, "evidence_check", None) != "unknown_unit":
137
+ return None
138
+ diagnostics = _safe_evidence_diagnostics(error)
139
+ path = diagnostics.get("evidence_path")
140
+ expected = getattr(error, "evidence_expected_ids", ())
141
+ if not isinstance(path, str) or not isinstance(expected, tuple) or not all(
142
+ isinstance(item, str) and item for item in expected
143
+ ):
144
+ return None
145
+ expected_json = json.dumps(list(expected), ensure_ascii=False, separators=(",", ":"))
146
+ actual_parts = []
147
+ for key, label in (("evidence_actual_type", "type"), ("evidence_actual_length", "length"),
148
+ ("evidence_actual_sha256", "sha256")):
149
+ if key in diagnostics:
150
+ actual_parts.append(f"{label}={diagnostics[key]}")
151
+ descriptor = ", ".join(actual_parts) or "no safe value descriptor"
152
+ legal = (
153
+ f"the complete legal unit_id set is exactly {expected_json}. "
154
+ if expected else "there are no legal unit_id values in this call. "
155
+ )
156
+ return (
157
+ "Evidence reference diagnostic (structural only): evidence_check=unknown_unit; "
158
+ "the invalid unit_id was at "
159
+ f"{path}. Safe descriptor: {descriptor}. For this call, {legal}"
160
+ "Copy one legal unit_id character-for-character from that set when one exists; "
161
+ "do not use the invalid value, a placeholder, event_key, call ID, digest, or a guessed mapping."
162
+ )
163
+
164
+
131
165
  def _diagnostic_enabled(self) -> bool:
132
166
  try:
133
167
  config = self.service.vault.config()
@@ -187,6 +221,7 @@ class ModelExecutor:
187
221
  }
188
222
  if evidence_check is not None:
189
223
  entry["evidence_check"] = evidence_check
224
+ entry.update(_safe_evidence_diagnostics(error) if error is not None else {})
190
225
  response_diagnostics = getattr(error, "response_diagnostics", None) if error is not None else None
191
226
  if isinstance(response_diagnostics, Mapping):
192
227
  allowed_diagnostics = {
@@ -16,7 +16,7 @@ from .llm import MODEL_ERROR_CODES, MODEL_VALIDATION_REASONS, ModelUnavailable
16
16
  from .locking import read_json
17
17
  from .models import Memory, utc_now
18
18
  from .retrieval import candidate_matches_query, normalize_term
19
- from .validation import MODEL_EVIDENCE_CHECKS, MODEL_VALIDATION_DETAILS, ModelOutputError, parse_strict_json, normalize_relative_calendar_text
19
+ from .validation import MODEL_EVIDENCE_CHECKS, MODEL_VALIDATION_DETAILS, ModelOutputError, parse_strict_json, normalize_relative_calendar_text, safe_evidence_context
20
20
 
21
21
  _PROCESSING_LEASE_SECONDS = 3600
22
22
 
@@ -298,6 +298,12 @@ def _safe_evidence_check(error: BaseException) -> Optional[str]:
298
298
  return value if isinstance(value, str) and value in MODEL_EVIDENCE_CHECKS else None
299
299
 
300
300
 
301
+ def _safe_evidence_diagnostics(error: BaseException) -> dict[str, Any]:
302
+ """Return allowlisted structural evidence details for local diagnostics."""
303
+
304
+ return safe_evidence_context(error)
305
+
306
+
301
307
  def _json_top_level_type(value: Any) -> str:
302
308
  if isinstance(value, Mapping):
303
309
  return "object"
@@ -15,7 +15,7 @@ from .locking import atomic_write_json, atomic_write_text
15
15
  from .turn_plan import turn_identity_key
16
16
  from .redaction import redact_text
17
17
  from .vault import safe_component
18
- from .process_common import ProcessingError, _FAILED_STATUS, _LEGACY_PROCESSING_GRACE_SECONDS, _MAX_SESSION_LINEAGE_DEPTH, _PROCESSING_LEASE_SECONDS, _PROCESSING_STATUS, _Snapshot, _as_int, _failure_metadata, _now_value, _parse_time, _read_processed, _safe_evidence_check, _safe_scope_background, _session_key
18
+ from .process_common import ProcessingError, _FAILED_STATUS, _LEGACY_PROCESSING_GRACE_SECONDS, _MAX_SESSION_LINEAGE_DEPTH, _PROCESSING_LEASE_SECONDS, _PROCESSING_STATUS, _Snapshot, _as_int, _failure_metadata, _now_value, _parse_time, _read_processed, _safe_evidence_check, _safe_evidence_diagnostics, _safe_scope_background, _session_key
19
19
 
20
20
 
21
21
  class ProcessJournal:
@@ -371,6 +371,7 @@ class ProcessJournal:
371
371
  failed_marker["validation_detail"] = validation_detail
372
372
  if evidence_check is not None:
373
373
  failed_marker["evidence_check"] = evidence_check
374
+ failed_marker.update(_safe_evidence_diagnostics(error))
374
375
  if attempt_count is not None:
375
376
  failed_marker["attempt_count"] = attempt_count
376
377
  state["processing"] = failed_marker
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
 
6
6
  GATE_SYSTEM = """You are memleaf's strict, source-neutral memory gate. Return exactly one strict JSON object with the top-level fields candidates, coverage, and evidence_bindings.
7
- When physical evidence units are supplied, coverage must contain exactly one row for every supplied unit. An empty coverage list is complete only when no physical evidence units are supplied. Never omit a supplied physical evidence unit from coverage. A binding is {"candidate_id":"id","claims":[{"unit_id":"supplied id","start":0,"end":5,"quote":"exact substring","role":"assertion"}]}. start/end are Python Unicode offsets relative to unit.text; both may be omitted only when the exact quote occurs once. role is assertion, source_excerpt, or user_confirmation. When no physical evidence is supplied, return {"candidates":[],"coverage":[],"evidence_bindings":[]}.
7
+ When physical evidence units are supplied, coverage must contain exactly one row for every supplied unit. An empty coverage list is complete only when no physical evidence units are supplied. Never omit a supplied physical evidence unit from coverage. For bindings, copy candidate_id from the returned candidates and copy unit_id character-for-character from the supplied Evidence units list; schema labels and placeholders are not valid values. start/end are Python Unicode offsets relative to unit.text; both may be omitted only when the exact quote occurs once. role is assertion, source_excerpt, or user_confirmation. When no physical evidence is supplied, return {"candidates":[],"coverage":[],"evidence_bindings":[]}.
8
8
 
9
9
  Physical source_role is supplied by the host and is immutable. Current user assertions and matched current-turn external observations may support new memory. Assistant synthesis, retrieved memleaf/native memory, questions, hypothetical/example text, and unsupported inference do not independently authorize a write. Exact quotation proves provenance only; semantic entailment, ownership, polarity, uncertainty, conditions and future value are your responsibility.
10
10
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import json
6
7
  import re
7
8
  from datetime import date, datetime, timedelta, timezone
@@ -43,12 +44,50 @@ class ModelOutputError(ValueError):
43
44
  if isinstance(evidence_check, str) and evidence_check in MODEL_EVIDENCE_CHECKS
44
45
  else None
45
46
  )
47
+ # Evidence diagnostics are attached by the local Gate validator. They
48
+ # are intentionally kept separate from ``str(error)`` so callers can
49
+ # safely expose only the allowlisted structural fields below.
50
+ self.evidence_path: str | None = None
51
+ self.evidence_actual_type: str | None = None
52
+ self.evidence_actual_length: int | None = None
53
+ self.evidence_actual_sha256: str | None = None
54
+ self.evidence_expected_ids: tuple[str, ...] = ()
55
+ self.evidence_expected_count: int | None = None
56
+ self.evidence_expected_sha256: str | None = None
46
57
 
47
58
  def with_detail(self, detail: str | None) -> "ModelOutputError":
48
59
  if isinstance(detail, str) and detail in MODEL_VALIDATION_DETAILS:
49
60
  self.validation_detail = detail
50
61
  return self
51
62
 
63
+ def with_evidence_context(
64
+ self,
65
+ *,
66
+ path: str,
67
+ actual: Any,
68
+ expected_ids: Iterable[Any],
69
+ ) -> "ModelOutputError":
70
+ """Attach bounded evidence-reference diagnostics without raw values.
71
+
72
+ Expected IDs are retained on the exception for the transient retry
73
+ prompt. Persistent diagnostics must use ``safe_evidence_context``
74
+ below, which deliberately omits both the IDs and the invalid value.
75
+ """
76
+
77
+ if not isinstance(path, str) or not _EVIDENCE_PATH_RE.fullmatch(path):
78
+ return self
79
+ expected = tuple(dict.fromkeys(
80
+ item for item in expected_ids if isinstance(item, str) and item
81
+ ))
82
+ self.evidence_path = path
83
+ self.evidence_actual_type, self.evidence_actual_length, self.evidence_actual_sha256 = (
84
+ _evidence_value_descriptor(actual)
85
+ )
86
+ self.evidence_expected_ids = expected
87
+ self.evidence_expected_count = len(expected)
88
+ self.evidence_expected_sha256 = _evidence_ids_digest(expected)
89
+ return self
90
+
52
91
 
53
92
  MEMORY_TYPES = frozenset(("preference", "fact", "project", "todo", "event", "identity", "other"))
54
93
  SCOPE_SOURCES = frozenset(("model", "user", "session_context", "insufficient_context"))
@@ -101,6 +140,88 @@ MODEL_EVIDENCE_CHECKS = frozenset((
101
140
  "coverage_binding_conflict",
102
141
  "candidate_evidence",
103
142
  ))
143
+
144
+ _EVIDENCE_PATH_RE = re.compile(
145
+ r"(?:coverage\[\d+\]\.unit_id|evidence_bindings\[\d+\]\.claims\[\d+\]\.unit_id)"
146
+ )
147
+ _EVIDENCE_VALUE_TYPES = frozenset(("null", "boolean", "number", "string", "array", "object", "other"))
148
+ _EVIDENCE_DIAGNOSTIC_MAX_LENGTH = 1_000_000
149
+
150
+
151
+ def _evidence_value_descriptor(value: Any) -> tuple[str, int | None, str]:
152
+ """Return a safe type/length/digest descriptor for a model JSON value."""
153
+
154
+ if value is None:
155
+ value_type = "null"
156
+ length = None
157
+ elif isinstance(value, bool):
158
+ value_type = "boolean"
159
+ length = None
160
+ elif isinstance(value, (int, float)):
161
+ value_type = "number"
162
+ length = None
163
+ elif isinstance(value, str):
164
+ value_type = "string"
165
+ length = min(len(value), _EVIDENCE_DIAGNOSTIC_MAX_LENGTH)
166
+ elif isinstance(value, list):
167
+ value_type = "array"
168
+ length = min(len(value), _EVIDENCE_DIAGNOSTIC_MAX_LENGTH)
169
+ elif isinstance(value, dict):
170
+ value_type = "object"
171
+ length = min(len(value), _EVIDENCE_DIAGNOSTIC_MAX_LENGTH)
172
+ else:
173
+ value_type = "other"
174
+ length = None
175
+ try:
176
+ encoded = json.dumps(value, ensure_ascii=False, allow_nan=False,
177
+ sort_keys=True, separators=(",", ":")).encode("utf-8")
178
+ except (TypeError, ValueError):
179
+ encoded = value_type.encode("ascii")
180
+ return value_type, length, hashlib.sha256(encoded).hexdigest()
181
+
182
+
183
+ def _evidence_ids_digest(ids: Iterable[str]) -> str:
184
+ encoded = json.dumps(list(ids), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
185
+ return hashlib.sha256(encoded).hexdigest()
186
+
187
+
188
+ def safe_evidence_context(error: BaseException) -> dict[str, Any]:
189
+ """Return only allowlisted structural evidence diagnostics for persistence."""
190
+
191
+ path = getattr(error, "evidence_path", None)
192
+ if not isinstance(path, str) or not _EVIDENCE_PATH_RE.fullmatch(path):
193
+ path = None
194
+ value_type = getattr(error, "evidence_actual_type", None)
195
+ if not isinstance(value_type, str) or value_type not in _EVIDENCE_VALUE_TYPES:
196
+ value_type = None
197
+ length = getattr(error, "evidence_actual_length", None)
198
+ if isinstance(length, bool) or not isinstance(length, int) or not 0 <= length <= _EVIDENCE_DIAGNOSTIC_MAX_LENGTH:
199
+ length = None
200
+ digest = getattr(error, "evidence_actual_sha256", None)
201
+ if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
202
+ digest = None
203
+ expected_count = getattr(error, "evidence_expected_count", None)
204
+ if isinstance(expected_count, bool) or not isinstance(expected_count, int) or not 0 <= expected_count <= _EVIDENCE_DIAGNOSTIC_MAX_LENGTH:
205
+ expected_count = None
206
+ expected_digest = getattr(error, "evidence_expected_sha256", None)
207
+ if not isinstance(expected_digest, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_digest):
208
+ expected_digest = None
209
+ result: dict[str, Any] = {}
210
+ if path is not None:
211
+ result["evidence_path"] = path
212
+ if value_type is not None:
213
+ result["evidence_actual_type"] = value_type
214
+ if length is not None:
215
+ result["evidence_actual_length"] = length
216
+ if digest is not None:
217
+ result["evidence_actual_sha256"] = digest
218
+ if expected_count is not None:
219
+ result["evidence_expected_count"] = expected_count
220
+ if expected_digest is not None:
221
+ result["evidence_expected_sha256"] = expected_digest
222
+ return result
223
+
224
+
104
225
  _SCOPE_NAME = re.compile(r"^[^\s/\\:\x00\r\n]+$")
105
226
  _RELATIVE_DATE_TOKEN = (
106
227
  r"(?:"
@@ -1372,6 +1493,7 @@ __all__ = [
1372
1493
  "MODEL_VALIDATION_REASONS",
1373
1494
  "ModelOutputError",
1374
1495
  "NO_CHANGE_DECISION",
1496
+ "safe_evidence_context",
1375
1497
  "SCOPE_SOURCES",
1376
1498
  "TODO_STATUSES",
1377
1499
  "parse_gate",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.31
3
+ Version: 0.2.32
4
4
  Summary: A local-first Markdown memory core for AI agents
5
5
  Author: memleaf contributors
6
6
  License-Expression: MIT
@@ -23,8 +23,8 @@ Dynamic: license-file
23
23
 
24
24
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
25
25
 
26
- > **版本:0.2.31。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。本版统一 Core 与 Hermes Provider 的 UTF-8 证据留存预算:最多 64 条 source records、每条正文 32 KiB、正文总量 128 KiB,并保留有界 loss markers 与幂等规范化;同时将 metadata 模式下成功捕获的超限 pending 证据按相同有效策略消费。Gate 继续使用 physical evidence projection、`candidates`、`coverage`、`evidence_bindings` 和 allowlisted `evidence_check` 诊断,保持 source-neutral 语义、Markdown 唯一事实源和无 SQLite 运行时依赖。真实模型语义效果仍需结合本地模型和代表性样本验收。
26
+ > **版本:0.2.32。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。本版在既有 UTF-8 证据留存预算和 metadata pending 消费策略上,为 Gate 的 `unknown_unit` 提供字段路径、类型/长度/摘要和期望集合摘要等受限诊断,并在同一合法 ID 清单约束下最多三次尝试、最多两次纠正;不记录原始值、不做 ID 猜测,持续失败不推进水位且不触发清理。保持 source-neutral 语义、Markdown 唯一事实源和无 SQLite 运行时依赖。真实模型语义效果仍需结合本地模型和代表性样本验收。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -490,7 +490,7 @@ MIT,见 [LICENSE](LICENSE)。
490
490
  *Your memories, in files you own.*
491
491
 
492
492
 
493
- ## 通用处理与只读验收(0.2.31)
493
+ ## 通用处理与只读验收(0.2.32)
494
494
 
495
495
  邮件、日历、工单、文件、浏览器与普通对话共用证据准入、覆盖检查和写入路径。
496
496
  自动摘要只能使用获准引用的原文;助手复述和旧记忆回读不能单独授权新增写入。