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.
- {memleaf-0.2.1 → memleaf-0.2.3}/CHANGELOG.md +17 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/PKG-INFO +3 -3
- {memleaf-0.2.1 → memleaf-0.2.3}/README.en.md +2 -2
- {memleaf-0.2.1 → memleaf-0.2.3}/README.md +2 -2
- {memleaf-0.2.1 → memleaf-0.2.3}/pyproject.toml +1 -1
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/processing.py +1 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/prompts.py +19 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/validation.py +32 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf.egg-info/PKG-INFO +3 -3
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_pypi_install.py +1 -1
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_a.py +4 -2
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b1.py +194 -1
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_c3_packaging.py +37 -1
- {memleaf-0.2.1 → memleaf-0.2.3}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/LICENSE +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/MANIFEST.in +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/examples/README.md +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/examples/basic_usage.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/install.ps1 +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/install.sh +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/setup.cfg +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/config.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/index.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/models.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/service.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf.egg-info/SOURCES.txt +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/__init__.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_admission_noise.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_hermes_provider.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_host_events.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_install.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_maintenance_v2.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b2a.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.1 → memleaf-0.2.3}/tests/test_v2_nomatch_semantics.py +0 -0
- {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.
|
|
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.
|
|
27
|
-
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.
|
|
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.
|
|
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.
|
|
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.
|
|
8
|
-
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.
|
|
7
|
+
> **当前版本:0.2.3。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.3 通过 PyPI 分发。
|
|
9
9
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
10
10
|
|
|
11
11
|
## 项目定位
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Local-first Markdown memory core for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
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
|
|
@@ -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.
|
|
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.
|
|
27
|
-
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.
|
|
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.
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|