memleaf 0.2.53__tar.gz → 0.2.55__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.53 → memleaf-0.2.55}/CHANGELOG.md +16 -0
  2. {memleaf-0.2.53/src/memleaf.egg-info → memleaf-0.2.55}/PKG-INFO +2 -2
  3. {memleaf-0.2.53 → memleaf-0.2.55}/README.en.md +1 -1
  4. {memleaf-0.2.53 → memleaf-0.2.55}/README.md +1 -1
  5. {memleaf-0.2.53 → memleaf-0.2.55}/pyproject.toml +1 -1
  6. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/model_execution.py +3 -1
  9. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/process_common.py +152 -0
  10. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/single_pass_memory_planner.py +116 -4
  11. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/single_pass_plan.py +122 -9
  12. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/validation.py +16 -1
  13. {memleaf-0.2.53 → memleaf-0.2.55/src/memleaf.egg-info}/PKG-INFO +2 -2
  14. {memleaf-0.2.53 → memleaf-0.2.55}/LICENSE +0 -0
  15. {memleaf-0.2.53 → memleaf-0.2.55}/MANIFEST.in +0 -0
  16. {memleaf-0.2.53 → memleaf-0.2.55}/docs/capture-budget-design.md +0 -0
  17. {memleaf-0.2.53 → memleaf-0.2.55}/docs/config-migrations.md +0 -0
  18. {memleaf-0.2.53 → memleaf-0.2.55}/docs/core-refactor.md +0 -0
  19. {memleaf-0.2.53 → memleaf-0.2.55}/docs/evidence-retention.md +0 -0
  20. {memleaf-0.2.53 → memleaf-0.2.55}/docs/extraction-latency.md +0 -0
  21. {memleaf-0.2.53 → memleaf-0.2.55}/docs/gate-evidence-boundary.md +0 -0
  22. {memleaf-0.2.53 → memleaf-0.2.55}/docs/general-processing.md +0 -0
  23. {memleaf-0.2.53 → memleaf-0.2.55}/docs/hermes-mcp-runtime.md +0 -0
  24. {memleaf-0.2.53 → memleaf-0.2.55}/docs/processing-quality-acceptance.md +0 -0
  25. {memleaf-0.2.53 → memleaf-0.2.55}/docs/v0.2.26-processing-status.md +0 -0
  26. {memleaf-0.2.53 → memleaf-0.2.55}/examples/README.md +0 -0
  27. {memleaf-0.2.53 → memleaf-0.2.55}/examples/basic_usage.py +0 -0
  28. {memleaf-0.2.53 → memleaf-0.2.55}/examples/mcp_stdio.ndjson +0 -0
  29. {memleaf-0.2.53 → memleaf-0.2.55}/install.ps1 +0 -0
  30. {memleaf-0.2.53 → memleaf-0.2.55}/install.sh +0 -0
  31. {memleaf-0.2.53 → memleaf-0.2.55}/setup.cfg +0 -0
  32. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/__main__.py +0 -0
  33. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/adapters/__init__.py +0 -0
  34. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/adapters/antigravity.py +0 -0
  35. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/adapters/base.py +0 -0
  36. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/adapters/codex.py +0 -0
  37. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/adapters/hermes.py +0 -0
  38. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/admission.py +0 -0
  39. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/batch_review.py +0 -0
  40. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/budget.py +0 -0
  41. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/capture.py +0 -0
  42. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/cli.py +0 -0
  43. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/compaction.py +0 -0
  44. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/config.py +0 -0
  45. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/create_coordinator.py +0 -0
  46. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/credentials.py +0 -0
  47. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/evidence_budget.py +0 -0
  48. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/evidence_policy.py +0 -0
  49. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/evidence_structure.py +0 -0
  50. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/evidence_syntax.py +0 -0
  51. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/extraction_budget.py +0 -0
  52. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/extraction_capability.py +0 -0
  53. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/extraction_work_state.py +0 -0
  54. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/frontmatter.py +0 -0
  55. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/hermes_provider/README.md +0 -0
  56. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/hermes_provider/__init__.py +0 -0
  57. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
  58. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/hermes_provider/_provider.py +0 -0
  59. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/hermes_provider/_shared.py +0 -0
  60. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
  61. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/hermes_runtime.py +0 -0
  62. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/host_events.py +0 -0
  63. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/host_runtime.py +0 -0
  64. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/inbox.py +0 -0
  65. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/index.py +0 -0
  66. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/inspection.py +0 -0
  67. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/installer.py +0 -0
  68. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/llm/__init__.py +0 -0
  69. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/llm/base.py +0 -0
  70. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/llm/claude_compatible.py +0 -0
  71. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/llm/gemini.py +0 -0
  72. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/llm/openai_compatible.py +0 -0
  73. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/llm/router.py +0 -0
  74. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/llm/thinking.py +0 -0
  75. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/locking.py +0 -0
  76. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/mcp_server.py +0 -0
  77. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/memory_commit.py +0 -0
  78. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/memory_planner.py +0 -0
  79. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/memory_writer.py +0 -0
  80. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/model_capabilities.py +0 -0
  81. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/model_discovery.py +0 -0
  82. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/models.py +0 -0
  83. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/native_index.py +0 -0
  84. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/native_registration.py +0 -0
  85. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/parallel_model.py +0 -0
  86. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/planning_context.py +0 -0
  87. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/process_jobs.py +0 -0
  88. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/process_journal.py +0 -0
  89. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/process_owner.py +0 -0
  90. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/processing.py +0 -0
  91. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/prompts.py +0 -0
  92. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/provenance.py +0 -0
  93. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/recording_policy.py +0 -0
  94. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/redaction.py +0 -0
  95. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/retention.py +0 -0
  96. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/retrieval.py +0 -0
  97. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/retrieval_gate.py +0 -0
  98. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/scope_maintenance.py +0 -0
  99. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/scope_state.py +0 -0
  100. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/service.py +0 -0
  101. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/source_policy.py +0 -0
  102. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/state_layout.py +0 -0
  103. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/subprocess_flags.py +0 -0
  104. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/summary_batch.py +0 -0
  105. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/target_reconciliation.py +0 -0
  106. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/turn_audit.py +0 -0
  107. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/turn_plan.py +0 -0
  108. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/update_coordinator.py +0 -0
  109. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/update_review.py +0 -0
  110. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf/vault.py +0 -0
  111. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf.egg-info/SOURCES.txt +0 -0
  112. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf.egg-info/dependency_links.txt +0 -0
  113. {memleaf-0.2.53 → memleaf-0.2.55}/src/memleaf.egg-info/entry_points.txt +0 -0
  114. {memleaf-0.2.53 → memleaf-0.2.55}/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.55 — 2026-09-14
6
+
7
+ - Classify memory candidates by future reuse with a shorter B3 instruction: point-in-time counts and snapshots without a trend, threshold, obligation, decision or later comparison are `no_future_value`, while future-use facts remain eligible. Project ownership now has a conservative Core guard: an explicit single-project label cannot silently persist as `global`, but ordinary platform, product and vendor mentions still do not establish ownership. Self-contained memories refer to the conversation person as the user rather than as an owner. The B3 system prompt shrinks from 6395 to 5686 UTF-8 bytes.
8
+ - Preserve candidate-local date authority while improving structured todo deadlines. A legal `whole_unit` claim continues through the existing binding validator as one canonical full-text quote; no second evidence path was added. Core fills `due_date` only when that candidate's own admitted user/assistant evidence establishes one unambiguous deadline, never from sibling evidence, and exposes ambiguous deadline counts in total, stage and operation metrics instead of guessing.
9
+ - Normalize compact date-time text without corrupting adjacent content: a resolved ISO date next to a valid clock gains one separating space, existing spacing remains unchanged, and non-date words such as `本周日报` remain ordinary text. Deadline recognition uses boundary-safe English cues, accepts slash dates before sentence punctuation, and rejects word-suffix and decimal false positives.
10
+
11
+ Verification for this release used eight local synthetic regression tests with no model call and no production Vault write. They cover whole-unit/exact-quote equivalence, candidate date isolation, date-time boundaries, future-value protocol outcomes, explicit-project/global conflicts, platform-only mentions, unique and ambiguous todo deadlines, deadline word boundaries and metric visibility. Python 3.11 compilation and imports pass, as does `git diff --check`. The repository intentionally ignores `/tests/`, so these local tests are not part of the source distribution; real-model adherence and a newly installed Hermes replay remain post-release runtime acceptance.
12
+
13
+ ## 0.2.54 — 2026-09-14
14
+
15
+ - 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.
16
+ - 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`.
17
+ - 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.
18
+
19
+ 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.
20
+
5
21
  ## 0.2.53 — 2026-09-13
6
22
 
7
23
  - 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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.53
3
+ Version: 0.2.55
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.53。**
26
+ > **版本:0.2.55。**
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.53.**
7
+ > **Version: 0.2.55.**
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.53。**
7
+ > **版本:0.2.55。**
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.53"
7
+ version = "0.2.55"
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.53"
3
+ __version__ = "0.2.55"
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.53
2
+ version: 0.2.55
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -56,7 +56,7 @@ _METRIC_EVENT_FIELDS = frozenset({
56
56
  # first two were emitted but silently dropped here, which made the
57
57
  # "visible rather than silent" claim in their release notes untrue.
58
58
  "b3_normalization_count", "b3_candidate_deferred_count",
59
- "b3_ungrounded_scope_dropped_count",
59
+ "b3_ungrounded_scope_dropped_count", "b3_due_date_ambiguous_count",
60
60
  })
61
61
  _THINKING_OBSERVATION_SOURCES = frozenset({
62
62
  "request_parameter", "reasoning_tokens", "reasoning_content", "unavailable",
@@ -91,6 +91,7 @@ def _metric_bucket() -> dict[str, Any]:
91
91
  "b3_normalization_count": 0,
92
92
  "b3_candidate_deferred_count": 0,
93
93
  "b3_ungrounded_scope_dropped_count": 0,
94
+ "b3_due_date_ambiguous_count": 0,
94
95
  "_first_started": None,
95
96
  "_last_finished": None,
96
97
  }
@@ -515,6 +516,7 @@ class ModelExecutor:
515
516
  "repair_rejected_semantic_drift_count": int(bucket.get("repair_rejected_semantic_drift_count", 0)),
516
517
  "parse_accepted_count": int(bucket.get("parse_accepted_count", 0)),
517
518
  "decision_case_normalization_count": int(bucket.get("decision_case_normalization_count", 0)),
519
+ "b3_due_date_ambiguous_count": int(bucket.get("b3_due_date_ambiguous_count", 0)),
518
520
  }
519
521
  for field in _PROVIDER_METRIC_FIELDS:
520
522
  result[field] = int(bucket.get(field, 0))
@@ -16,6 +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 .scope_state import project_scope_matches_text
19
20
  from .validation import MODEL_EVIDENCE_CHECKS, MODEL_VALIDATION_DETAILS, ModelOutputError, parse_strict_json, normalize_relative_calendar_text, safe_evidence_context, safe_schema_context
20
21
 
21
22
  _PROCESSING_LEASE_SECONDS = 3600
@@ -73,6 +74,26 @@ _SUMMARY_CHINESE_CALENDAR_DATE_RE = re.compile(
73
74
  r"(?![A-Za-z\d])"
74
75
  )
75
76
 
77
+ _SLASH_CALENDAR_DATE_RE = re.compile(
78
+ r"(?<![\d./-])(?:(?P<year>\d{4})/)?(?P<month>\d{1,2})/(?P<day>\d{1,2})(?![\d/-]|\.\d)"
79
+ )
80
+ _DEADLINE_PREFIX_RE = re.compile(
81
+ r"(?:截止(?:日期|时间)?|截至(?:日期|时间)?|不晚于|不得晚于|\bdeadline\b|\bdue(?:\s+date)?\b|\bby\b|\bbefore\b)"
82
+ r"\s*(?:为|是|[::])?\s*$",
83
+ re.IGNORECASE,
84
+ )
85
+ _DEADLINE_SUFFIX_RE = re.compile(
86
+ r"^\s*(?:(?:[01]?\d|2[0-3]):[0-5]\d\s*)?(?:前|之前|为止|截止|截至|不晚于|不得晚于|before\b|deadline\b|due\b)",
87
+ re.IGNORECASE,
88
+ )
89
+ _EXPLICIT_PROJECT_LABEL_RE = re.compile(
90
+ r"(?<![A-Za-z0-9_])project\s*:\s*(?P<colon>[^\s,,;;。!?::]{1,64})"
91
+ r"|(?<![A-Za-z0-9_])project\s+(?P<english>[^\s,,;;。!?::]{1,64})\s*[::—–-]"
92
+ r"|(?:^|[\s((\[{,,])项目\s*[::]\s*(?P<chinese>[^\s,,;;。!?::]{1,64})"
93
+ r"|(?:^|[\s((\[{,,])(?P<suffix>[^\s,,;;。!?::]{1,64})\s*项目\s*[::]",
94
+ re.IGNORECASE,
95
+ )
96
+
76
97
 
77
98
 
78
99
 
@@ -141,6 +162,71 @@ def _project_scope_occurrences(
141
162
  return selected
142
163
 
143
164
 
165
+ def _explicit_project_scope_labels(
166
+ texts: Iterable[Any],
167
+ scope_registry: Mapping[str, Any] | None,
168
+ ) -> list[str]:
169
+ """Find project scopes explicitly written as project labels in candidate text.
170
+
171
+ A registered project name appearing as an ordinary noun is insufficient;
172
+ this recognizes only explicit forms such as ``project:Alpha`` or
173
+ ``Alpha 项目``. It supports a conservative global-scope guard without
174
+ treating a platform, product, or vendor mention as ownership.
175
+ """
176
+
177
+ rows = [text for text in texts if isinstance(text, str) and text.strip()]
178
+ if not rows:
179
+ return []
180
+ registry = scope_registry if isinstance(scope_registry, Mapping) else {}
181
+ try:
182
+ matches = {
183
+ scope
184
+ for text in rows
185
+ for scope in project_scope_matches_text(text, {"scopes": registry})
186
+ }
187
+ except (TypeError, ValueError):
188
+ return []
189
+ labeled: set[str] = set()
190
+ for scope in matches:
191
+ node = registry.get(scope)
192
+ terms = [scope.partition(":")[2]]
193
+ if isinstance(node, Mapping) and isinstance(node.get("aliases"), list):
194
+ terms.extend(value for value in node["aliases"] if isinstance(value, str))
195
+ for text in rows:
196
+ for raw_term in terms:
197
+ term = re.sub(r"\\\s+", r"\\s+", re.escape(raw_term.strip()))
198
+ if not term:
199
+ continue
200
+ patterns = (
201
+ rf"(?<![A-Za-z0-9_])project\s*:\s*{term}(?![A-Za-z0-9_])",
202
+ rf"(?<![A-Za-z0-9_])project\s+{term}(?![A-Za-z0-9_])",
203
+ rf"(?:^|[\s((\[,,::])项目\s*[::]?\s*{term}(?=$|[\s,。,::\-—])",
204
+ rf"(?:^|[\s((\[,,::]){term}\s*(?:项目|project)(?=$|[\s,。,::\-—])",
205
+ )
206
+ if any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns):
207
+ labeled.add(scope)
208
+ break
209
+ if scope in labeled:
210
+ break
211
+ for text in rows:
212
+ for match in _EXPLICIT_PROJECT_LABEL_RE.finditer(text):
213
+ raw_name = next((value for value in match.groupdict().values() if value), "")
214
+ if not raw_name:
215
+ continue
216
+ try:
217
+ resolved = project_scope_matches_text(raw_name, {"scopes": registry})
218
+ except (TypeError, ValueError):
219
+ resolved = []
220
+ if len(resolved) == 1:
221
+ labeled.add(resolved[0])
222
+ else:
223
+ # An explicit but unregistered project label is still enough
224
+ # to reject a silent global attribution; the candidate is
225
+ # deferred because Core must not invent the project's scope.
226
+ labeled.add("project-label:" + raw_name.casefold())
227
+ return sorted(labeled, key=str.casefold)
228
+
229
+
144
230
  def _automatic_read_only_query(events: Iterable[Mapping[str, Any]]) -> bool:
145
231
  return read_only_turn(analyze_turn_evidence(events))
146
232
 
@@ -867,6 +953,72 @@ def _grounded_due_dates(
867
953
  return result
868
954
 
869
955
 
956
+ def _grounded_deadline_dates(
957
+ evidence_events: Iterable[Mapping[str, Any]],
958
+ ) -> set[str]:
959
+ """Return only dates explicitly tied to a deadline in this evidence set.
960
+
961
+ Relative dates use the cited event's own timestamp. Yearless calendar dates
962
+ use that same timestamp only when one is available. No other turn content
963
+ or retrieval timestamp participates.
964
+ """
965
+
966
+ result: set[str] = set()
967
+ for event in evidence_events:
968
+ if not isinstance(event, Mapping) or event.get("role") not in {"user", "assistant"}:
969
+ continue
970
+ content = event.get("content")
971
+ if not isinstance(content, str) or not content:
972
+ continue
973
+ timestamp = _parse_time(event.get("timestamp"))
974
+ text = normalize_relative_calendar_text(content, timestamp) if timestamp is not None else content
975
+ if text is None:
976
+ text = content
977
+ tokens: list[tuple[int, int, str]] = []
978
+
979
+ for match in _ISO_CALENDAR_DATE_RE.finditer(text):
980
+ try:
981
+ canonical = datetime.strptime(match.group(1), "%Y-%m-%d").date().isoformat()
982
+ except ValueError:
983
+ continue
984
+ tokens.append((match.start(), match.end(), canonical))
985
+
986
+ for match in _SUMMARY_CHINESE_CALENDAR_DATE_RE.finditer(text):
987
+ try:
988
+ year = int(match.group("year")) if match.group("year") else (
989
+ timestamp.year if timestamp is not None else None
990
+ )
991
+ if year is None:
992
+ continue
993
+ canonical = datetime(
994
+ year, int(match.group("month")), int(match.group("day")), tzinfo=timezone.utc
995
+ ).date().isoformat()
996
+ except ValueError:
997
+ continue
998
+ tokens.append((match.start(), match.end(), canonical))
999
+
1000
+ for match in _SLASH_CALENDAR_DATE_RE.finditer(text):
1001
+ try:
1002
+ year = int(match.group("year")) if match.group("year") else (
1003
+ timestamp.year if timestamp is not None else None
1004
+ )
1005
+ if year is None:
1006
+ continue
1007
+ canonical = datetime(
1008
+ year, int(match.group("month")), int(match.group("day")), tzinfo=timezone.utc
1009
+ ).date().isoformat()
1010
+ except ValueError:
1011
+ continue
1012
+ tokens.append((match.start(), match.end(), canonical))
1013
+
1014
+ for start, end, canonical in tokens:
1015
+ prefix = text[max(0, start - 32):start]
1016
+ suffix = text[end:end + 24]
1017
+ if _DEADLINE_PREFIX_RE.search(prefix) or _DEADLINE_SUFFIX_RE.search(suffix):
1018
+ result.add(canonical)
1019
+ return result
1020
+
1021
+
870
1022
  def _native_result(value: Any) -> list[dict[str, Any]]:
871
1023
  if value is None:
872
1024
  return []
@@ -24,6 +24,8 @@ from .process_common import (
24
24
  ProcessingError,
25
25
  _automatic_create_conflicts,
26
26
  _event_payload,
27
+ _explicit_project_scope_labels,
28
+ _grounded_deadline_dates,
27
29
  _grounded_due_dates,
28
30
  _normalize_summary_dates,
29
31
  _summary_date_grounding_violations,
@@ -53,6 +55,87 @@ def _drop_ungrounded_project_scopes(scopes: Iterable[Any]) -> list[str]:
53
55
  return kept or ["global"]
54
56
 
55
57
 
58
+ def _claim_date_evidence(
59
+ claims: Iterable[Mapping[str, Any]],
60
+ by_unit: Mapping[str, Any],
61
+ events: Iterable[Mapping[str, Any]],
62
+ ) -> list[dict[str, Any]]:
63
+ """Project dates from this candidate's canonical validated exact quotes only."""
64
+
65
+ event_by_key = {
66
+ event.get("event_key"): event
67
+ for event in events
68
+ if isinstance(event, Mapping) and isinstance(event.get("event_key"), str)
69
+ }
70
+ result: list[dict[str, Any]] = []
71
+ seen: set[tuple[str, str]] = set()
72
+ for claim in claims:
73
+ if not isinstance(claim, Mapping):
74
+ continue
75
+ unit_id = claim.get("unit_id")
76
+ quote = claim.get("quote")
77
+ unit = by_unit.get(unit_id) if isinstance(unit_id, str) else None
78
+ if not isinstance(quote, str) or not quote or unit is None:
79
+ continue
80
+ event = event_by_key.get(getattr(unit, "event_key", None))
81
+ if event is None or getattr(unit, "source_role", None) not in {"user", "assistant"}:
82
+ continue
83
+ identity = (unit_id, quote)
84
+ if identity in seen:
85
+ continue
86
+ seen.add(identity)
87
+ result.append({
88
+ "unit_id": unit_id,
89
+ "event_key": getattr(unit, "event_key", None),
90
+ "role": getattr(unit, "source_role", None),
91
+ "timestamp": event.get("timestamp"),
92
+ "content": quote,
93
+ })
94
+ return result
95
+
96
+
97
+ def _fill_unambiguous_todo_deadline(
98
+ memory_type: Any,
99
+ summary: Mapping[str, Any],
100
+ deadline_dates: Iterable[str],
101
+ ) -> tuple[dict[str, Any], bool]:
102
+ """Fill an omitted todo date only when candidate evidence proves one deadline."""
103
+
104
+ normalized = dict(summary)
105
+ dates = {value for value in deadline_dates if isinstance(value, str)}
106
+ if memory_type != "todo" or normalized.get("due_date") is not None:
107
+ return normalized, False
108
+ if len(dates) == 1:
109
+ normalized["due_date"] = next(iter(dates))
110
+ return normalized, False
111
+ return normalized, len(dates) > 1
112
+
113
+
114
+ def _global_scope_conflicts_with_candidate_evidence(
115
+ scopes: Iterable[Any],
116
+ candidate_evidence: Iterable[Mapping[str, Any]],
117
+ by_unit: Mapping[str, Any],
118
+ scope_registry: Mapping[str, Any] | None,
119
+ ) -> bool:
120
+ """Detect a global choice contradicted by one explicit project label."""
121
+
122
+ scope_values = {value.casefold() for value in scopes if isinstance(value, str)}
123
+ if "global" not in scope_values or any(value.startswith("project:") for value in scope_values):
124
+ return False
125
+ texts: list[str] = []
126
+ for event in candidate_evidence:
127
+ if not isinstance(event, Mapping):
128
+ continue
129
+ unit = by_unit.get(event.get("unit_id"))
130
+ section_path = getattr(unit, "section_path", ())
131
+ if isinstance(section_path, (list, tuple)):
132
+ texts.extend(value for value in section_path if isinstance(value, str))
133
+ content = event.get("content")
134
+ if isinstance(content, str):
135
+ texts.append(content)
136
+ return len(_explicit_project_scope_labels(texts, scope_registry)) == 1
137
+
138
+
56
139
  class SinglePassMemoryPlanner(MemoryPlanner):
57
140
  """One semantic model call for an ordinary automatic turn."""
58
141
 
@@ -350,6 +433,7 @@ class SinglePassMemoryPlanner(MemoryPlanner):
350
433
  no_memory_by_unit: dict[str, str] = {}
351
434
  observed_scopes: list[str] = []
352
435
  requests: list[dict[str, Any]] = []
436
+ ambiguous_todo_deadlines: set[str] = set()
353
437
 
354
438
  if not planning_units:
355
439
  self._finalize_evidence_audit(
@@ -443,6 +527,18 @@ class SinglePassMemoryPlanner(MemoryPlanner):
443
527
  }
444
528
  if decision == "UPDATE" and isinstance(target_id, str):
445
529
  candidate["update_memory_id"] = target_id
530
+
531
+ candidate_evidence = _claim_date_evidence(claims, by_unit, events)
532
+ if _global_scope_conflicts_with_candidate_evidence(
533
+ scopes, candidate_evidence, by_unit, validation_scope_registry
534
+ ):
535
+ # The evidence explicitly labels one project, but Core cannot
536
+ # safely rewrite the model's chosen scope. Defer only this
537
+ # candidate rather than persist it globally.
538
+ raise ModelOutputError(
539
+ "global scope conflicts with an explicit project label",
540
+ validation_detail="scope_not_grounded",
541
+ )
446
542
  if decision in {"CREATE", "UPDATE"} and not _model_project_scope_is_source_grounded(
447
543
  candidate,
448
544
  planning_units,
@@ -500,7 +596,10 @@ class SinglePassMemoryPlanner(MemoryPlanner):
500
596
  event["event_key"] for event in admitted_events
501
597
  if isinstance(event, Mapping) and isinstance(event.get("event_key"), str)
502
598
  ))
503
- grounded_dates = _grounded_due_dates(turn, evidence_events=admitted_events)
599
+ candidate_date_evidence = candidate_evidence
600
+ grounded_dates = _grounded_due_dates(turn, evidence_events=candidate_date_evidence)
601
+ deadline_dates = _grounded_deadline_dates(candidate_date_evidence)
602
+ grounded_dates.update(deadline_dates)
504
603
  summary = dict(proposed)
505
604
  if decision == "UPDATE" and target_memory is not None:
506
605
  summary.setdefault("title", target_memory.title)
@@ -512,6 +611,11 @@ class SinglePassMemoryPlanner(MemoryPlanner):
512
611
  summary["update_memory_id"] = target_memory.memory_id
513
612
  else:
514
613
  summary.setdefault("tags", [])
614
+ summary, deadline_is_ambiguous = _fill_unambiguous_todo_deadline(
615
+ memory_type, summary, deadline_dates
616
+ )
617
+ if deadline_is_ambiguous:
618
+ ambiguous_todo_deadlines.add(candidate_id)
515
619
  summary["type"] = memory_type
516
620
  summary["scopes"] = scopes
517
621
  if isinstance(scope_source, str) and scope_source:
@@ -535,7 +639,7 @@ class SinglePassMemoryPlanner(MemoryPlanner):
535
639
  expected_target_type=target_memory.type if target_memory is not None else None,
536
640
  expected_scopes=scopes,
537
641
  expected_scope_source=scope_source if isinstance(scope_source, str) else None,
538
- allowed_due_dates=grounded_dates,
642
+ allowed_due_dates=(deadline_dates or grounded_dates),
539
643
  allow_no_change=False,
540
644
  allow_update_target=target_memory is not None,
541
645
  )
@@ -543,8 +647,7 @@ class SinglePassMemoryPlanner(MemoryPlanner):
543
647
  parsed,
544
648
  grounded_dates=grounded_dates,
545
649
  source_texts=[
546
- event.get("content", "") for event in admitted_events
547
- if isinstance(event, Mapping) and event.get("role") in {"user", "assistant"}
650
+ event.get("content", "") for event in candidate_date_evidence
548
651
  ],
549
652
  preserved_texts=(
550
653
  target_memory.title,
@@ -581,6 +684,15 @@ class SinglePassMemoryPlanner(MemoryPlanner):
581
684
  },
582
685
  )
583
686
 
687
+ if ambiguous_todo_deadlines:
688
+ metric = getattr(self.model, "_record_metric_event", None)
689
+ if callable(metric):
690
+ for _ in ambiguous_todo_deadlines:
691
+ metric(
692
+ {"stage": "single_pass", "operation": "single_pass_primary"},
693
+ "b3_due_date_ambiguous_count",
694
+ )
695
+
584
696
  fallback_scopes, fallback_scope_source = self._fallback_scopes(scope_background)
585
697
  for row in result["items"]:
586
698
  candidate_id = row["candidate_id"]
@@ -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,20 +139,17 @@ 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 failures, one-time fallbacks, routine checks with no follow-up, and point-in-time counts or snapshots unless needed for a trend, threshold, obligation, decision or later comparison. Preserve future-use facts in final reports and keep independent topics separate. Preserve entity, condition, polarity, uncertainty, ownership, state and meaning-critical numbers/codes. Use self-contained wording; call the conversation person “the user” (用户), never “owner” (主人). 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. 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
- 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. 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
- 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.
149
+ Scope to one project only when this candidate's evidence identifies it as owner of the work, decision, state or deadline; use a name present in that evidence, whether or not registered. A platform, product or vendor mention alone is not ownership; defer when ownership is unclear. Use global for cross-project facts, standing preferences and environment-wide rules.
134
150
 
135
151
  DATES
136
- An evidence unit may carry an ISO-8601 UTC timestamp. Use it ONLY to resolve a relative, partial or yearless date that the unit's own text expresses; never borrow another unit's timestamp and never guess a missing year. The timestamp is an anchor, not content: never write its own date into a memory, and add no date the evidence text does not state. A date literal in a memory must appear in that memory's cited evidence, either verbatim or as the same month and day. Write due_date as YYYY-MM-DD. A todo declares status, and due_date only when its own evidence establishes a deadline: otherwise omit due_date, or set it to null when the deadline itself is still unresolved. Any memory carrying a date that no admitted evidence grounds is rejected and costs the whole turn, so defer instead of approximating.
152
+ An evidence unit may carry an ISO-8601 UTC timestamp. Use it ONLY to resolve a relative, partial or yearless date that the unit's own text expresses; never borrow another unit's timestamp and never guess a missing year. The timestamp is an anchor, not content: never write its own date into a memory, and add no date the evidence text does not state. A date literal in a memory must appear in that memory's cited evidence, either verbatim or as the same month and day. Write due_date as YYYY-MM-DD; a todo with one unambiguous evidence-grounded deadline must include it. Omit due_date when no deadline is stated or it cannot be resolved. Any memory carrying a date that no admitted evidence grounds is rejected and costs the whole turn, so defer instead of approximating.
137
153
 
138
154
  {B3_COMPACT_CONTRACT}"""
139
155
 
@@ -420,6 +436,71 @@ def _canonical_target(raw: Any, local_by_key: Mapping[str, Mapping[str, Any]]) -
420
436
  return target["memory_id"], target
421
437
 
422
438
 
439
+ def _target_anchor_sets(text: str) -> tuple[set[str], set[str]]:
440
+ normalized = unicodedata.normalize("NFKC", text).casefold()
441
+ without_dates = _TARGET_DATE_ANCHOR.sub(" ", normalized)
442
+ ascii_anchors = {
443
+ token for token in _TARGET_ASCII_ANCHOR.findall(without_dates)
444
+ if len(token) >= 3 or (token.isdigit() and len(token) >= 2)
445
+ }
446
+ cjk_anchors: set[str] = set()
447
+ for run in _TARGET_CJK_RUN.findall(normalized):
448
+ cjk_anchors.update(
449
+ run[index:index + 2]
450
+ for index in range(len(run) - 1)
451
+ if run[index:index + 2] not in _TARGET_GENERIC_CJK_BIGRAMS
452
+ )
453
+ return ascii_anchors, cjk_anchors
454
+
455
+
456
+ def _target_is_same_future_use(
457
+ target: Mapping[str, Any], claims: Iterable[Mapping[str, Any]],
458
+ ) -> bool:
459
+ """Check a NO_CHANGE target against this candidate's exact cited text."""
460
+
461
+ target_units = [
462
+ part.strip()
463
+ for value in (target.get("title"), target.get("body"))
464
+ if isinstance(value, str)
465
+ for part in _TARGET_SENTENCE_SPLIT.split(value)
466
+ if part.strip()
467
+ ]
468
+ for claim in claims:
469
+ quote = claim.get("quote")
470
+ if not isinstance(quote, str) or not quote.strip():
471
+ continue
472
+ for quote_unit in (part.strip() for part in _TARGET_SENTENCE_SPLIT.split(quote) if part.strip()):
473
+ quote_ascii, quote_cjk = _target_anchor_sets(quote_unit)
474
+ for target_unit in target_units:
475
+ target_ascii, target_cjk = _target_anchor_sets(target_unit)
476
+ shared_ascii = quote_ascii & target_ascii
477
+ shared_cjk = quote_cjk & target_cjk
478
+ shared_numbers = {token for token in shared_ascii if token.isdigit()}
479
+ shared_identifiers = {token for token in shared_ascii if not token.isdigit()}
480
+ strong_identifiers = {
481
+ token for token in shared_identifiers
482
+ if len(token) >= 6 or any(char.isdigit() for char in token)
483
+ }
484
+ if len(strong_identifiers) >= 2 or (
485
+ strong_identifiers and (shared_cjk or len(shared_identifiers) >= 2)
486
+ ):
487
+ return True
488
+ if any(len(token) >= 5 for token in shared_numbers) and (
489
+ shared_cjk or shared_identifiers
490
+ ):
491
+ return True
492
+ if len(shared_cjk) < 2:
493
+ continue
494
+ overlap = len(shared_cjk) / min(len(quote_cjk), len(target_cjk))
495
+ compact = "".join(
496
+ char for char in unicodedata.normalize("NFKC", quote_unit).casefold()
497
+ if char.isalnum()
498
+ )
499
+ if overlap >= 0.28 and compact not in _TARGET_GENERIC_CJK_PHRASES:
500
+ return True
501
+ return False
502
+
503
+
423
504
  MemoryValidator = Callable[
424
505
  [str, str, str | None, Mapping[str, Any] | None, Mapping[str, Any], list[dict[str, Any]], Mapping[str, Any]],
425
506
  Mapping[str, Any],
@@ -498,6 +579,8 @@ def parse_single_pass_output(
498
579
  binding_rows: list[dict[str, Any]] = []
499
580
  prepared: list[tuple[dict[str, Any], Mapping[str, Any] | None]] = []
500
581
  forced_defer: dict[str, str] = {}
582
+ broad_whole_unit_no_change: set[str] = set()
583
+ unit_by_id = {getattr(unit, "unit_id", None): unit for unit in source_units}
501
584
 
502
585
  for item_index, raw_item in enumerate(items):
503
586
  item_path = f"items[{item_index}]"
@@ -579,6 +662,16 @@ def parse_single_pass_output(
579
662
  path=f"{item_path}.evidence", rule="type", actual=claims,
580
663
  expected_type="array",
581
664
  )
665
+ if decision == "NO_CHANGE" and any(
666
+ isinstance(claim, Mapping)
667
+ and claim.get("whole_unit") is True
668
+ and len(getattr(unit_by_id.get(claim.get("unit_id")), "text", "")) > 512
669
+ for claim in claims
670
+ ):
671
+ # A whole long reply can contain many independent topics. It is
672
+ # insufficient proof that one selected old memory covers this
673
+ # candidate; ask for a narrow claim on a later bounded pass.
674
+ broad_whole_unit_no_change.add(candidate_key)
582
675
  binding_rows.append({"candidate_id": candidate_id, "claims": claims})
583
676
 
584
677
  target_record: Mapping[str, Any] | None = None
@@ -790,7 +883,27 @@ def parse_single_pass_output(
790
883
  normalized["target_memory_id"] = target_id
791
884
  normalized["memory"] = dict(validated)
792
885
  elif decision == "NO_CHANGE":
793
- normalized["target_memory_id"] = item["target_memory_id"]
886
+ if (
887
+ candidate_id.casefold() in broad_whole_unit_no_change
888
+ or not isinstance(target_record, Mapping)
889
+ or not _target_is_same_future_use(
890
+ target_record, evidence
891
+ )
892
+ ):
893
+ normalized = {
894
+ "candidate_id": candidate_id,
895
+ "decision": "DEFERRED",
896
+ "reason": "target_ambiguous",
897
+ "evidence": evidence,
898
+ }
899
+ if deferrals is not None:
900
+ deferrals.append({
901
+ "candidate_id": candidate_id,
902
+ "reason": "target_ambiguous",
903
+ "detail": "target_relevance_unproven",
904
+ })
905
+ else:
906
+ normalized["target_memory_id"] = item["target_memory_id"]
794
907
  else:
795
908
  normalized["reason"] = item["reason"]
796
909
  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
  )
@@ -364,6 +364,9 @@ _ISO_CALENDAR_DATE = re.compile(
364
364
  r"(?<![A-Za-z\d./-])\d{4}-(?:0?[1-9]|1[0-2])-(?:0?[1-9]|[12]\d|3[01])"
365
365
  r"(?![A-Za-z\d./-])"
366
366
  )
367
+ _ISO_DATE_FOLLOWED_BY_CLOCK = re.compile(
368
+ r"(?P<date>\d{4}-\d{2}-\d{2})(?=(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d)?(?!\d))"
369
+ )
367
370
  _EMPTY_ISO_DATE_PARENTHESIS = re.compile(
368
371
  r"(?P<date>\d{4}-(?:0?[1-9]|1[0-2])-(?:0?[1-9]|[12]\d|3[01]))"
369
372
  r"[ \t]*(?:\([ \t]*[))]|([ \t]*[))])"
@@ -605,6 +608,18 @@ def _normalize_relative_calendar_text(
605
608
  normalized = _RELATIVE_CALENDAR_EXPRESSION.sub(replace_token, text)
606
609
  if relative_replaced:
607
610
  normalized = _collapse_duplicate_calendar_dates(normalized)
611
+ # A relative date can be adjacent to a clock in compact source text (for
612
+ # example, "today10:30"). Keep the date and time as separate values after
613
+ # normalization, while leaving already-spaced dates unchanged.
614
+ def separate_date_from_clock(match: re.Match[str]) -> str:
615
+ value = match.group("date")
616
+ try:
617
+ datetime.strptime(value, "%Y-%m-%d")
618
+ except ValueError:
619
+ return match.group(0)
620
+ return value + " "
621
+
622
+ normalized = _ISO_DATE_FOLLOWED_BY_CLOCK.sub(separate_date_from_clock, normalized)
608
623
  normalized = _strip_empty_iso_date_parenthesis(normalized)
609
624
  return normalized, safe
610
625
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.53
3
+ Version: 0.2.55
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.53。**
26
+ > **版本:0.2.55。**
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