memleaf 0.2.1__tar.gz → 0.2.3__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.1 → memleaf-0.2.3}/CHANGELOG.md +17 -0
  2. {memleaf-0.2.1 → memleaf-0.2.3}/PKG-INFO +3 -3
  3. {memleaf-0.2.1 → memleaf-0.2.3}/README.en.md +2 -2
  4. {memleaf-0.2.1 → memleaf-0.2.3}/README.md +2 -2
  5. {memleaf-0.2.1 → memleaf-0.2.3}/pyproject.toml +1 -1
  6. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/processing.py +1 -0
  9. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/prompts.py +19 -0
  10. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/validation.py +32 -0
  11. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf.egg-info/PKG-INFO +3 -3
  12. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_pypi_install.py +1 -1
  13. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_a.py +4 -2
  14. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b1.py +194 -1
  15. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_c1_mcp.py +1 -1
  16. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_c3_packaging.py +37 -1
  17. {memleaf-0.2.1 → memleaf-0.2.3}/IMPLEMENTATION_PLAN.md +0 -0
  18. {memleaf-0.2.1 → memleaf-0.2.3}/LICENSE +0 -0
  19. {memleaf-0.2.1 → memleaf-0.2.3}/MANIFEST.in +0 -0
  20. {memleaf-0.2.1 → memleaf-0.2.3}/RELEASE_CHECKLIST.md +0 -0
  21. {memleaf-0.2.1 → memleaf-0.2.3}/examples/README.md +0 -0
  22. {memleaf-0.2.1 → memleaf-0.2.3}/examples/basic_usage.py +0 -0
  23. {memleaf-0.2.1 → memleaf-0.2.3}/examples/mcp_stdio.ndjson +0 -0
  24. {memleaf-0.2.1 → memleaf-0.2.3}/install.ps1 +0 -0
  25. {memleaf-0.2.1 → memleaf-0.2.3}/install.sh +0 -0
  26. {memleaf-0.2.1 → memleaf-0.2.3}/setup.cfg +0 -0
  27. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/__main__.py +0 -0
  28. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/adapters/__init__.py +0 -0
  29. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/adapters/antigravity.py +0 -0
  30. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/adapters/base.py +0 -0
  31. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/adapters/codex.py +0 -0
  32. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/adapters/hermes.py +0 -0
  33. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/budget.py +0 -0
  34. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/capture.py +0 -0
  35. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/cli.py +0 -0
  36. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/compaction.py +0 -0
  37. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/config.py +0 -0
  38. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/credentials.py +0 -0
  39. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/frontmatter.py +0 -0
  40. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/hermes_provider/README.md +0 -0
  41. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/hermes_provider/__init__.py +0 -0
  42. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/host_events.py +0 -0
  43. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/host_runtime.py +0 -0
  44. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/inbox.py +0 -0
  45. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/index.py +0 -0
  46. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/installer.py +0 -0
  47. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/llm/__init__.py +0 -0
  48. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/llm/base.py +0 -0
  49. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/llm/claude_compatible.py +0 -0
  50. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/llm/gemini.py +0 -0
  51. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/llm/openai_compatible.py +0 -0
  52. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/llm/router.py +0 -0
  53. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/locking.py +0 -0
  54. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/mcp_server.py +0 -0
  55. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/memory_writer.py +0 -0
  56. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/model_discovery.py +0 -0
  57. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/models.py +0 -0
  58. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/native_index.py +0 -0
  59. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/redaction.py +0 -0
  60. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/retrieval.py +0 -0
  61. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/retrieval_gate.py +0 -0
  62. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/scope_maintenance.py +0 -0
  63. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/scope_state.py +0 -0
  64. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/service.py +0 -0
  65. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/vault.py +0 -0
  66. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf.egg-info/SOURCES.txt +0 -0
  67. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf.egg-info/dependency_links.txt +0 -0
  68. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf.egg-info/entry_points.txt +0 -0
  69. {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf.egg-info/top_level.txt +0 -0
  70. {memleaf-0.2.1 → memleaf-0.2.3}/tests/__init__.py +0 -0
  71. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_admission_noise.py +0 -0
  72. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_codex_install.py +0 -0
  73. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_codex_native_cli.py +0 -0
  74. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_context_budget.py +0 -0
  75. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_credential_safety.py +0 -0
  76. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_cross_host_acceptance.py +0 -0
  77. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_hermes_provider.py +0 -0
  78. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_hermes_stdio_transport.py +0 -0
  79. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_host_events.py +0 -0
  80. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_host_runtime_contract.py +0 -0
  81. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_install.py +0 -0
  82. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_maintenance_v2.py +0 -0
  83. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_model_discovery.py +0 -0
  84. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_retrieval_gate.py +0 -0
  85. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_retrieval_v2.py +0 -0
  86. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_session_lineage.py +0 -0
  87. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b2a.py +0 -0
  88. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b2b.py +0 -0
  89. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3a_commit.py +0 -0
  90. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3a_contract.py +0 -0
  91. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3b_native_context.py +0 -0
  92. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3b_native_index.py +0 -0
  93. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3b_scope.py +0 -0
  94. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3c_retrieval.py +0 -0
  95. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  96. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.3 — 2026-09-01
6
+
7
+ - Preserve capture event timestamps in extraction prompts and resolve one-off
8
+ relative calendar dates against the supporting event timestamp as
9
+ `YYYY-MM-DD`; reject unresolved relative dates with bounded retries while
10
+ retaining the inbox turn after repeated failure.
11
+
12
+ ## 0.2.2 — 2026-09-01
13
+
14
+ - Publish GitHub Release and PyPI from the same wheel and source distribution
15
+ artifact verified by the CI build job; the PyPI workflow accepts only a
16
+ successful push from this repository's `main` branch.
17
+ - Make GitHub Release publication idempotent: an existing tag must resolve to
18
+ the current release commit before its three assets are replaced or completed.
19
+ - Normalize resolved paths in the partial-delete regression test so the
20
+ macOS `/var` and `/private/var` aliases exercise the intended failure path.
21
+
5
22
  ## 0.2.1 — 2026-08-31
6
23
 
7
24
  - Fixed the finalized `search → read` contract in both supported hosts: Codex `PostToolUse` and Hermes diagnostics now accept only `memory_id + title` search candidates, so a real `found` result no longer degrades to an error before `read`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.1
3
+ Version: 0.2.3
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.1。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.1 通过 PyPI 分发。
26
+ > **当前版本:0.2.3。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.3 通过 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.1.**
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.1 is distributed through PyPI.
7
+ > **Version: 0.2.3.**
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.3 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.1。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.1 通过 PyPI 分发。
7
+ > **当前版本:0.2.3。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.3 通过 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.1"
7
+ version = "0.2.3"
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.1"
3
+ __version__ = "0.2.3"
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.1
2
+ version: 0.2.3
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -282,6 +282,7 @@ def _event_payload(turn: InboxTurn) -> list[dict[str, Any]]:
282
282
  "event_key": event.event_key,
283
283
  "role": event.role,
284
284
  "turn_id": event.turn_id or "",
285
+ "timestamp": event.timestamp,
285
286
  "content": event.content,
286
287
  }
287
288
  for event in turn.events
@@ -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
@@ -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.1
3
+ Version: 0.2.3
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.1。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.1 通过 PyPI 分发。
26
+ > **当前版本:0.2.3。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.3 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -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.1", (target / "plugin.yaml").read_text(encoding="utf-8"))
42
+ self.assertIn("version: 0.2.3", (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:
@@ -306,13 +306,15 @@ class StageATest(unittest.TestCase):
306
306
  )
307
307
  active_path = self.vault_path / "knowledge" / f"{active.memory_id}.md"
308
308
  first_path = self.vault_path / "history" / f"{first_history.memory_id}.md"
309
- second_path = self.vault_path / "history" / f"{second_history.memory_id}.md"
309
+ second_path = (
310
+ self.vault_path / "history" / f"{second_history.memory_id}.md"
311
+ ).resolve()
310
312
  original_unlink = Path.unlink
311
313
  attempted: list[str] = []
312
314
 
313
315
  def flaky_unlink(path, *args, **kwargs):
314
316
  attempted.append(path.name)
315
- if path == second_path:
317
+ if path.resolve() == second_path:
316
318
  raise PermissionError("simulated history delete failure")
317
319
  return original_unlink(path, *args, **kwargs)
318
320
 
@@ -4,6 +4,7 @@ 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
@@ -21,7 +22,7 @@ from memleaf.llm import (
21
22
  ModelUnavailable,
22
23
  OpenAICompatibleBackend,
23
24
  )
24
- from memleaf.processing import Processor
25
+ from memleaf.processing import Processor, _event_payload
25
26
  from memleaf.validation import parse_gate_output, parse_summarize_output
26
27
 
27
28
 
@@ -139,6 +140,198 @@ class StageB1Test(unittest.TestCase):
139
140
  self.assertTrue(turns[0].complete)
140
141
  self.assertEqual(complete_turns(self.service.vault), turns)
141
142
 
143
+ def test_event_timestamp_is_preserved_in_extraction_payload_and_prompts(self):
144
+ anchor = "2026-09-01T02:01:41Z"
145
+ with mock.patch("memleaf.capture._timestamp", return_value=anchor):
146
+ self.service.capture("codex", "calendar", "turn-1", "user", "Finish before Wednesday", event_id="calendar-user")
147
+ self.service.capture(
148
+ "codex",
149
+ "calendar",
150
+ "turn-1",
151
+ "assistant",
152
+ "I will track the deadline.",
153
+ event_id="calendar-assistant",
154
+ )
155
+
156
+ turn = parse_inbox(self.service.vault)[0]
157
+ events = _event_payload(turn)
158
+ self.assertEqual([event["timestamp"] for event in events], [anchor, anchor])
159
+ candidate = {
160
+ "candidate_id": "calendar-deadline",
161
+ "memory": "Finish before Wednesday",
162
+ "evidence_event_ids": [event_key("calendar-user")],
163
+ "duplicate": False,
164
+ "worth": True,
165
+ "type": "todo",
166
+ "scopes": ["global"],
167
+ "scope_source": "model",
168
+ }
169
+ gate_text = gate_prompt(events)
170
+ summary_text = summarize_prompt(candidate, events)
171
+ self.assertIn(anchor, gate_text)
172
+ self.assertIn(anchor, summary_text)
173
+ for prompt_system in (GATE_SYSTEM, SUMMARIZE_SYSTEM):
174
+ self.assertIn("ISO-8601 UTC timestamp", prompt_system)
175
+ self.assertIn("YYYY-MM-DD", prompt_system)
176
+ self.assertIn("本周X/这周X/下周X/上周X", prompt_system)
177
+ self.assertIn("every Wednesday", prompt_system)
178
+
179
+ def test_relative_summary_is_rejected_then_absolute_retry_is_committed(self):
180
+ anchor = "2026-09-01T02:01:41Z"
181
+ evidence_key = event_key("relative-retry-user")
182
+ gate = {
183
+ "candidates": [{
184
+ "candidate_id": "relative-deadline",
185
+ "memory": "Complete before Wednesday",
186
+ "evidence_event_ids": [evidence_key],
187
+ "duplicate": False,
188
+ "worth": True,
189
+ "type": "todo",
190
+ "scopes": ["global"],
191
+ "scope_source": "model",
192
+ }]
193
+ }
194
+
195
+ def summary(body):
196
+ return {
197
+ "title": "Project deadline",
198
+ "body": body,
199
+ "tags": ["deadline"],
200
+ "type": "todo",
201
+ "scopes": ["global"],
202
+ "scope_source": "model",
203
+ "sources": [{"event_key": evidence_key}],
204
+ "status": "active",
205
+ }
206
+
207
+ responses = [json.dumps(gate), json.dumps(summary("在本周三前完成。")), json.dumps(summary("在2026-09-02前完成。"))]
208
+ calls = []
209
+
210
+ def callback(prompt, **kwargs):
211
+ calls.append((prompt, kwargs["purpose"]))
212
+ return responses.pop(0)
213
+
214
+ with mock.patch("memleaf.capture._timestamp", return_value=anchor):
215
+ self.service.capture("codex", "relative-retry", "turn-1", "user", "Finish before Wednesday", event_id="relative-retry-user")
216
+ self.service.capture(
217
+ "codex",
218
+ "relative-retry",
219
+ "turn-1",
220
+ "assistant",
221
+ "The deadline is confirmed.",
222
+ event_id="relative-retry-assistant",
223
+ )
224
+
225
+ result = self.service.process(
226
+ source="codex",
227
+ session_id="relative-retry",
228
+ model=FakeBackend(callback, model="relative-retry"),
229
+ )
230
+ self.assertEqual(result["processed_turns"], 1)
231
+ self.assertEqual(len(calls), 3)
232
+ self.assertEqual([purpose for _, purpose in calls], ["gate", "summarize", "summarize"])
233
+ self.assertIn(anchor, calls[0][0])
234
+ self.assertIn("Previous output violated: relative_time.", calls[2][0])
235
+ memory = self.service.read(result["memory_ids"][0])
236
+ self.assertIsNotNone(memory)
237
+ self.assertIn("2026-09-02", memory.body)
238
+ self.assertNotIn("本周三", memory.body)
239
+
240
+ def test_recurring_weekday_expression_is_not_rejected_as_relative_date(self):
241
+ for body in (
242
+ "每周三例会。",
243
+ "The recurring meeting is every Wednesday.",
244
+ "会议时间:2026-09-01(周二)16:30。",
245
+ ):
246
+ with self.subTest(body=body):
247
+ summary = {
248
+ "title": "Recurring meeting",
249
+ "body": body,
250
+ "tags": ["meeting"],
251
+ "type": "event",
252
+ "scopes": ["global"],
253
+ "sources": [{"event_key": "event-a"}],
254
+ }
255
+ parsed = parse_summarize_output(json.dumps(summary), current_event_keys=["event-a"])
256
+ self.assertEqual(parsed["body"], body)
257
+
258
+ def test_relative_summary_without_timestamp_is_rejected_and_only_title_body_are_scanned(self):
259
+ relative = {
260
+ "title": "截止今天",
261
+ "body": "完成项目交付。",
262
+ "tags": [],
263
+ "type": "project",
264
+ "scopes": ["global"],
265
+ "sources": [{"event_key": "event-a", "conversation_title": "今天的会话"}],
266
+ }
267
+ with self.assertRaises(ModelOutputError) as raised:
268
+ parse_summarize_output(json.dumps(relative), current_event_keys=["event-a"])
269
+ self.assertEqual(raised.exception.validation_detail, "relative_time")
270
+
271
+ recurring_metadata = dict(relative, title="交付安排", body="每周三例会。")
272
+ parsed = parse_summarize_output(json.dumps(recurring_metadata), current_event_keys=["event-a"])
273
+ self.assertEqual(parsed["sources"][0]["conversation_title"], "今天的会话")
274
+
275
+ def test_three_relative_summary_failures_keep_watermark_and_inbox(self):
276
+ anchor = "2026-09-01T02:01:41Z"
277
+ evidence_key = event_key("relative-failure-user")
278
+ gate = {
279
+ "candidates": [{
280
+ "candidate_id": "relative-failure",
281
+ "memory": "Complete before Wednesday",
282
+ "evidence_event_ids": [evidence_key],
283
+ "duplicate": False,
284
+ "worth": True,
285
+ "type": "todo",
286
+ "scopes": ["global"],
287
+ "scope_source": "model",
288
+ }]
289
+ }
290
+ relative_summary = {
291
+ "title": "Project deadline",
292
+ "body": "在本周三前完成。",
293
+ "tags": ["deadline"],
294
+ "type": "todo",
295
+ "scopes": ["global"],
296
+ "scope_source": "model",
297
+ "sources": [{"event_key": evidence_key}],
298
+ "status": "active",
299
+ }
300
+ responses = [json.dumps(gate)] + [json.dumps(relative_summary)] * 3
301
+ calls = []
302
+
303
+ def callback(prompt, **kwargs):
304
+ calls.append(kwargs["purpose"])
305
+ return responses.pop(0)
306
+
307
+ with mock.patch("memleaf.capture._timestamp", return_value=anchor):
308
+ self.service.capture("codex", "relative-failure", "turn-1", "user", "Finish before Wednesday", event_id="relative-failure-user")
309
+ self.service.capture(
310
+ "codex",
311
+ "relative-failure",
312
+ "turn-1",
313
+ "assistant",
314
+ "The deadline is confirmed.",
315
+ event_id="relative-failure-assistant",
316
+ )
317
+
318
+ with self.assertRaises(ModelOutputError) as raised:
319
+ self.service.process(
320
+ source="codex",
321
+ session_id="relative-failure",
322
+ model=FakeBackend(callback, model="relative-failure"),
323
+ )
324
+ self.assertEqual(raised.exception.validation_detail, "relative_time")
325
+ self.assertEqual(raised.exception.attempt_count, 3)
326
+ self.assertEqual(calls, ["gate", "summarize", "summarize", "summarize"])
327
+ processed = json.loads(self.service.vault.processed_index_path.read_text(encoding="utf-8"))
328
+ state = processed["sessions"]["codex/relative-failure"]
329
+ self.assertEqual(state.get("watermark", 0), 0)
330
+ self.assertEqual(state["processing"]["status"], "failed")
331
+ inbox = self.vault_path / "inbox" / "codex" / "relative-failure.md"
332
+ self.assertTrue(inbox.is_file())
333
+ self.assertIn("Finish before Wednesday", inbox.read_text(encoding="utf-8"))
334
+
142
335
  def test_process_retries_schema_violation_until_third_gate_attempt_and_commits(self):
143
336
  responses = [
144
337
  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.1"},
107
+ {"name": "memleaf", "version": "0.2.3"},
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.1")
27
+ self.assertEqual(self.project["version"], "0.2.3")
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")
@@ -121,6 +121,42 @@ class StageC3PackagingTests(unittest.TestCase):
121
121
  self.assertNotIn("/Users/", sample.read_text(encoding="utf-8"))
122
122
  self.assertNotIn("secret", sample.read_text(encoding="utf-8").casefold())
123
123
 
124
+ def test_pypi_workflow_uses_the_triggering_ci_artifact_without_rebuilding(self) -> None:
125
+ workflow_path = ROOT / ".github" / "workflows" / "release.yml"
126
+ if not workflow_path.is_file():
127
+ self.skipTest("repository workflows are not included in the source distribution")
128
+ workflow = workflow_path.read_text(encoding="utf-8")
129
+ self.assertNotIn("python -m build", workflow)
130
+ self.assertEqual(workflow.count("github.event.workflow_run.event == 'push'"), 2)
131
+ self.assertEqual(
132
+ workflow.count("github.event.workflow_run.head_repository.full_name == github.repository"),
133
+ 2,
134
+ )
135
+ self.assertIn("ref: ${{ github.event.workflow_run.head_sha }}", workflow)
136
+ self.assertIn("name: memleaf-distributions", workflow)
137
+ self.assertIn("github-token: ${{ github.token }}", workflow)
138
+ self.assertIn("repository: ${{ github.repository }}", workflow)
139
+ self.assertIn("run-id: ${{ github.event.workflow_run.id }}", workflow)
140
+ self.assertIn("actions: read", workflow)
141
+ self.assertIn("contents: read", workflow)
142
+ self.assertIn("id-token: write", workflow)
143
+
144
+ def test_github_release_update_validates_tag_target_and_clobbers_assets(self) -> None:
145
+ workflow_path = ROOT / ".github" / "workflows" / "ci.yml"
146
+ if not workflow_path.is_file():
147
+ self.skipTest("repository workflows are not included in the source distribution")
148
+ workflow = workflow_path.read_text(encoding="utf-8")
149
+ self.assertIn('gh release view "$TAG"', workflow)
150
+ self.assertIn('--json targetCommitish --jq \'.targetCommitish\'', workflow)
151
+ self.assertIn(
152
+ 'gh api "repos/${GITHUB_REPOSITORY}/commits/${TAG}" --jq \'.sha\'',
153
+ workflow,
154
+ )
155
+ self.assertIn('test "$target_sha" = "$RELEASE_SHA"', workflow)
156
+ self.assertIn("gh release upload", workflow)
157
+ self.assertIn("--clobber", workflow)
158
+ self.assertIn("dist/SHA256SUMS", workflow)
159
+
124
160
 
125
161
  if __name__ == "__main__":
126
162
  unittest.main()
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