memleaf 0.2.2__tar.gz → 0.2.4__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 (102) hide show
  1. {memleaf-0.2.2 → memleaf-0.2.4}/CHANGELOG.md +16 -0
  2. {memleaf-0.2.2 → memleaf-0.2.4}/PKG-INFO +3 -3
  3. {memleaf-0.2.2 → memleaf-0.2.4}/README.en.md +2 -2
  4. {memleaf-0.2.2 → memleaf-0.2.4}/README.md +2 -2
  5. {memleaf-0.2.2 → memleaf-0.2.4}/pyproject.toml +1 -1
  6. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/hermes_provider/__init__.py +1 -0
  8. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  9. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/processing.py +21 -4
  10. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/prompts.py +35 -0
  11. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/validation.py +32 -0
  12. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf.egg-info/PKG-INFO +3 -3
  13. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_hermes_provider.py +38 -0
  14. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_pypi_install.py +1 -1
  15. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_b1.py +210 -2
  16. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_c1_mcp.py +1 -1
  17. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_c3_packaging.py +1 -1
  18. {memleaf-0.2.2 → memleaf-0.2.4}/IMPLEMENTATION_PLAN.md +0 -0
  19. {memleaf-0.2.2 → memleaf-0.2.4}/LICENSE +0 -0
  20. {memleaf-0.2.2 → memleaf-0.2.4}/MANIFEST.in +0 -0
  21. {memleaf-0.2.2 → memleaf-0.2.4}/RELEASE_CHECKLIST.md +0 -0
  22. {memleaf-0.2.2 → memleaf-0.2.4}/examples/README.md +0 -0
  23. {memleaf-0.2.2 → memleaf-0.2.4}/examples/basic_usage.py +0 -0
  24. {memleaf-0.2.2 → memleaf-0.2.4}/examples/mcp_stdio.ndjson +0 -0
  25. {memleaf-0.2.2 → memleaf-0.2.4}/install.ps1 +0 -0
  26. {memleaf-0.2.2 → memleaf-0.2.4}/install.sh +0 -0
  27. {memleaf-0.2.2 → memleaf-0.2.4}/setup.cfg +0 -0
  28. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/__main__.py +0 -0
  29. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/adapters/__init__.py +0 -0
  30. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/adapters/antigravity.py +0 -0
  31. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/adapters/base.py +0 -0
  32. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/adapters/codex.py +0 -0
  33. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/adapters/hermes.py +0 -0
  34. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/budget.py +0 -0
  35. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/capture.py +0 -0
  36. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/cli.py +0 -0
  37. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/compaction.py +0 -0
  38. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/config.py +0 -0
  39. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/credentials.py +0 -0
  40. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/frontmatter.py +0 -0
  41. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/hermes_provider/README.md +0 -0
  42. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/host_events.py +0 -0
  43. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/host_runtime.py +0 -0
  44. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/inbox.py +0 -0
  45. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/index.py +0 -0
  46. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/installer.py +0 -0
  47. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/llm/__init__.py +0 -0
  48. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/llm/base.py +0 -0
  49. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/llm/claude_compatible.py +0 -0
  50. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/llm/gemini.py +0 -0
  51. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/llm/openai_compatible.py +0 -0
  52. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/llm/router.py +0 -0
  53. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/locking.py +0 -0
  54. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/mcp_server.py +0 -0
  55. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/memory_writer.py +0 -0
  56. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/model_discovery.py +0 -0
  57. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/models.py +0 -0
  58. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/native_index.py +0 -0
  59. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/redaction.py +0 -0
  60. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/retrieval.py +0 -0
  61. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/retrieval_gate.py +0 -0
  62. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/scope_maintenance.py +0 -0
  63. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/scope_state.py +0 -0
  64. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/service.py +0 -0
  65. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf/vault.py +0 -0
  66. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf.egg-info/SOURCES.txt +0 -0
  67. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf.egg-info/dependency_links.txt +0 -0
  68. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf.egg-info/entry_points.txt +0 -0
  69. {memleaf-0.2.2 → memleaf-0.2.4}/src/memleaf.egg-info/top_level.txt +0 -0
  70. {memleaf-0.2.2 → memleaf-0.2.4}/tests/__init__.py +0 -0
  71. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_admission_noise.py +0 -0
  72. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_codex_install.py +0 -0
  73. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_codex_native_cli.py +0 -0
  74. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_context_budget.py +0 -0
  75. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_credential_safety.py +0 -0
  76. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_cross_host_acceptance.py +0 -0
  77. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_hermes_stdio_transport.py +0 -0
  78. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_host_events.py +0 -0
  79. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_host_runtime_contract.py +0 -0
  80. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_install.py +0 -0
  81. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_maintenance_v2.py +0 -0
  82. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_model_discovery.py +0 -0
  83. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_retrieval_gate.py +0 -0
  84. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_retrieval_v2.py +0 -0
  85. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_session_lineage.py +0 -0
  86. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_a.py +0 -0
  87. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_b2a.py +0 -0
  88. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_b2b.py +0 -0
  89. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_b3a_commit.py +0 -0
  90. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_b3a_contract.py +0 -0
  91. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_b3b_native_context.py +0 -0
  92. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_b3b_native_index.py +0 -0
  93. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_b3b_scope.py +0 -0
  94. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_b3c_retrieval.py +0 -0
  95. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  96. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.2 → memleaf-0.2.4}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.4 — 2026-09-01
6
+
7
+ - Give `relative_time` retries explicit, actionable correction instructions so
8
+ the summarizer recalculates one-off dates from evidence timestamps, removes
9
+ relative wording, and resolves conflicting parenthesized dates before the
10
+ strict validator accepts the memory.
11
+ - Preserve the `relative_time` validation detail across the Hermes provider
12
+ boundary instead of degrading it to `other_schema_violation` in diagnostics.
13
+
14
+ ## 0.2.3 — 2026-09-01
15
+
16
+ - Preserve capture event timestamps in extraction prompts and resolve one-off
17
+ relative calendar dates against the supporting event timestamp as
18
+ `YYYY-MM-DD`; reject unresolved relative dates with bounded retries while
19
+ retaining the inbox turn after repeated failure.
20
+
5
21
  ## 0.2.2 — 2026-09-01
6
22
 
7
23
  - Publish GitHub Release and PyPI from the same wheel and source distribution
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: A local-first Markdown memory core for AI agents
5
5
  Author: memleaf contributors
6
6
  License-Expression: MIT
@@ -23,8 +23,8 @@ Dynamic: license-file
23
23
 
24
24
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
25
25
 
26
- > **当前版本:0.2.2。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.2 通过 PyPI 分发。
26
+ > **当前版本:0.2.4。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.4 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -4,8 +4,8 @@
4
4
 
5
5
  [中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **Version: 0.2.2.**
8
- > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. memleaf 0.2.2 is distributed through PyPI.
7
+ > **Version: 0.2.4.**
8
+ > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. memleaf 0.2.4 is distributed through PyPI.
9
9
  > **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
10
10
 
11
11
  ## Project scope
@@ -4,8 +4,8 @@
4
4
 
5
5
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **当前版本:0.2.2。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.2 通过 PyPI 分发。
7
+ > **当前版本:0.2.4。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.4 通过 PyPI 分发。
9
9
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
10
10
 
11
11
  ## 项目定位
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memleaf"
7
- version = "0.2.2"
7
+ version = "0.2.4"
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.2"
3
+ __version__ = "0.2.4"
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
@@ -85,6 +85,7 @@ _MODEL_VALIDATION_DETAILS = frozenset(
85
85
  "reason_too_long",
86
86
  "source_shape",
87
87
  "todo_fields",
88
+ "relative_time",
88
89
  "other_schema_violation",
89
90
  }
90
91
  )
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.2
2
+ version: 0.2.4
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -30,7 +30,14 @@ from .locking import atomic_write_json, atomic_write_text, read_json
30
30
  from .memory_writer import MemoryWriter
31
31
  from .models import Memory, utc_now
32
32
  from .native_index import NativeIndexer
33
- from .prompts import JSON_CORRECTION, GATE_SYSTEM, SUMMARIZE_SYSTEM, gate_prompt, summarize_prompt
33
+ from .prompts import (
34
+ JSON_CORRECTION,
35
+ RELATIVE_TIME_CORRECTION,
36
+ GATE_SYSTEM,
37
+ SUMMARIZE_SYSTEM,
38
+ gate_prompt,
39
+ summarize_prompt,
40
+ )
34
41
  from .redaction import redact_text
35
42
  from .retrieval import candidate_matches_query, filter_by_scope, normalize_term
36
43
  from .scope_state import ScopeError, normalize_scopes, register_scope_nodes
@@ -282,6 +289,7 @@ def _event_payload(turn: InboxTurn) -> list[dict[str, Any]]:
282
289
  "event_key": event.event_key,
283
290
  "role": event.role,
284
291
  "turn_id": event.turn_id or "",
292
+ "timestamp": event.timestamp,
285
293
  "content": event.content,
286
294
  }
287
295
  for event in turn.events
@@ -462,6 +470,15 @@ class Processor:
462
470
  return reason
463
471
  return None
464
472
 
473
+ @staticmethod
474
+ def _correction_instruction(error: BaseException) -> Optional[str]:
475
+ hint = Processor._safe_correction_hint(error)
476
+ if hint == "relative_time":
477
+ return RELATIVE_TIME_CORRECTION
478
+ if hint is not None:
479
+ return f"Previous output violated: {hint}."
480
+ return None
481
+
465
482
  def _diagnostic_enabled(self) -> bool:
466
483
  try:
467
484
  config = self.service.vault.config()
@@ -619,10 +636,10 @@ class Processor:
619
636
  except Exception:
620
637
  pass
621
638
  if self._allows_next_json_attempt(error, attempt_count):
622
- hint = self._safe_correction_hint(error)
623
639
  correction_prompt = prompt + "\n\n" + JSON_CORRECTION
624
- if hint is not None:
625
- correction_prompt += f"\nPrevious output violated: {hint}."
640
+ instruction = self._correction_instruction(error)
641
+ if instruction is not None:
642
+ correction_prompt += f"\n{instruction}"
626
643
  continue
627
644
  raise
628
645
  try:
@@ -38,6 +38,16 @@ Resolve an indirect reference only to one supplied active memory; otherwise
38
38
  return no candidate or leave it unscoped for retry. Never copy a related
39
39
  diagnostic/tool failure without a separate user-confirmed business fact, project
40
40
  risk, or durable lesson.
41
+ Calendar dates are strict. Each event may include an ISO-8601 UTC timestamp.
42
+ For a one-off relative date in a candidate or its later summary, use the
43
+ timestamp of the evidence event that supports it as the calendar anchor and
44
+ emit an absolute YYYY-MM-DD date. Resolve today/tomorrow/yesterday,
45
+ 今天/明天/昨天, 本周X/这周X/下周X/上周X, and this/next/last weekday;
46
+ use Monday-Sunday calendar weeks for week expressions. Do not leave these
47
+ strong relative forms in a worthy memory, and never guess without a timestamp:
48
+ defer or omit a date-dependent candidate if its supporting event has no
49
+ timestamp. Recurring schedules such as 每天、每周三, or every Wednesday are
50
+ not one-off dates and may remain recurring.
41
51
  If supplied, a scope directory has only memory_id, title, type, and scopes for
42
52
  active memories in one inherited scope; it has no body, sources, or extra
43
53
  metadata and is not evidence. Use it only to choose an exact ID when the same
@@ -123,6 +133,15 @@ active memory; otherwise return no candidate or leave it unscoped for retry.
123
133
  For the same use, preserve still-valid facts, add confirmed progress, replace
124
134
  contradictions, and never reduce the memory to the latest operation or create a
125
135
  sibling with a new label.
136
+ Calendar dates are strict. Each event may include an ISO-8601 UTC timestamp.
137
+ For a one-off relative date in the summary title or body, use the timestamp of
138
+ the supporting evidence event as the calendar anchor and emit an absolute
139
+ YYYY-MM-DD date. Resolve today/tomorrow/yesterday,
140
+ 今天/明天/昨天, 本周X/这周X/下周X/上周X, and this/next/last weekday;
141
+ use Monday-Sunday calendar weeks for week expressions. Never leave these
142
+ strong relative forms in the summary, and never guess without a timestamp.
143
+ Recurring schedules such as 每天、每周三, or every Wednesday are not one-off
144
+ dates and may remain recurring.
126
145
  In automatic capture/process mode, a pure read-only query whose answer only
127
146
  restates a related active memory is not a new candidate. It must have been
128
147
  rejected by the gate; do not use duplicate_memory_id or update_memory_id to
@@ -176,6 +195,22 @@ JSON_CORRECTION = (
176
195
  )
177
196
 
178
197
 
198
+ RELATIVE_TIME_CORRECTION = (
199
+ "Previous output violated: relative_time. Re-read the evidence events and use "
200
+ "the timestamp of the event supporting each date as the anchor; recompute "
201
+ "each one-off relative date from that timestamp and the stated weekday "
202
+ "semantics. In the summary title and body, every one-off calendar date must "
203
+ "be written only as YYYY-MM-DD: remove forms such as today/tomorrow/yesterday, "
204
+ "今天/明天/昨天, 本周三/这周三/下周三/上周三, and this/next/last Wednesday. "
205
+ "If a relative weekday is followed by a parenthesized numeric date and they "
206
+ "conflict, trust the event timestamp plus the weekday meaning, replace the "
207
+ "numeric date with the computed YYYY-MM-DD, and remove the relative wording "
208
+ "and conflicting parenthetical date. Do not guess when no supporting timestamp "
209
+ "exists; omit or defer the date-dependent detail. Recurring schedules such as "
210
+ "每天、每周三, or every Wednesday are allowed. Return only the strict JSON object."
211
+ )
212
+
213
+
179
214
  COMPACT_SYSTEM = """You are memleaf's memory compactor. Return JSON only.
180
215
  Merge only the supplied low-priority memories when they express compatible
181
216
  information. Return an object with a memories array; [] is a safe no-op.
@@ -61,10 +61,20 @@ MODEL_VALIDATION_DETAILS = frozenset(
61
61
  "reason_too_long",
62
62
  "source_shape",
63
63
  "todo_fields",
64
+ "relative_time",
64
65
  "other_schema_violation",
65
66
  )
66
67
  )
67
68
  _SCOPE_NAME = re.compile(r"^[^\s/\\:\x00\r\n]+$")
69
+ _RELATIVE_CALENDAR_EXPRESSION = re.compile(
70
+ r"(?:"
71
+ r"(?<![A-Za-z])(?:today|tomorrow|yesterday)(?![A-Za-z])"
72
+ r"|(?<![A-Za-z])(?:this|next|last)\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)(?![A-Za-z])"
73
+ r"|(?:本|这|下|上)(?:个)?(?:周|星期|礼拜)\s*(?:一|二|三|四|五|六|日|天|末|[1-7])"
74
+ r"|(?:今天|明天|昨天)"
75
+ r")",
76
+ re.IGNORECASE,
77
+ )
68
78
  _SOURCE_FIELDS = frozenset(("event_key", "session_id", "turn_id", "conversation_title", "evidence_event_ids"))
69
79
  _COMPACT_FIELDS = frozenset(
70
80
  (
@@ -167,6 +177,27 @@ def _string_list(
167
177
  return result
168
178
 
169
179
 
180
+ def _reject_relative_calendar_expression(item: Mapping[str, Any]) -> None:
181
+ """Reject unresolved one-off calendar expressions without parsing dates.
182
+
183
+ The event timestamp is supplied to the model in the prompt, where the
184
+ model can resolve the expression. This validator only catches the small
185
+ set of strong relative forms; recurring language such as 每周三 and
186
+ every Wednesday is intentionally outside the expression.
187
+ """
188
+
189
+ text = "\n".join(
190
+ value
191
+ for field in ("title", "body")
192
+ if isinstance(value := item.get(field), str)
193
+ )
194
+ if _RELATIVE_CALENDAR_EXPRESSION.search(text):
195
+ raise ModelOutputError(
196
+ "summary contains an unresolved relative calendar date",
197
+ validation_detail="relative_time",
198
+ )
199
+
200
+
170
201
  def _scopes(value: Any, scope_source: Any) -> list[str]:
171
202
  result = _string_list(value, "scopes", validation_detail="invalid_scope")
172
203
  for scope in result:
@@ -579,6 +610,7 @@ def validate_summarize_output(
579
610
  item = dict(value)
580
611
  _string(item["title"], "title")
581
612
  _string(item["body"], "body", multiline=True)
613
+ _reject_relative_calendar_expression(item)
582
614
  item["tags"] = _string_list(item["tags"], "tags")
583
615
  candidate_type = item["type"]
584
616
  if not isinstance(candidate_type, str) or candidate_type not in MEMORY_TYPES:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: A local-first Markdown memory core for AI agents
5
5
  Author: memleaf contributors
6
6
  License-Expression: MIT
@@ -23,8 +23,8 @@ Dynamic: license-file
23
23
 
24
24
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
25
25
 
26
- > **当前版本:0.2.2。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.2 通过 PyPI 分发。
26
+ > **当前版本:0.2.4。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.4 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -1717,6 +1717,44 @@ class HermesProviderTests(unittest.TestCase):
1717
1717
  self.assertIsNotNone(fields)
1718
1718
  self.assertEqual(fields[4], "invalid_update_target")
1719
1719
 
1720
+ def test_relative_time_validation_detail_is_preserved_and_logged(self) -> None:
1721
+ value = {
1722
+ "isError": True,
1723
+ "structuredContent": {
1724
+ "error": {
1725
+ "code": "model_invalid_response",
1726
+ "stage": "summarize",
1727
+ "validation_reason": "schema_violation",
1728
+ "validation_detail": "relative_time",
1729
+ "attempt_count": 3,
1730
+ }
1731
+ },
1732
+ }
1733
+ fields = provider_module._mcp_error_fields(value)
1734
+ self.assertIsNotNone(fields)
1735
+ self.assertEqual(fields[4], "relative_time")
1736
+ error = provider_module._MCPToolError(
1737
+ code="model_invalid_response",
1738
+ stage="summarize",
1739
+ validation_reason="schema_violation",
1740
+ validation_detail="relative_time",
1741
+ attempt_count=3,
1742
+ )
1743
+ self.assertEqual(error.validation_detail, "relative_time")
1744
+
1745
+ provider = self.provider(
1746
+ responses=[
1747
+ {"stored": True},
1748
+ {"stored": True},
1749
+ value,
1750
+ ]
1751
+ )
1752
+ with patch.object(provider_module.logger, "info") as info:
1753
+ provider.sync_turn("visible user", "visible assistant", session_id="relative-time-session")
1754
+ output = "\n".join(call.args[0] % call.args[1:] for call in info.call_args_list)
1755
+ self.assertIn("validation_detail=relative_time", output)
1756
+ self.assertIn("attempt_count=3", output)
1757
+
1720
1758
  def test_capture_failure_skips_process_and_does_not_escape_provider(self) -> None:
1721
1759
  provider = self.provider(responses=[{"stored": True}, RuntimeError("capture unavailable")])
1722
1760
  with patch.object(provider_module.logger, "warning") as warning:
@@ -39,7 +39,7 @@ class PyPIInstallTests(unittest.TestCase):
39
39
 
40
40
  self.assertTrue(target.is_dir())
41
41
  self.assertFalse(target.is_symlink())
42
- self.assertIn("version: 0.2.2", (target / "plugin.yaml").read_text(encoding="utf-8"))
42
+ self.assertIn("version: 0.2.4", (target / "plugin.yaml").read_text(encoding="utf-8"))
43
43
 
44
44
  def test_windows_hermes_paths_follow_official_native_layout(self) -> None:
45
45
  with tempfile.TemporaryDirectory(prefix="memleaf-win-paths-") as temporary:
@@ -4,11 +4,18 @@ import tempfile
4
4
  import urllib.error
5
5
  import unittest
6
6
  from pathlib import Path
7
+ from unittest import mock
7
8
 
8
9
  from memleaf import Memleaf
9
10
  from memleaf.config import default_config, load_config, save_config
10
11
  from memleaf.inbox import complete_turns, parse_inbox
11
- from memleaf.prompts import GATE_SYSTEM, SUMMARIZE_SYSTEM, gate_prompt, summarize_prompt
12
+ from memleaf.prompts import (
13
+ GATE_SYSTEM,
14
+ RELATIVE_TIME_CORRECTION,
15
+ SUMMARIZE_SYSTEM,
16
+ gate_prompt,
17
+ summarize_prompt,
18
+ )
12
19
  from memleaf.validation import ModelOutputError
13
20
  from memleaf.index import event_key, turn_key
14
21
  from memleaf.llm import (
@@ -21,7 +28,7 @@ from memleaf.llm import (
21
28
  ModelUnavailable,
22
29
  OpenAICompatibleBackend,
23
30
  )
24
- from memleaf.processing import Processor
31
+ from memleaf.processing import Processor, _event_payload
25
32
  from memleaf.validation import parse_gate_output, parse_summarize_output
26
33
 
27
34
 
@@ -139,6 +146,207 @@ class StageB1Test(unittest.TestCase):
139
146
  self.assertTrue(turns[0].complete)
140
147
  self.assertEqual(complete_turns(self.service.vault), turns)
141
148
 
149
+ def test_event_timestamp_is_preserved_in_extraction_payload_and_prompts(self):
150
+ anchor = "2026-09-01T02:01:41Z"
151
+ with mock.patch("memleaf.capture._timestamp", return_value=anchor):
152
+ self.service.capture("codex", "calendar", "turn-1", "user", "Finish before Wednesday", event_id="calendar-user")
153
+ self.service.capture(
154
+ "codex",
155
+ "calendar",
156
+ "turn-1",
157
+ "assistant",
158
+ "I will track the deadline.",
159
+ event_id="calendar-assistant",
160
+ )
161
+
162
+ turn = parse_inbox(self.service.vault)[0]
163
+ events = _event_payload(turn)
164
+ self.assertEqual([event["timestamp"] for event in events], [anchor, anchor])
165
+ candidate = {
166
+ "candidate_id": "calendar-deadline",
167
+ "memory": "Finish before Wednesday",
168
+ "evidence_event_ids": [event_key("calendar-user")],
169
+ "duplicate": False,
170
+ "worth": True,
171
+ "type": "todo",
172
+ "scopes": ["global"],
173
+ "scope_source": "model",
174
+ }
175
+ gate_text = gate_prompt(events)
176
+ summary_text = summarize_prompt(candidate, events)
177
+ self.assertIn(anchor, gate_text)
178
+ self.assertIn(anchor, summary_text)
179
+ for prompt_system in (GATE_SYSTEM, SUMMARIZE_SYSTEM):
180
+ self.assertIn("ISO-8601 UTC timestamp", prompt_system)
181
+ self.assertIn("YYYY-MM-DD", prompt_system)
182
+ self.assertIn("本周X/这周X/下周X/上周X", prompt_system)
183
+ self.assertIn("every Wednesday", prompt_system)
184
+
185
+ def test_relative_summary_is_rejected_then_absolute_retry_is_committed(self):
186
+ anchor = "2026-09-01T02:01:41Z"
187
+ evidence_key = event_key("relative-retry-user")
188
+ gate = {
189
+ "candidates": [{
190
+ "candidate_id": "relative-deadline",
191
+ "memory": "Complete before Wednesday",
192
+ "evidence_event_ids": [evidence_key],
193
+ "duplicate": False,
194
+ "worth": True,
195
+ "type": "todo",
196
+ "scopes": ["global"],
197
+ "scope_source": "model",
198
+ }]
199
+ }
200
+
201
+ def summary(body):
202
+ return {
203
+ "title": "Project deadline",
204
+ "body": body,
205
+ "tags": ["deadline"],
206
+ "type": "todo",
207
+ "scopes": ["global"],
208
+ "scope_source": "model",
209
+ "sources": [{"event_key": evidence_key}],
210
+ "status": "active",
211
+ }
212
+
213
+ responses = [
214
+ json.dumps(gate),
215
+ json.dumps(summary("在本周三(9/3)前完成。")),
216
+ json.dumps(summary("在2026-09-02前完成。")),
217
+ ]
218
+ calls = []
219
+
220
+ def callback(prompt, **kwargs):
221
+ calls.append((prompt, kwargs["purpose"]))
222
+ return responses.pop(0)
223
+
224
+ with mock.patch("memleaf.capture._timestamp", return_value=anchor):
225
+ self.service.capture("codex", "relative-retry", "turn-1", "user", "Finish before Wednesday", event_id="relative-retry-user")
226
+ self.service.capture(
227
+ "codex",
228
+ "relative-retry",
229
+ "turn-1",
230
+ "assistant",
231
+ "The deadline is confirmed.",
232
+ event_id="relative-retry-assistant",
233
+ )
234
+
235
+ result = self.service.process(
236
+ source="codex",
237
+ session_id="relative-retry",
238
+ model=FakeBackend(callback, model="relative-retry"),
239
+ )
240
+ self.assertEqual(result["processed_turns"], 1)
241
+ self.assertEqual(len(calls), 3)
242
+ self.assertEqual([purpose for _, purpose in calls], ["gate", "summarize", "summarize"])
243
+ self.assertIn(anchor, calls[0][0])
244
+ self.assertIn(RELATIVE_TIME_CORRECTION, calls[2][0])
245
+ self.assertIn("event supporting each date as the anchor", calls[2][0])
246
+ self.assertIn("every one-off calendar date must be written only as YYYY-MM-DD", calls[2][0])
247
+ self.assertIn("parenthesized numeric date", calls[2][0])
248
+ self.assertIn("trust the event timestamp plus the weekday meaning", calls[2][0])
249
+ self.assertIn("Previous output violated: relative_time.", calls[2][0])
250
+ memory = self.service.read(result["memory_ids"][0])
251
+ self.assertIsNotNone(memory)
252
+ self.assertIn("2026-09-02", memory.body)
253
+ self.assertNotIn("本周三", memory.body)
254
+
255
+ def test_recurring_weekday_expression_is_not_rejected_as_relative_date(self):
256
+ for body in (
257
+ "每周三例会。",
258
+ "The recurring meeting is every Wednesday.",
259
+ "会议时间:2026-09-01(周二)16:30。",
260
+ ):
261
+ with self.subTest(body=body):
262
+ summary = {
263
+ "title": "Recurring meeting",
264
+ "body": body,
265
+ "tags": ["meeting"],
266
+ "type": "event",
267
+ "scopes": ["global"],
268
+ "sources": [{"event_key": "event-a"}],
269
+ }
270
+ parsed = parse_summarize_output(json.dumps(summary), current_event_keys=["event-a"])
271
+ self.assertEqual(parsed["body"], body)
272
+
273
+ def test_relative_summary_without_timestamp_is_rejected_and_only_title_body_are_scanned(self):
274
+ relative = {
275
+ "title": "截止今天",
276
+ "body": "完成项目交付。",
277
+ "tags": [],
278
+ "type": "project",
279
+ "scopes": ["global"],
280
+ "sources": [{"event_key": "event-a", "conversation_title": "今天的会话"}],
281
+ }
282
+ with self.assertRaises(ModelOutputError) as raised:
283
+ parse_summarize_output(json.dumps(relative), current_event_keys=["event-a"])
284
+ self.assertEqual(raised.exception.validation_detail, "relative_time")
285
+
286
+ recurring_metadata = dict(relative, title="交付安排", body="每周三例会。")
287
+ parsed = parse_summarize_output(json.dumps(recurring_metadata), current_event_keys=["event-a"])
288
+ self.assertEqual(parsed["sources"][0]["conversation_title"], "今天的会话")
289
+
290
+ def test_three_relative_summary_failures_keep_watermark_and_inbox(self):
291
+ anchor = "2026-09-01T02:01:41Z"
292
+ evidence_key = event_key("relative-failure-user")
293
+ gate = {
294
+ "candidates": [{
295
+ "candidate_id": "relative-failure",
296
+ "memory": "Complete before Wednesday",
297
+ "evidence_event_ids": [evidence_key],
298
+ "duplicate": False,
299
+ "worth": True,
300
+ "type": "todo",
301
+ "scopes": ["global"],
302
+ "scope_source": "model",
303
+ }]
304
+ }
305
+ relative_summary = {
306
+ "title": "Project deadline",
307
+ "body": "在本周三前完成。",
308
+ "tags": ["deadline"],
309
+ "type": "todo",
310
+ "scopes": ["global"],
311
+ "scope_source": "model",
312
+ "sources": [{"event_key": evidence_key}],
313
+ "status": "active",
314
+ }
315
+ responses = [json.dumps(gate)] + [json.dumps(relative_summary)] * 3
316
+ calls = []
317
+
318
+ def callback(prompt, **kwargs):
319
+ calls.append(kwargs["purpose"])
320
+ return responses.pop(0)
321
+
322
+ with mock.patch("memleaf.capture._timestamp", return_value=anchor):
323
+ self.service.capture("codex", "relative-failure", "turn-1", "user", "Finish before Wednesday", event_id="relative-failure-user")
324
+ self.service.capture(
325
+ "codex",
326
+ "relative-failure",
327
+ "turn-1",
328
+ "assistant",
329
+ "The deadline is confirmed.",
330
+ event_id="relative-failure-assistant",
331
+ )
332
+
333
+ with self.assertRaises(ModelOutputError) as raised:
334
+ self.service.process(
335
+ source="codex",
336
+ session_id="relative-failure",
337
+ model=FakeBackend(callback, model="relative-failure"),
338
+ )
339
+ self.assertEqual(raised.exception.validation_detail, "relative_time")
340
+ self.assertEqual(raised.exception.attempt_count, 3)
341
+ self.assertEqual(calls, ["gate", "summarize", "summarize", "summarize"])
342
+ processed = json.loads(self.service.vault.processed_index_path.read_text(encoding="utf-8"))
343
+ state = processed["sessions"]["codex/relative-failure"]
344
+ self.assertEqual(state.get("watermark", 0), 0)
345
+ self.assertEqual(state["processing"]["status"], "failed")
346
+ inbox = self.vault_path / "inbox" / "codex" / "relative-failure.md"
347
+ self.assertTrue(inbox.is_file())
348
+ self.assertIn("Finish before Wednesday", inbox.read_text(encoding="utf-8"))
349
+
142
350
  def test_process_retries_schema_violation_until_third_gate_attempt_and_commits(self):
143
351
  responses = [
144
352
  json.dumps({"candidates": "invalid"}),
@@ -104,7 +104,7 @@ class StageC1MCPTest(unittest.TestCase):
104
104
  testcase.assertEqual(result["resultType"], "complete")
105
105
  testcase.assertEqual(
106
106
  result["_meta"]["io.modelcontextprotocol/serverInfo"],
107
- {"name": "memleaf", "version": "0.2.2"},
107
+ {"name": "memleaf", "version": "0.2.4"},
108
108
  )
109
109
  return result
110
110
 
@@ -24,7 +24,7 @@ class StageC3PackagingTests(unittest.TestCase):
24
24
 
25
25
  def test_project_metadata_and_version_export(self) -> None:
26
26
  self.assertEqual(self.project["name"], "memleaf")
27
- self.assertEqual(self.project["version"], "0.2.2")
27
+ self.assertEqual(self.project["version"], "0.2.4")
28
28
  self.assertEqual(self.project["requires-python"], ">=3.11")
29
29
  self.assertEqual(self.project["dependencies"], [])
30
30
  self.assertEqual(self.project["license"], "MIT")
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
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