memleaf 0.2.52__tar.gz → 0.2.54__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 (114) hide show
  1. {memleaf-0.2.52 → memleaf-0.2.54}/CHANGELOG.md +16 -0
  2. {memleaf-0.2.52/src/memleaf.egg-info → memleaf-0.2.54}/PKG-INFO +2 -2
  3. {memleaf-0.2.52 → memleaf-0.2.54}/README.en.md +1 -1
  4. {memleaf-0.2.52 → memleaf-0.2.54}/README.md +1 -1
  5. {memleaf-0.2.52 → memleaf-0.2.54}/pyproject.toml +1 -1
  6. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/model_execution.py +9 -0
  9. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/single_pass_memory_planner.py +78 -7
  10. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/single_pass_plan.py +121 -6
  11. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/validation.py +1 -1
  12. {memleaf-0.2.52 → memleaf-0.2.54/src/memleaf.egg-info}/PKG-INFO +2 -2
  13. {memleaf-0.2.52 → memleaf-0.2.54}/LICENSE +0 -0
  14. {memleaf-0.2.52 → memleaf-0.2.54}/MANIFEST.in +0 -0
  15. {memleaf-0.2.52 → memleaf-0.2.54}/docs/capture-budget-design.md +0 -0
  16. {memleaf-0.2.52 → memleaf-0.2.54}/docs/config-migrations.md +0 -0
  17. {memleaf-0.2.52 → memleaf-0.2.54}/docs/core-refactor.md +0 -0
  18. {memleaf-0.2.52 → memleaf-0.2.54}/docs/evidence-retention.md +0 -0
  19. {memleaf-0.2.52 → memleaf-0.2.54}/docs/extraction-latency.md +0 -0
  20. {memleaf-0.2.52 → memleaf-0.2.54}/docs/gate-evidence-boundary.md +0 -0
  21. {memleaf-0.2.52 → memleaf-0.2.54}/docs/general-processing.md +0 -0
  22. {memleaf-0.2.52 → memleaf-0.2.54}/docs/hermes-mcp-runtime.md +0 -0
  23. {memleaf-0.2.52 → memleaf-0.2.54}/docs/processing-quality-acceptance.md +0 -0
  24. {memleaf-0.2.52 → memleaf-0.2.54}/docs/v0.2.26-processing-status.md +0 -0
  25. {memleaf-0.2.52 → memleaf-0.2.54}/examples/README.md +0 -0
  26. {memleaf-0.2.52 → memleaf-0.2.54}/examples/basic_usage.py +0 -0
  27. {memleaf-0.2.52 → memleaf-0.2.54}/examples/mcp_stdio.ndjson +0 -0
  28. {memleaf-0.2.52 → memleaf-0.2.54}/install.ps1 +0 -0
  29. {memleaf-0.2.52 → memleaf-0.2.54}/install.sh +0 -0
  30. {memleaf-0.2.52 → memleaf-0.2.54}/setup.cfg +0 -0
  31. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/__main__.py +0 -0
  32. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/adapters/__init__.py +0 -0
  33. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/adapters/antigravity.py +0 -0
  34. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/adapters/base.py +0 -0
  35. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/adapters/codex.py +0 -0
  36. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/adapters/hermes.py +0 -0
  37. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/admission.py +0 -0
  38. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/batch_review.py +0 -0
  39. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/budget.py +0 -0
  40. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/capture.py +0 -0
  41. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/cli.py +0 -0
  42. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/compaction.py +0 -0
  43. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/config.py +0 -0
  44. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/create_coordinator.py +0 -0
  45. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/credentials.py +0 -0
  46. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/evidence_budget.py +0 -0
  47. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/evidence_policy.py +0 -0
  48. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/evidence_structure.py +0 -0
  49. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/evidence_syntax.py +0 -0
  50. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/extraction_budget.py +0 -0
  51. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/extraction_capability.py +0 -0
  52. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/extraction_work_state.py +0 -0
  53. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/frontmatter.py +0 -0
  54. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/hermes_provider/README.md +0 -0
  55. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/hermes_provider/__init__.py +0 -0
  56. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
  57. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/hermes_provider/_provider.py +0 -0
  58. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/hermes_provider/_shared.py +0 -0
  59. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
  60. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/hermes_runtime.py +0 -0
  61. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/host_events.py +0 -0
  62. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/host_runtime.py +0 -0
  63. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/inbox.py +0 -0
  64. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/index.py +0 -0
  65. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/inspection.py +0 -0
  66. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/installer.py +0 -0
  67. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/llm/__init__.py +0 -0
  68. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/llm/base.py +0 -0
  69. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/llm/claude_compatible.py +0 -0
  70. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/llm/gemini.py +0 -0
  71. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/llm/openai_compatible.py +0 -0
  72. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/llm/router.py +0 -0
  73. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/llm/thinking.py +0 -0
  74. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/locking.py +0 -0
  75. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/mcp_server.py +0 -0
  76. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/memory_commit.py +0 -0
  77. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/memory_planner.py +0 -0
  78. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/memory_writer.py +0 -0
  79. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/model_capabilities.py +0 -0
  80. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/model_discovery.py +0 -0
  81. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/models.py +0 -0
  82. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/native_index.py +0 -0
  83. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/native_registration.py +0 -0
  84. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/parallel_model.py +0 -0
  85. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/planning_context.py +0 -0
  86. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/process_common.py +0 -0
  87. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/process_jobs.py +0 -0
  88. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/process_journal.py +0 -0
  89. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/process_owner.py +0 -0
  90. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/processing.py +0 -0
  91. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/prompts.py +0 -0
  92. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/provenance.py +0 -0
  93. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/recording_policy.py +0 -0
  94. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/redaction.py +0 -0
  95. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/retention.py +0 -0
  96. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/retrieval.py +0 -0
  97. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/retrieval_gate.py +0 -0
  98. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/scope_maintenance.py +0 -0
  99. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/scope_state.py +0 -0
  100. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/service.py +0 -0
  101. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/source_policy.py +0 -0
  102. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/state_layout.py +0 -0
  103. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/subprocess_flags.py +0 -0
  104. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/summary_batch.py +0 -0
  105. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/target_reconciliation.py +0 -0
  106. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/turn_audit.py +0 -0
  107. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/turn_plan.py +0 -0
  108. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/update_coordinator.py +0 -0
  109. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/update_review.py +0 -0
  110. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf/vault.py +0 -0
  111. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf.egg-info/SOURCES.txt +0 -0
  112. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf.egg-info/dependency_links.txt +0 -0
  113. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf.egg-info/entry_points.txt +0 -0
  114. {memleaf-0.2.52 → memleaf-0.2.54}/src/memleaf.egg-info/top_level.txt +0 -0
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.54 — 2026-09-14
6
+
7
+ - Judge automatic memories by future reuse rather than speaker identity. Visible assistant final reports may establish facts learned from external sources, while transient tool failures, one-time fallbacks and routine checks with no issue or follow-up are `no_future_value`. The B3 system prompt and compact contract were rewritten rather than extended: together they shrink from 9469 to 9331 bytes, and the normal path still uses one model request.
8
+ - Reject an unrelated `NO_CHANGE` target before it can enter durable audit state. B3 previously verified only that the selected ID existed in the local catalog; it now compares the candidate's exact cited span with the target title/body using source-neutral lexical anchors. An unproven relation defers only that candidate as `target_ambiguous`, without adding a model call or failing valid siblings. Long multi-topic whole-unit claims are likewise too broad to authorize `NO_CHANGE`.
9
+ - Keep dates candidate-local and stop treating `本周日报` as `本周日` plus `报`. Date grounding now uses the candidate's exact validated quote and matching event timestamp instead of the whole assistant reply, so one mail item's date cannot authorize another memory. The relative-date tokenizer preserves `本周日报` and `下周日报`, while the existing `本周日` and `下周日` conversions remain intact.
10
+
11
+ Verification for this release used a temporary focused regression module and no model call or production Vault write. Nine tests pass for unrelated and related `NO_CHANGE` targets, Chinese subjects, identifiers and numeric codes, long whole-unit deferral, assistant-reported future-use todos, execution-noise disposition, candidate-local date grounding, and the `本周日报`/`本周日` boundary. The three changed modules compile under Python 3.11, `git diff --check` passes, and no real-model adherence or installed-Hermes replay is claimed.
12
+
13
+ ## 0.2.53 — 2026-09-13
14
+
15
+ - Keep the memory when only the project name is unproven. A real turn was lost in full because of one word: the user wrote "在弄个记账的小玩意儿,就扔家里那台 N100 上跑,没打算上云", the model named the project `project:记账小玩意儿` -- the user's phrase with 的 removed -- and Core refuses a project scope whose name is not in the candidate's own evidence. That refusal discarded the whole candidate, so the N100 deployment and the no-cloud decision went with the unproven name, leaving three evidence units unresolved and nothing written. The memory was grounded; only the affiliation was not. The claim is now dropped and the memory kept with `global`, which asserts no ownership and loses no source-backed content. The refusal itself is unchanged: Core still never records a project it cannot ground.
16
+ - Name the project from the evidence verbatim, and prefer the shortest term that appears. The same turn showed the model composing a name by editing the user's words, which is the failure mode the grounding rule catches. The contract now says so directly, with the worked example: the thing described as "记账的小玩意儿" is `project:记账`, not `project:记账小玩意儿` and not `project:记账的小玩意儿`. Measured against the live model on the exact captured turn, the composed name is gone and both the with-assistant and user-only variants yield `project:记账`.
17
+ - Make the B3 outcome counters actually surface. `b3_normalization_count` and `b3_candidate_deferred_count` were emitted but were not in the metric allowlist, so they were discarded without a word -- which made the "visible rather than silent" claim in the 0.2.49 and 0.2.50 notes untrue. Both are now recorded, together with `b3_ungrounded_scope_dropped_count` for the fallback above.
18
+
19
+ Verification for this release used the real model on the captured turn. 118 tests pass with no model call, pinning the grounding rule that caused the loss (a verbatim name passes, the edited name does not), the fallback (a project scope becomes `global`, non-project scopes survive, an empty input still yields a legal scope), and the grounding decision itself. Against the live route, replaying the exact captured turn with and without the assistant's reply now yields `project:记账` in both cases, where before the fix the with-assistant variant yielded the ungrounded composed name. The deferred candidates of that already-processed turn are replayed from the journal rather than re-attempted, so the memory lost by the old code is not recovered by upgrading; the fix applies to turns processed from now on.
20
+
5
21
  ## 0.2.52 — 2026-09-13
6
22
 
7
23
  - Stop an assistant message from originating a durable claim. A three-sentence acceptance run recorded a project requirement the user never asked for: the user wrote only "我写日志习惯用中文,英文的扫两眼就跳过去了", the assistant replied with its own elaboration ("记账工具的日志……都写成中文;异常堆栈上头再包一层人话"), and the planner stored that elaboration as a project fact. The design allows an assistant reply as a source for *organising what the user already said*, so the rule is now explicit about what that excludes: an assistant message may organise, restate or report what the user's own evidence establishes, may never be the only support for a durable claim, and contributes nothing where it states a decision, requirement, rule, plan, recommendation or added detail of its own -- however definite it sounds. Consequences the assistant derived from a user preference are the assistant's, not the user's, and are not recorded as requirements or project state.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.52
3
+ Version: 0.2.54
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.52。**
26
+ > **版本:0.2.54。**
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.52.**
7
+ > **Version: 0.2.54.**
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.52。**
7
+ > **版本:0.2.54。**
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.52"
7
+ version = "0.2.54"
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.52"
3
+ __version__ = "0.2.54"
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
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.52
2
+ version: 0.2.54
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -51,6 +51,12 @@ _COVERAGE_DIAGNOSTIC_FIELDS = frozenset({"explanation"})
51
51
  _METRIC_EVENT_FIELDS = frozenset({
52
52
  "repair_attempted_count", "repair_rejected_semantic_drift_count",
53
53
  "parse_accepted_count", "decision_case_normalization_count",
54
+ # Normalization, deferral and scope fallback are all outcomes a turn can
55
+ # reach without failing, so they must actually surface in the metrics. The
56
+ # first two were emitted but silently dropped here, which made the
57
+ # "visible rather than silent" claim in their release notes untrue.
58
+ "b3_normalization_count", "b3_candidate_deferred_count",
59
+ "b3_ungrounded_scope_dropped_count",
54
60
  })
55
61
  _THINKING_OBSERVATION_SOURCES = frozenset({
56
62
  "request_parameter", "reasoning_tokens", "reasoning_content", "unavailable",
@@ -82,6 +88,9 @@ def _metric_bucket() -> dict[str, Any]:
82
88
  "repair_rejected_semantic_drift_count": 0,
83
89
  "parse_accepted_count": 0,
84
90
  "decision_case_normalization_count": 0,
91
+ "b3_normalization_count": 0,
92
+ "b3_candidate_deferred_count": 0,
93
+ "b3_ungrounded_scope_dropped_count": 0,
85
94
  "_first_started": None,
86
95
  "_last_finished": None,
87
96
  }
@@ -34,6 +34,63 @@ from .validation import ModelOutputError, parse_summarize_output
34
34
  from .llm import ModelUnavailable
35
35
 
36
36
 
37
+ def _drop_ungrounded_project_scopes(scopes: Iterable[Any]) -> list[str]:
38
+ """Return the scopes that assert no unproven project ownership.
39
+
40
+ A project scope is a claimed affiliation and Core refuses one the evidence
41
+ does not name. The memory carrying it is usually grounded in full; only the
42
+ affiliation is unproven, so the claim is dropped rather than the memory.
43
+ Measured on a real turn, discarding the candidate instead lost
44
+ "在弄个记账的小玩意儿,跑在 N100,不上云" entirely because the model had
45
+ named the project "记账小玩意儿" while the user wrote "记账的小玩意儿".
46
+ """
47
+
48
+ kept = [
49
+ scope
50
+ for scope in scopes
51
+ if not (isinstance(scope, str) and scope.startswith("project:"))
52
+ ]
53
+ return kept or ["global"]
54
+
55
+
56
+ def _claim_date_evidence(
57
+ claims: Iterable[Mapping[str, Any]],
58
+ by_unit: Mapping[str, Any],
59
+ events: Iterable[Mapping[str, Any]],
60
+ ) -> list[dict[str, Any]]:
61
+ """Project dates from this candidate's exact validated quotes only."""
62
+
63
+ event_by_key = {
64
+ event.get("event_key"): event
65
+ for event in events
66
+ if isinstance(event, Mapping) and isinstance(event.get("event_key"), str)
67
+ }
68
+ result: list[dict[str, Any]] = []
69
+ seen: set[tuple[str, str]] = set()
70
+ for claim in claims:
71
+ if not isinstance(claim, Mapping):
72
+ continue
73
+ unit_id = claim.get("unit_id")
74
+ quote = claim.get("quote")
75
+ unit = by_unit.get(unit_id) if isinstance(unit_id, str) else None
76
+ if not isinstance(quote, str) or unit is None:
77
+ continue
78
+ event = event_by_key.get(getattr(unit, "event_key", None))
79
+ if event is None or getattr(unit, "source_role", None) not in {"user", "assistant"}:
80
+ continue
81
+ identity = (unit_id, quote)
82
+ if identity in seen:
83
+ continue
84
+ seen.add(identity)
85
+ result.append({
86
+ "event_key": getattr(unit, "event_key", None),
87
+ "role": getattr(unit, "source_role", None),
88
+ "timestamp": event.get("timestamp"),
89
+ "content": quote,
90
+ })
91
+ return result
92
+
93
+
37
94
  class SinglePassMemoryPlanner(MemoryPlanner):
38
95
  """One semantic model call for an ordinary automatic turn."""
39
96
 
@@ -430,10 +487,24 @@ class SinglePassMemoryPlanner(MemoryPlanner):
430
487
  validation_scope_registry,
431
488
  authorized_project_scopes,
432
489
  ):
433
- raise ModelOutputError(
434
- "B3 project scope is not grounded by claimed source",
435
- validation_detail="scope_not_grounded",
436
- )
490
+ # The memory itself is grounded; only the ownership claim is
491
+ # not. Discarding the whole candidate over an unproven project
492
+ # name loses source-backed content the user stated, so the
493
+ # claim is dropped and the memory is kept unscoped instead.
494
+ # Nothing is fabricated either way: no ownership is asserted.
495
+ kept = _drop_ungrounded_project_scopes(scopes)
496
+ dropped = kept != list(scopes)
497
+ scopes = kept
498
+ scope_source = self._derived_scope_source(scopes, scope_background, scope)
499
+ candidate["scopes"] = scopes
500
+ candidate["scope_source"] = scope_source
501
+ if dropped:
502
+ event = getattr(self.model, "_record_metric_event", None)
503
+ if callable(event):
504
+ event(
505
+ {"stage": "single_pass", "operation": "single_pass_primary"},
506
+ "b3_ungrounded_scope_dropped_count",
507
+ )
437
508
  if (
438
509
  decision == "UPDATE"
439
510
  and target_memory is not None
@@ -467,7 +538,8 @@ class SinglePassMemoryPlanner(MemoryPlanner):
467
538
  event["event_key"] for event in admitted_events
468
539
  if isinstance(event, Mapping) and isinstance(event.get("event_key"), str)
469
540
  ))
470
- grounded_dates = _grounded_due_dates(turn, evidence_events=admitted_events)
541
+ candidate_date_evidence = _claim_date_evidence(claims, by_unit, events)
542
+ grounded_dates = _grounded_due_dates(turn, evidence_events=candidate_date_evidence)
471
543
  summary = dict(proposed)
472
544
  if decision == "UPDATE" and target_memory is not None:
473
545
  summary.setdefault("title", target_memory.title)
@@ -510,8 +582,7 @@ class SinglePassMemoryPlanner(MemoryPlanner):
510
582
  parsed,
511
583
  grounded_dates=grounded_dates,
512
584
  source_texts=[
513
- event.get("content", "") for event in admitted_events
514
- if isinstance(event, Mapping) and event.get("role") in {"user", "assistant"}
585
+ event.get("content", "") for event in candidate_date_evidence
515
586
  ],
516
587
  preserved_texts=(
517
588
  target_memory.title,
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import re
12
+ import unicodedata
12
13
  from copy import deepcopy
13
14
  from decimal import Decimal, InvalidOperation
14
15
  from collections.abc import Callable, Iterable, Mapping, Sequence
@@ -95,6 +96,24 @@ _DECISION_OPTIONAL_FIELDS = {
95
96
  _LEGACY_REDUNDANT_ITEM_FIELDS = frozenset({"sources", "update_memory_id"})
96
97
  _LEGACY_REDUNDANT_MEMORY_FIELDS = frozenset({"type", "scopes", "scope_source", "sources", "update_memory_id"})
97
98
  _B3_REPAIR_MAX_BYTES = 64 * 1024
99
+ _TARGET_SENTENCE_SPLIT = re.compile(r"[\r\n。!?!?;;]+")
100
+ _TARGET_ASCII_ANCHOR = re.compile(r"[a-z0-9]+(?:[._/-][a-z0-9]+)*")
101
+ _TARGET_CJK_RUN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+")
102
+ _TARGET_DATE_ANCHOR = re.compile(
103
+ r"(?<![a-z0-9])(?:\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)(?![a-z0-9])"
104
+ )
105
+ _TARGET_GENERIC_CJK_BIGRAMS = frozenset({
106
+ "用户", "我们", "他们", "自己", "要求", "需要", "应该", "可以", "如果", "因为",
107
+ "所以", "没有", "不是", "已经", "目前", "现在", "相关", "问题", "情况", "项目",
108
+ "一个", "一些", "这个", "那个", "内容", "事情", "工作", "处理", "本次", "通过",
109
+ "根据", "后续", "其他", "结果", "暂时", "异常", "注意", "确认", "完成", "说明",
110
+ "状态", "新增", "进行", "是否", "可能", "所有", "每个", "提供", "发生",
111
+ })
112
+ _TARGET_GENERIC_CJK_PHRASES = frozenset({
113
+ "用户要求", "后续需要", "需要确认", "目前没有", "没有问题", "没有异常", "无异常情况",
114
+ "项目相关", "相关项目", "进行处理", "重要内容", "邮件内容", "本次处理", "需要进一步",
115
+ "工作内容", "结果如下", "如何处理", "用户需要", "继续跟进", "确认状态", "是否需要",
116
+ })
98
117
 
99
118
 
100
119
  def _enum_text(values: Iterable[str]) -> str:
@@ -109,7 +128,7 @@ UPDATE exactly requires candidate_id,decision,evidence,target_memory_id,memory;
109
128
  NO_CHANGE exactly requires candidate_id,decision,evidence,target_memory_id.
110
129
  DEFERRED exactly requires candidate_id,decision,evidence,reason; reason={_enum_text(_DEFER_REASONS)}.
111
130
  Memory allowed only: title,body,tags,aliases,keywords,status,completed_at,due_date,shadow_native_ids. status={_enum_text(TODO_STATUSES)}. status,completed_at,due_date are todo-only: omit all three for every other type. A todo UPDATE must restate its current status; status=completed requires completed_at and completed_at requires status=completed. Never put type,scopes,scope_source,sources,update_memory_id in memory.
112
- Evidence is a NONEMPTY ARRAY of claims, never one bare claim object. Each claim is exactly one of: {{unit_id,quote,role}} OR {{unit_id,whole_unit:true,role}} OR {{unit_id,start,end,quote,role}}. role={_enum_text(_EVIDENCE_ROLES)}. Offsets are start-inclusive/end-exclusive and text[start:end]==quote; quote-only must occur exactly once; user_confirmation must cite user evidence.
131
+ Evidence is a NONEMPTY ARRAY of claims, never one bare claim object. Each claim is exactly one of: {{unit_id,quote,role}} OR {{unit_id,whole_unit:true,role}} OR {{unit_id,start,end,quote,role}}. role={_enum_text(_EVIDENCE_ROLES)}. Offsets are start-inclusive/end-exclusive and text[start:end]==quote; quote-only must occur exactly once; user_confirmation must cite user evidence. Prefer the shortest exact quote; for a long reply with several facts, avoid whole_unit when a specific span suffices.
113
132
  NoMemory row exactly {{unit_id,reason}}; reason={_enum_text(_NO_MEMORY_REASONS)}.
114
133
  Every current_evidence unit must be claimed by >=1 item OR appear exactly once in no_memory, never both and never omitted. One evidence unit may support multiple independent items.
115
134
  CREATE/UPDATE/NO_CHANGE require lookup_complete=true. UPDATE/NO_CHANGE target only local_memory_catalog; a target may be used once: when several changes touch one target, emit ONE UPDATE carrying their merged current state, never several items for the same target.
@@ -120,16 +139,15 @@ Return one JSON object only. No Markdown, explanation, or reasoning."""
120
139
  SINGLE_PASS_SYSTEM = f"""You are memleaf's single-pass memory planner.
121
140
 
122
141
  SOURCE
123
- Only current_evidence may establish new facts or changes. local_memory_catalog and native_memory_catalog are comparison context; native memory is never an UPDATE/NO_CHANGE target. Never invent source facts, ownership, dates, status, obligations, numbers, relationships or IDs.
124
- An assistant message is evidence of what you reported, never of what the user wants. It may organise, restate or report what the user's own evidence establishes, but it may never be the only support for a durable claim. When it states something the user's message does not establish -- your own decision, requirement, rule, plan, recommendation or added detail -- record nothing from that statement, however definite it sounds. Consequences you derived from a user preference are yours, not the user's: never record them as a requirement or as project state. Only something the user stated or accepted becomes memory.
142
+ Use current_evidence only. User messages and assistant final reports may support facts, including facts obtained from external sources. Do not turn your own advice, plans or inferences into user intent unless accepted. Never invent facts, dates, numbers, ownership or IDs. local_memory_catalog is comparison context; native memory is never an UPDATE/NO_CHANGE target.
125
143
 
126
144
  TASK
127
- Extract every independently useful long-term memory. Keep independently retrievable/updateable topics separate and preserve entity, condition, polarity, uncertainty, ownership, state and meaning-critical numbers/codes. CREATE only when no supplied local memory represents the durable information; UPDATE only when current evidence proves a change to one supplied local memory; NO_CHANGE only when it adds no semantic change; DEFERRED for a durable candidate that cannot safely reach a terminal decision. Project ownership and platform/system names are separate judgments. Do not turn every negation into no_memory and do not use NO_CHANGE to hide ambiguity.
145
+ Keep independently retrievable facts useful for future answers, actions, commitments, status tracking or avoiding repeated research. Prefer stable facts, decisions, open work and deadlines; skip transient tool/execution failures, one-time fallbacks and routine checks with no issue or follow-up (no_future_value). Preserve future-use facts in final reports and keep independent topics separate. Preserve entity, condition, polarity, uncertainty, ownership, state and meaning-critical numbers/codes. CREATE only if no local memory represents the information; UPDATE only for a proven change to one target; NO_CHANGE only for the same future-use item with no semantic change; DEFERRED for an unsafe terminal decision. Scope ownership separately from platform/system names. Do not use NO_CHANGE to hide ambiguity.
128
146
 
129
147
  SCOPES
130
148
  Legal values: global | domain:<name> | portfolio:<name> | project:<name> | unscoped. scopes is a nonempty array; at most one project:<name> per memory; unscoped must be the only value, and Core then records insufficient_context.
131
149
  When a candidate concerns one distinct project the user is working on -- a type=project memory, or the work, decisions, state or deadlines that belong to that project -- scope it to that project as project:<name>, using the name the evidence itself uses. The project does not need to be registered first; scope_registry only lists what already exists.
132
- Ground the name in this candidate's own cited evidence, or in a project scope explicitly supplied for this turn. A product, platform, system, vendor, notification source, comparison or implementation context is not ownership by name alone.
150
+ Ground the name in this candidate's own cited evidence, or in a project scope explicitly supplied for this turn. Never edit, translate or combine the evidence's words into a name that does not appear there, and choose the shortest term that does appear and names the project: the thing described as "记账的小玩意儿" is project:记账, not project:记账小玩意儿 and not project:记账的小玩意儿. An ungrounded name is rejected and its memory is deferred instead of written. A product, platform, system, vendor, notification source, comparison or implementation context is not ownership by name alone.
133
151
  Use global only for a fact that no single project owns: a standing personal preference, a machine-wide or tool-wide rule, or an environment fact. Never invent, translate or borrow a project name.
134
152
 
135
153
  DATES
@@ -420,6 +438,71 @@ def _canonical_target(raw: Any, local_by_key: Mapping[str, Mapping[str, Any]]) -
420
438
  return target["memory_id"], target
421
439
 
422
440
 
441
+ def _target_anchor_sets(text: str) -> tuple[set[str], set[str]]:
442
+ normalized = unicodedata.normalize("NFKC", text).casefold()
443
+ without_dates = _TARGET_DATE_ANCHOR.sub(" ", normalized)
444
+ ascii_anchors = {
445
+ token for token in _TARGET_ASCII_ANCHOR.findall(without_dates)
446
+ if len(token) >= 3 or (token.isdigit() and len(token) >= 2)
447
+ }
448
+ cjk_anchors: set[str] = set()
449
+ for run in _TARGET_CJK_RUN.findall(normalized):
450
+ cjk_anchors.update(
451
+ run[index:index + 2]
452
+ for index in range(len(run) - 1)
453
+ if run[index:index + 2] not in _TARGET_GENERIC_CJK_BIGRAMS
454
+ )
455
+ return ascii_anchors, cjk_anchors
456
+
457
+
458
+ def _target_is_same_future_use(
459
+ target: Mapping[str, Any], claims: Iterable[Mapping[str, Any]],
460
+ ) -> bool:
461
+ """Check a NO_CHANGE target against this candidate's exact cited text."""
462
+
463
+ target_units = [
464
+ part.strip()
465
+ for value in (target.get("title"), target.get("body"))
466
+ if isinstance(value, str)
467
+ for part in _TARGET_SENTENCE_SPLIT.split(value)
468
+ if part.strip()
469
+ ]
470
+ for claim in claims:
471
+ quote = claim.get("quote")
472
+ if not isinstance(quote, str) or not quote.strip():
473
+ continue
474
+ for quote_unit in (part.strip() for part in _TARGET_SENTENCE_SPLIT.split(quote) if part.strip()):
475
+ quote_ascii, quote_cjk = _target_anchor_sets(quote_unit)
476
+ for target_unit in target_units:
477
+ target_ascii, target_cjk = _target_anchor_sets(target_unit)
478
+ shared_ascii = quote_ascii & target_ascii
479
+ shared_cjk = quote_cjk & target_cjk
480
+ shared_numbers = {token for token in shared_ascii if token.isdigit()}
481
+ shared_identifiers = {token for token in shared_ascii if not token.isdigit()}
482
+ strong_identifiers = {
483
+ token for token in shared_identifiers
484
+ if len(token) >= 6 or any(char.isdigit() for char in token)
485
+ }
486
+ if len(strong_identifiers) >= 2 or (
487
+ strong_identifiers and (shared_cjk or len(shared_identifiers) >= 2)
488
+ ):
489
+ return True
490
+ if any(len(token) >= 5 for token in shared_numbers) and (
491
+ shared_cjk or shared_identifiers
492
+ ):
493
+ return True
494
+ if len(shared_cjk) < 2:
495
+ continue
496
+ overlap = len(shared_cjk) / min(len(quote_cjk), len(target_cjk))
497
+ compact = "".join(
498
+ char for char in unicodedata.normalize("NFKC", quote_unit).casefold()
499
+ if char.isalnum()
500
+ )
501
+ if overlap >= 0.28 and compact not in _TARGET_GENERIC_CJK_PHRASES:
502
+ return True
503
+ return False
504
+
505
+
423
506
  MemoryValidator = Callable[
424
507
  [str, str, str | None, Mapping[str, Any] | None, Mapping[str, Any], list[dict[str, Any]], Mapping[str, Any]],
425
508
  Mapping[str, Any],
@@ -498,6 +581,8 @@ def parse_single_pass_output(
498
581
  binding_rows: list[dict[str, Any]] = []
499
582
  prepared: list[tuple[dict[str, Any], Mapping[str, Any] | None]] = []
500
583
  forced_defer: dict[str, str] = {}
584
+ broad_whole_unit_no_change: set[str] = set()
585
+ unit_by_id = {getattr(unit, "unit_id", None): unit for unit in source_units}
501
586
 
502
587
  for item_index, raw_item in enumerate(items):
503
588
  item_path = f"items[{item_index}]"
@@ -579,6 +664,16 @@ def parse_single_pass_output(
579
664
  path=f"{item_path}.evidence", rule="type", actual=claims,
580
665
  expected_type="array",
581
666
  )
667
+ if decision == "NO_CHANGE" and any(
668
+ isinstance(claim, Mapping)
669
+ and claim.get("whole_unit") is True
670
+ and len(getattr(unit_by_id.get(claim.get("unit_id")), "text", "")) > 512
671
+ for claim in claims
672
+ ):
673
+ # A whole long reply can contain many independent topics. It is
674
+ # insufficient proof that one selected old memory covers this
675
+ # candidate; ask for a narrow claim on a later bounded pass.
676
+ broad_whole_unit_no_change.add(candidate_key)
582
677
  binding_rows.append({"candidate_id": candidate_id, "claims": claims})
583
678
 
584
679
  target_record: Mapping[str, Any] | None = None
@@ -790,7 +885,27 @@ def parse_single_pass_output(
790
885
  normalized["target_memory_id"] = target_id
791
886
  normalized["memory"] = dict(validated)
792
887
  elif decision == "NO_CHANGE":
793
- normalized["target_memory_id"] = item["target_memory_id"]
888
+ if (
889
+ candidate_id.casefold() in broad_whole_unit_no_change
890
+ or not isinstance(target_record, Mapping)
891
+ or not _target_is_same_future_use(
892
+ target_record, evidence
893
+ )
894
+ ):
895
+ normalized = {
896
+ "candidate_id": candidate_id,
897
+ "decision": "DEFERRED",
898
+ "reason": "target_ambiguous",
899
+ "evidence": evidence,
900
+ }
901
+ if deferrals is not None:
902
+ deferrals.append({
903
+ "candidate_id": candidate_id,
904
+ "reason": "target_ambiguous",
905
+ "detail": "target_relevance_unproven",
906
+ })
907
+ else:
908
+ normalized["target_memory_id"] = item["target_memory_id"]
794
909
  else:
795
910
  normalized["reason"] = item["reason"]
796
911
  except ModelOutputError as error:
@@ -344,7 +344,7 @@ _RELATIVE_DATE_TOKEN = (
344
344
  r"(?<![A-Za-z])(?:today|tomorrow|yesterday)(?![A-Za-z])"
345
345
  r"|(?<![A-Za-z])(?:this|next|last)\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)(?![A-Za-z])"
346
346
  r"|(?:本|这|下|上)(?:个)?(?:周|星期|礼拜)\s*"
347
- r"(?:(?:星期|礼拜)\s*)?(?:一|二|三|四|五|六|日|天|末|[1-7])"
347
+ r"(?:(?:星期|礼拜)\s*)?(?:一|二|三|四|五|六|天|末|[1-7]|日(?!报))"
348
348
  r"|(?:今天|明天|昨天|今日|明日|昨日)"
349
349
  r")"
350
350
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.52
3
+ Version: 0.2.54
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.52。**
26
+ > **版本:0.2.54。**
27
27
  > 记忆只从用户与 Agent 的可见对话提炼。Agent 已在回复中整理的事实、项目进展和明确待办可作为来源;邮件、附件、网页和终端等工具原文不进入记忆提炼。模型负责保留原话中的不确定性,现有记忆仅用于比较、去重和更新。Markdown 仍是唯一事实源。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes