memleaf 0.2.47__tar.gz → 0.2.49__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 (113) hide show
  1. {memleaf-0.2.47 → memleaf-0.2.49}/CHANGELOG.md +21 -0
  2. {memleaf-0.2.47/src/memleaf.egg-info → memleaf-0.2.49}/PKG-INFO +2 -2
  3. {memleaf-0.2.47 → memleaf-0.2.49}/README.en.md +1 -1
  4. {memleaf-0.2.47 → memleaf-0.2.49}/README.md +1 -1
  5. {memleaf-0.2.47 → memleaf-0.2.49}/pyproject.toml +1 -1
  6. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/extraction_budget.py +3 -0
  8. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  9. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/llm/router.py +16 -0
  10. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/planning_context.py +26 -9
  11. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/process_common.py +34 -19
  12. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/single_pass_memory_planner.py +5 -0
  13. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/single_pass_plan.py +350 -34
  14. {memleaf-0.2.47 → memleaf-0.2.49/src/memleaf.egg-info}/PKG-INFO +2 -2
  15. {memleaf-0.2.47 → memleaf-0.2.49}/LICENSE +0 -0
  16. {memleaf-0.2.47 → memleaf-0.2.49}/MANIFEST.in +0 -0
  17. {memleaf-0.2.47 → memleaf-0.2.49}/docs/capture-budget-design.md +0 -0
  18. {memleaf-0.2.47 → memleaf-0.2.49}/docs/config-migrations.md +0 -0
  19. {memleaf-0.2.47 → memleaf-0.2.49}/docs/core-refactor.md +0 -0
  20. {memleaf-0.2.47 → memleaf-0.2.49}/docs/evidence-retention.md +0 -0
  21. {memleaf-0.2.47 → memleaf-0.2.49}/docs/extraction-latency.md +0 -0
  22. {memleaf-0.2.47 → memleaf-0.2.49}/docs/gate-evidence-boundary.md +0 -0
  23. {memleaf-0.2.47 → memleaf-0.2.49}/docs/general-processing.md +0 -0
  24. {memleaf-0.2.47 → memleaf-0.2.49}/docs/hermes-mcp-runtime.md +0 -0
  25. {memleaf-0.2.47 → memleaf-0.2.49}/docs/processing-quality-acceptance.md +0 -0
  26. {memleaf-0.2.47 → memleaf-0.2.49}/docs/v0.2.26-processing-status.md +0 -0
  27. {memleaf-0.2.47 → memleaf-0.2.49}/examples/README.md +0 -0
  28. {memleaf-0.2.47 → memleaf-0.2.49}/examples/basic_usage.py +0 -0
  29. {memleaf-0.2.47 → memleaf-0.2.49}/examples/mcp_stdio.ndjson +0 -0
  30. {memleaf-0.2.47 → memleaf-0.2.49}/install.ps1 +0 -0
  31. {memleaf-0.2.47 → memleaf-0.2.49}/install.sh +0 -0
  32. {memleaf-0.2.47 → memleaf-0.2.49}/setup.cfg +0 -0
  33. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/__main__.py +0 -0
  34. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/adapters/__init__.py +0 -0
  35. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/adapters/antigravity.py +0 -0
  36. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/adapters/base.py +0 -0
  37. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/adapters/codex.py +0 -0
  38. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/adapters/hermes.py +0 -0
  39. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/admission.py +0 -0
  40. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/batch_review.py +0 -0
  41. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/budget.py +0 -0
  42. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/capture.py +0 -0
  43. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/cli.py +0 -0
  44. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/compaction.py +0 -0
  45. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/config.py +0 -0
  46. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/create_coordinator.py +0 -0
  47. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/credentials.py +0 -0
  48. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/evidence_budget.py +0 -0
  49. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/evidence_policy.py +0 -0
  50. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/evidence_structure.py +0 -0
  51. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/evidence_syntax.py +0 -0
  52. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/extraction_capability.py +0 -0
  53. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/extraction_work_state.py +0 -0
  54. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/frontmatter.py +0 -0
  55. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/hermes_provider/README.md +0 -0
  56. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/hermes_provider/__init__.py +0 -0
  57. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
  58. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/hermes_provider/_provider.py +0 -0
  59. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/hermes_provider/_shared.py +0 -0
  60. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
  61. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/hermes_runtime.py +0 -0
  62. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/host_events.py +0 -0
  63. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/host_runtime.py +0 -0
  64. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/inbox.py +0 -0
  65. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/index.py +0 -0
  66. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/inspection.py +0 -0
  67. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/installer.py +0 -0
  68. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/llm/__init__.py +0 -0
  69. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/llm/base.py +0 -0
  70. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/llm/claude_compatible.py +0 -0
  71. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/llm/gemini.py +0 -0
  72. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/llm/openai_compatible.py +0 -0
  73. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/llm/thinking.py +0 -0
  74. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/locking.py +0 -0
  75. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/mcp_server.py +0 -0
  76. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/memory_commit.py +0 -0
  77. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/memory_planner.py +0 -0
  78. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/memory_writer.py +0 -0
  79. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/model_capabilities.py +0 -0
  80. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/model_discovery.py +0 -0
  81. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/model_execution.py +0 -0
  82. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/models.py +0 -0
  83. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/native_index.py +0 -0
  84. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/native_registration.py +0 -0
  85. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/parallel_model.py +0 -0
  86. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/process_jobs.py +0 -0
  87. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/process_journal.py +0 -0
  88. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/process_owner.py +0 -0
  89. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/processing.py +0 -0
  90. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/prompts.py +0 -0
  91. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/provenance.py +0 -0
  92. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/recording_policy.py +0 -0
  93. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/redaction.py +0 -0
  94. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/retention.py +0 -0
  95. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/retrieval.py +0 -0
  96. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/retrieval_gate.py +0 -0
  97. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/scope_maintenance.py +0 -0
  98. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/scope_state.py +0 -0
  99. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/service.py +0 -0
  100. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/source_policy.py +0 -0
  101. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/state_layout.py +0 -0
  102. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/summary_batch.py +0 -0
  103. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/target_reconciliation.py +0 -0
  104. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/turn_audit.py +0 -0
  105. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/turn_plan.py +0 -0
  106. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/update_coordinator.py +0 -0
  107. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/update_review.py +0 -0
  108. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/validation.py +0 -0
  109. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf/vault.py +0 -0
  110. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf.egg-info/SOURCES.txt +0 -0
  111. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf.egg-info/dependency_links.txt +0 -0
  112. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf.egg-info/entry_points.txt +0 -0
  113. {memleaf-0.2.47 → memleaf-0.2.49}/src/memleaf.egg-info/top_level.txt +0 -0
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.49 — 2026-09-13
6
+
7
+ - Restore candidate-local deferral to the B3 single-pass path. v0.2.26 established that an invalid proposal receives bounded model correction and then explicit candidate-local deferral while "valid siblings proceed", and v0.2.34 required that "candidate-local deferral and idempotent partial retries" be preserved so unresolved ownership, target, evidence or timing never fabricates a write. The B3 planner had lost that property: the first proposal Core rejected raised out of the parser and discarded every other verdict in the turn, so one model slip cost the whole turn even when six other candidates were sound. A proposal that Core can attribute to a single candidate is now deferred with a B3 defer reason and the rest of the turn still commits. Nothing is written for a deferred candidate, no extra model call is made, and the turn stays retryable with its already-settled units intact.
8
+ - Keep the deferral boundary narrow and explicit. Only details that describe the candidate itself are converted: scope authorisation and update-target problems map to `scope_ambiguous` and `target_ambiguous`, while a malformed proposal (todo fields, ungrounded or malformed dates, type, source shape) maps to `maintenance_uncertain` and missing admitted evidence maps to `evidence_insufficient`. Every other failure — transport, truncation, envelope shape, evidence coverage, binding scope, and any detail Core does not recognise — still fails the whole turn closed, so a systemic fault cannot hide behind a deferral.
9
+ - Make the deferral visible rather than silent. Each one is counted as `b3_candidate_deferred_count` on the turn, the cause is recorded against the candidate, and the deferred work continues to surface through `deferred_candidates`, `retryable_deferred_turns` and `coverage_status: partial` in the read-only processing status. The persisted cause is restricted to a bounded identifier so no model text can reach state files.
10
+ - Keep the deferral from being mistaken for a collision. Same-target reconciliation groups items that reach a terminal disposition for one memory, and a deferred candidate reaches none: it writes nothing, carries no update target, and cannot conflict with an admitted update to the same memory. Only the candidates that actually reach UPDATE or NO_CHANGE are grouped now, so a turn that updates one memory and defers another does not spend its second request reconciling a candidate that was never admitted.
11
+
12
+ Verification for this release repeated the same real-model replay used for 0.2.48 and added focused synthetic tests: 90 tests pass with no model call, covering the sibling-commits case, every deferrable detail and the reason it maps to, deferral being driven by an explicit allowlist, the optional argument for direct parser callers, a deferred member not being grouped as a same-target collision, and the turn-level failures that must still abort. Three further preview runs of the previously failing production session completed with `execution_status: ok` and `coverage_status: complete`. A deferral is a visible degradation rather than a success: the candidate is retried, not written, and a prompt or configuration fault that made every proposal invalid would now show up as a fully deferred turn rather than as a hard failure.
13
+
14
+ ## 0.2.48 — 2026-09-13
15
+
16
+ - Restore same-target reconciliation in the B3 single-pass path. A real turn was discarded with `B3 target referenced more than once` whenever the model admitted two changes for one existing memory. One memory can only receive one terminal disposition per turn, because the writer archives the previous version and overwrites the same file, so the colliding items are now reconciled with one bounded call into a single current state and an unresolvable group is deferred as `target_ambiguous` instead of guessed. This is the capability the legacy staged path already had; it is not a new retry class, and the compact contract now tells the model to merge such changes into one UPDATE in the first place. The call is charged against the same two-request turn budget as the primary extraction and its structural repair, so a collision can never lengthen a turn: when a repair has already spent the budget, the group defers and every other item still commits. The strict "a target may be used once" rejection remains in the parser for direct callers.
17
+ - Give the B3 planner the temporal anchor it was always assumed to have. The prompt contract promised that "an admitted visible-message timestamp may anchor a supported relative date" and Core grounded `due_date` against exactly that timestamp, but the evidence projection never showed it to the model. A yearless deadline such as "9 月 30 号" was therefore unanswerable: the model guessed a year, and the grounded-date gate correctly rejected the entire turn. Each projected evidence unit now carries its own event timestamp, a `DATES` section states that the anchor resolves only dates the unit's own text expresses, and a timestamp's own date is never content.
18
+ - Stop treating a reworded yearless date as an invented one. Grounding compared raw surface text, so a faithful restatement of a source date that only changed its suffix, such as 号 -> 日, was rejected as ungrounded and cost the whole turn. A yearless date is now recognised by its month and day, which is the property the guard actually protects: no year is ever inferred, and a different month, day or full date is still refused.
19
+ - State that `status`, `completed_at` and `due_date` are todo-only in the compact contract. The validator has always rejected a `status` on any other type, but the model was never told, so a correct-looking extraction failed with `invalid todo status`; the contract now also states that a todo UPDATE restates its status and that `completed` and `completed_at` require each other.
20
+ - Resolve one bookkeeping contradiction instead of discarding the turn. A unit listed both as an item's evidence and as `no_memory` is self-contradictory, and the resolution is forced: an item already bound the unit, so the unit is not a no-memory unit. The redundant row is dropped, counted as `b3_normalization_count`, and no memory content, scope or evidence binding changes. Genuine omissions still fail closed as incomplete evidence coverage.
21
+ - Stop the single-pass budget wrapper from erasing protocol capability. `SinglePassBudgetBackend` delegates by attribute name, and `ModelRouter` had gained `single_pass_safe` without `single_pass_protocol`, so a wrapped route that does speak `b3-single-pass-v1` read as incapable and every automatic turn failed in 0.000s with zero model calls. The router now answers both properties from the same resolved route.
22
+ - Keep a clipped native memory from making the lookup look incomplete. Native sources are comparison context and can never be an UPDATE or NO_CHANGE target, but a native body shortened for the prompt budget was recorded as a dropped item and marked `lookup_complete=false`, which blocked every terminal decision in the turn. Only an item that is actually discarded for the budget can affect completeness now, and the prompt-length budget itself is unchanged.
23
+
24
+ Verification for this release combined focused synthetic tests with one real-model replay. 84 tests pass with no model call, covering same-target reconciliation (merge, no-change, deferral, re-validation, evidence union, and a route-level assertion that a third provider request is never issued), the date anchor projection, yearless-date identity, the todo-field contract, redundant-row normalization, the wrapper capability properties and native clipping completeness. The real replay ran the previously failing production session against `deepseek-flash`: before these fixes every attempt failed in the planner, and after them five consecutive preview runs completed with `execution_status: ok` (four `coverage_status: complete`, one partial with a single deferred candidate), and the committed run wrote five memories into the live Vault with a todo carrying `status: active` and the correct grounded `due_date`. A single-candidate semantic rejection from the model still fails its turn closed; restoring per-candidate deferral or semantic retries for that case would be a deliberate design change and was not made here.
25
+
5
26
  ## 0.2.47 — 2026-09-12
6
27
 
7
28
  - Fix two satisfiability deadlocks in the public candidate filter that made every compound query carrying a punctuation-rich identifier return no_match. Word splitting turns `vX.Y.Z` into `vX` / `Y` / `Z`; those one-character fragments were collected as *required* ASCII components while the matching rule refused to treat anything shorter than three characters as a strong match, so the requirement could never be met. A fixed three-character bar had the same effect on a two-character CJK component whenever an ASCII component was present. A component that cannot reach its own bar no longer creates a requirement, and a CJK component's bar never exceeds its own length. Identifier strictness is unchanged: a short fragment still cannot stand in for a longer topic.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.47
3
+ Version: 0.2.49
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.47。**
26
+ > **版本:0.2.49。**
27
27
  > 记忆只从用户与 Agent 的可见对话提炼。Agent 已在回复中整理的事实、项目进展和明确待办可作为来源;邮件、附件、网页和终端等工具原文不进入记忆提炼。模型负责保留原话中的不确定性,现有记忆仅用于比较、去重和更新。Markdown 仍是唯一事实源。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
@@ -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.47.**
7
+ > **Version: 0.2.49.**
8
8
  > Automatic extraction now uses only 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. Background processing is persisted as a local job and can be checked through the read-only `process_status` MCP tool; failed work remains retryable and fail closed. The release also tightens source/date grounding, target reconciliation, duplicate/no-op handling and semantic review before writes. Markdown remains the sole source of truth with no SQLite runtime dependency. Acceptance covers deterministic regression suites and synthetic inputs; it does not claim real-mail or customer-business acceptance.
9
9
  > **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
10
10
 
@@ -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.47。**
7
+ > **版本:0.2.49。**
8
8
  > 记忆只从用户与 Agent 的可见对话提炼。Agent 已在回复中整理的事实、项目进展和明确待办可作为来源;邮件、附件、网页和终端等工具原文不进入记忆提炼。模型负责保留原话中的不确定性,现有记忆仅用于比较、去重和更新。Markdown 仍是唯一事实源。
9
9
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
10
10
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memleaf"
7
- version = "0.2.47"
7
+ version = "0.2.49"
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.47"
3
+ __version__ = "0.2.49"
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
@@ -24,6 +24,9 @@ class SinglePassBudgetBackend:
24
24
  hidden host-to-API fallback. The optional durable reservation preserves
25
25
  consumed attempts across worker restarts. Neither the first request nor
26
26
  its repair overrides the transport's configured ``llm.request_timeout``.
27
+ The second request is spent on whichever bounded follow-up the planner
28
+ actually needs -- a structural repair or a same-target reconciliation --
29
+ so a collision between two admitted items can never extend the turn.
27
30
  """
28
31
 
29
32
  def __init__(
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.47
2
+ version: 0.2.49
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -84,6 +84,22 @@ class ModelRouter:
84
84
  return getattr(self.api, "single_pass_safe", False) is True
85
85
  return False
86
86
 
87
+ @property
88
+ def single_pass_protocol(self) -> bool:
89
+ """Expose B3 protocol capability symmetrically with ``single_pass_safe``.
90
+
91
+ Request-budget wrappers forward capability by attribute name. A router
92
+ that exposed only ``single_pass_safe`` therefore read as protocol
93
+ incapable the moment it was wrapped, which made automatic extraction
94
+ fail on exactly the routes that were safe enough to be wrapped.
95
+ """
96
+
97
+ if self.mode == "api":
98
+ return getattr(self.api, "single_pass_protocol", False) is True
99
+ if self.mode == "auto" and self.host is None:
100
+ return getattr(self.api, "single_pass_protocol", False) is True
101
+ return False
102
+
87
103
  @staticmethod
88
104
  def _coerce_host(value: Any) -> Optional[ModelBackend]:
89
105
  if value is None:
@@ -315,17 +315,36 @@ class PlanningContext:
315
315
  selected: list[dict[str, Any]] = []
316
316
  used = 2
317
317
  complete = True
318
- for value in values:
319
- if len(selected) >= _RELATED_MAX_ITEMS:
318
+
319
+ def _drop(item: Mapping[str, Any]) -> None:
320
+ """Record one related record that could not be projected verbatim.
321
+
322
+ ``complete`` answers one question only: can Core still prove that no
323
+ **local** record was withheld, so that an absent UPDATE target and a
324
+ create-safe lookup remain provable? Native sources are read-only
325
+ host files that are never an UPDATE or NO_CHANGE target and are only
326
+ ever referenced through ``shadow_native_ids``, so clipping one must
327
+ not make the whole lookup unprovable. Treating a long host memory
328
+ file as a failed proof would block every automatic extraction with no
329
+ way for the user to recover except shrinking a file they own.
330
+ """
331
+
332
+ nonlocal complete
333
+ if item.get("native") is not True:
320
334
  complete = False
335
+
336
+ for index, value in enumerate(values):
337
+ if len(selected) >= _RELATED_MAX_ITEMS:
338
+ for item in values[index:]:
339
+ _drop(item)
321
340
  break
322
341
  body = value.get("body")
323
342
  if isinstance(body, str) and len(body) > _RELATED_MAX_BODY_CHARS:
324
343
  value["body"] = body[: _RELATED_MAX_BODY_CHARS - 1].rstrip() + "…"
325
- complete = False
344
+ _drop(value)
326
345
  size = cls._related_payload_size(value)
327
346
  if size < 0:
328
- complete = False
347
+ _drop(value)
329
348
  continue
330
349
  additional = size + (1 if selected else 0)
331
350
  if used + additional > _RELATED_MAX_CHARS:
@@ -334,7 +353,7 @@ class PlanningContext:
334
353
  isinstance(memory_id, str)
335
354
  and memory_id.casefold() in priority
336
355
  ):
337
- complete = False
356
+ _drop(value)
338
357
  continue
339
358
  minimal = {
340
359
  key: value[key]
@@ -343,15 +362,13 @@ class PlanningContext:
343
362
  }
344
363
  size = cls._related_payload_size(minimal)
345
364
  if size < 0 or used + size + (1 if selected else 0) > _RELATED_MAX_CHARS:
346
- complete = False
365
+ _drop(value)
347
366
  continue
348
367
  value = minimal
349
368
  additional = size + (1 if selected else 0)
350
- complete = False
369
+ _drop(value)
351
370
  selected.append(value)
352
371
  used += additional
353
- if len(selected) != len(values):
354
- complete = False
355
372
  return selected, complete
356
373
 
357
374
  @classmethod
@@ -683,16 +683,19 @@ def _add_external_due_dates(result: set[str], content: Any) -> None:
683
683
  result.add(parsed.isoformat())
684
684
 
685
685
 
686
- def _summary_calendar_tokens(content: Any) -> tuple[tuple[str, str | None], ...]:
687
- """Return source-neutral calendar literals and canonical full dates.
686
+ def _summary_calendar_tokens(content: Any) -> tuple[tuple[str, str | None, str | None], ...]:
687
+ """Return source-neutral calendar literals, canonical dates and month/day.
688
688
 
689
689
  A ``None`` canonical value is intentional for yearless or invalid text;
690
- callers must not fill its year from a retrieval timestamp.
690
+ callers must not fill its year from a retrieval timestamp. The third item
691
+ is the yearless ``MM-DD`` identity of a token that carries no year, so a
692
+ faithful restatement of the same month and day can be recognized without
693
+ ever granting that token a year.
691
694
  """
692
695
 
693
696
  if not isinstance(content, str):
694
697
  return ()
695
- tokens: list[tuple[str, str | None]] = []
698
+ tokens: list[tuple[str, str | None, str | None]] = []
696
699
  seen: set[str] = set()
697
700
  for raw in _ISO_CALENDAR_DATE_RE.findall(content):
698
701
  if raw in seen:
@@ -702,27 +705,25 @@ def _summary_calendar_tokens(content: Any) -> tuple[tuple[str, str | None], ...]
702
705
  canonical = datetime.strptime(raw, "%Y-%m-%d").date().isoformat()
703
706
  except ValueError:
704
707
  canonical = None
705
- tokens.append((raw, canonical))
708
+ tokens.append((raw, canonical, None))
706
709
  for match in _SUMMARY_CHINESE_CALENDAR_DATE_RE.finditer(content):
707
710
  raw = match.group("raw")
708
711
  if raw in seen:
709
712
  continue
710
713
  seen.add(raw)
711
714
  year = match.group("year")
715
+ month = int(match.group("month"))
716
+ day = int(match.group("day"))
712
717
  try:
713
718
  canonical = (
714
- datetime(
715
- int(year),
716
- int(match.group("month")),
717
- int(match.group("day")),
718
- tzinfo=timezone.utc,
719
- ).date().isoformat()
719
+ datetime(year and int(year), month, day, tzinfo=timezone.utc).date().isoformat()
720
720
  if year
721
721
  else None
722
722
  )
723
723
  except ValueError:
724
724
  canonical = None
725
- tokens.append((raw, canonical))
725
+ monthday = f"{month:02d}-{day:02d}" if 1 <= month <= 12 and 1 <= day <= 31 else None
726
+ tokens.append((raw, canonical, monthday))
726
727
  return tuple(tokens)
727
728
 
728
729
 
@@ -738,7 +739,9 @@ def _summary_date_grounding_violations(
738
739
  ``grounded_dates`` is the canonical date set from the current admitted
739
740
  evidence. ``source_texts`` must contain only the current candidate's
740
741
  admitted evidence projection text. A source date without a year may be
741
- retained verbatim, but it never authorizes a year to be inferred.
742
+ retained verbatim, but it never authorizes a year to be inferred; the same
743
+ month and day restated in another surface form is still that source date,
744
+ because the guard forbids inventing a year, not rewording a suffix.
742
745
  ``preserved_texts`` must contain only the selected update target's existing
743
746
  title/body/due-date text. Existing target literals may be retained
744
747
  verbatim; dates from unrelated memories are never accepted.
@@ -761,33 +764,45 @@ def _summary_date_grounding_violations(
761
764
  allowed_dates.add(value)
762
765
 
763
766
  source_tokens: set[str] = set()
767
+ source_monthdays: set[str] = set()
764
768
  source_values = (source_texts,) if isinstance(source_texts, str) else source_texts
765
769
  for text in source_values:
766
- for raw, canonical in _summary_calendar_tokens(text):
770
+ for raw, canonical, monthday in _summary_calendar_tokens(text):
767
771
  source_tokens.add(raw)
772
+ if monthday is not None and canonical is None:
773
+ source_monthdays.add(monthday)
768
774
  if canonical is not None:
769
775
  allowed_dates.add(canonical)
770
776
 
771
777
  preserved_tokens: set[str] = set()
778
+ preserved_monthdays: set[str] = set()
772
779
  preserved_values = (preserved_texts,) if isinstance(preserved_texts, str) else preserved_texts
773
780
  for text in preserved_values:
774
- for raw, canonical in _summary_calendar_tokens(text):
781
+ for raw, canonical, monthday in _summary_calendar_tokens(text):
775
782
  preserved_tokens.add(raw)
783
+ if monthday is not None and canonical is None:
784
+ preserved_monthdays.add(monthday)
776
785
  if canonical is not None:
777
786
  allowed_dates.add(canonical)
778
787
 
788
+ yearless_monthdays = source_monthdays | preserved_monthdays
779
789
  violations: list[str] = []
780
790
  seen: set[str] = set()
781
791
  for field in ("title", "body"):
782
- for raw, canonical in _summary_calendar_tokens(summary.get(field)):
792
+ for raw, canonical, monthday in _summary_calendar_tokens(summary.get(field)):
783
793
  if raw in source_tokens or raw in preserved_tokens:
784
794
  continue
785
795
  value = canonical or raw
786
796
  if value in seen:
787
797
  continue
788
- if canonical is None or canonical not in allowed_dates:
789
- seen.add(value)
790
- violations.append(value)
798
+ if canonical is not None and canonical in allowed_dates:
799
+ continue
800
+ if canonical is None and monthday is not None and monthday in yearless_monthdays:
801
+ # Same month and day as a yearless source date: the wording
802
+ # changed, the date did not, and no year was invented.
803
+ continue
804
+ seen.add(value)
805
+ violations.append(value)
791
806
  return tuple(violations)
792
807
 
793
808
 
@@ -541,6 +541,11 @@ class SinglePassMemoryPlanner(MemoryPlanner):
541
541
  "session_id": turn.session_id,
542
542
  "turn_index": turn.turn_index,
543
543
  },
544
+ evidence_timestamps={
545
+ event["event_key"]: event.get("timestamp")
546
+ for event in events
547
+ if isinstance(event.get("event_key"), str)
548
+ },
544
549
  )
545
550
 
546
551
  fallback_scopes, fallback_scope_source = self._fallback_scopes(scope_background)