memleaf 0.2.5__tar.gz → 0.2.6__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.5 → memleaf-0.2.6}/CHANGELOG.md +13 -0
- {memleaf-0.2.5/src/memleaf.egg-info → memleaf-0.2.6}/PKG-INFO +3 -3
- {memleaf-0.2.5 → memleaf-0.2.6}/README.md +2 -2
- {memleaf-0.2.5 → memleaf-0.2.6}/pyproject.toml +1 -1
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/hermes_provider/__init__.py +1 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/processing.py +3 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/prompts.py +38 -4
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/validation.py +25 -2
- {memleaf-0.2.5 → memleaf-0.2.6/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_hermes_provider.py +17 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_pypi_install.py +1 -1
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b1.py +64 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b2a.py +347 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_c3_packaging.py +1 -1
- {memleaf-0.2.5 → memleaf-0.2.6}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/LICENSE +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/MANIFEST.in +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/README.en.md +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/examples/README.md +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/examples/basic_usage.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/install.ps1 +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/install.sh +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/setup.cfg +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/config.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/index.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/models.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/service.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf.egg-info/SOURCES.txt +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/__init__.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_admission_noise.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_host_events.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_install.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_maintenance_v2.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.6 — 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Reject one worthy memory that spans multiple independent project scopes and
|
|
8
|
+
retry with an explicit instruction to split actionable project topics while
|
|
9
|
+
discarding temporary mailbox-sweep status items.
|
|
10
|
+
- Tighten automatic admission so aggregate mailbox/daily-report snapshots,
|
|
11
|
+
one-off meetings without durable outcomes, pending PM acceptance, and
|
|
12
|
+
awaiting-feedback states do not become a cross-project permanent memory;
|
|
13
|
+
keep independent risks and actionable todos eligible.
|
|
14
|
+
- Prefer `UPDATE` or `NO_CHANGE` for an existing future-use topic, preserve
|
|
15
|
+
unimplemented customer requests as proposed or pending, and recognize the
|
|
16
|
+
Chinese relative-date forms `今日`, `明日`, and `昨日` in strict retries.
|
|
17
|
+
|
|
5
18
|
## 0.2.5 — 2026-09-01
|
|
6
19
|
|
|
7
20
|
- Detect multiple gate candidates that target the same active memory before
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
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.6。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.6 通过 PyPI 分发。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
30
30
|
## 项目定位
|
|
@@ -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.6。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.6 通过 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.6"
|
|
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
|
|
@@ -33,6 +33,7 @@ from .native_index import NativeIndexer
|
|
|
33
33
|
from .prompts import (
|
|
34
34
|
DUPLICATE_TARGET_CORRECTION,
|
|
35
35
|
JSON_CORRECTION,
|
|
36
|
+
MIXED_PROJECT_SCOPES_CORRECTION,
|
|
36
37
|
RELATIVE_TIME_CORRECTION,
|
|
37
38
|
GATE_SYSTEM,
|
|
38
39
|
SUMMARY_TARGET_CORRECTION,
|
|
@@ -479,6 +480,8 @@ class Processor:
|
|
|
479
480
|
stage = getattr(error, "stage", None)
|
|
480
481
|
if hint == "duplicate_update_target":
|
|
481
482
|
return DUPLICATE_TARGET_CORRECTION
|
|
483
|
+
if hint == "mixed_project_scopes":
|
|
484
|
+
return MIXED_PROJECT_SCOPES_CORRECTION
|
|
482
485
|
if hint == "relative_time":
|
|
483
486
|
return RELATIVE_TIME_CORRECTION
|
|
484
487
|
if stage == "summarize" and hint == "invalid_update_target":
|
|
@@ -47,7 +47,7 @@ Calendar dates are strict. Each event may include an ISO-8601 UTC timestamp.
|
|
|
47
47
|
For a one-off relative date in a candidate or its later summary, use the
|
|
48
48
|
timestamp of the evidence event that supports it as the calendar anchor and
|
|
49
49
|
emit an absolute YYYY-MM-DD date. Resolve today/tomorrow/yesterday,
|
|
50
|
-
|
|
50
|
+
今天/明天/昨天/今日/明日/昨日, 本周X/这周X/下周X/上周X, and this/next/last weekday;
|
|
51
51
|
use Monday-Sunday calendar weeks for week expressions. Do not leave these
|
|
52
52
|
strong relative forms in a worthy memory, and never guess without a timestamp:
|
|
53
53
|
defer or omit a date-dependent candidate if its supporting event has no
|
|
@@ -92,6 +92,13 @@ be independently retrieved and updated. Prefer one coherent current-state memory
|
|
|
92
92
|
over adjacent overlapping snapshots; do not use a score or fixed category rule.
|
|
93
93
|
Most ordinary turns should produce zero or one candidate; multiple require
|
|
94
94
|
genuinely independent future questions or actions.
|
|
95
|
+
Never persist an aggregate mailbox sweep, daily report, or "items to watch"
|
|
96
|
+
digest as one cross-project memory. Inspect each item and split genuinely
|
|
97
|
+
independent project risks or actionable todo items; if an item has no
|
|
98
|
+
independent future use, set worth=false. A one-off meeting with no durable
|
|
99
|
+
decision or commitment, a request awaiting PM acceptance, and pending feedback
|
|
100
|
+
or another intermediate status normally have worth=false. Keep an independent
|
|
101
|
+
project risk or concrete future action even when it came from a digest.
|
|
95
102
|
An intermediate state such as a draft awaiting confirmation, preparation/
|
|
96
103
|
processing status, temporary error, repeated confirmation, or unconfirmed
|
|
97
104
|
suggestion normally has no independent future use and is worth=false. Keep one
|
|
@@ -113,6 +120,11 @@ operational incident spans stats/search/remember calls; this default remains.
|
|
|
113
120
|
Retain only a separate, user-confirmed future-use fact, preference, identity, constraint, commitment,
|
|
114
121
|
project risk, or durable lesson that prevents repeating the investigation, not
|
|
115
122
|
the execution transcript.
|
|
123
|
+
If a related active memory already covers the same future question/action and
|
|
124
|
+
the current turn adds no confirmed change, use worth=false and do not create a
|
|
125
|
+
sibling. If a customer or user proposes a new change without confirmation of
|
|
126
|
+
implementation, preserve it as requested/proposed/pending and never state that
|
|
127
|
+
it is already implemented.
|
|
116
128
|
Return no prose, markdown fences, comments, or trailing text."""
|
|
117
129
|
|
|
118
130
|
|
|
@@ -142,11 +154,15 @@ to the gate candidate type and to the active update target type.
|
|
|
142
154
|
For the same use, preserve still-valid facts, add confirmed progress, replace
|
|
143
155
|
contradictions, and never reduce the memory to the latest operation or create a
|
|
144
156
|
sibling with a new label.
|
|
157
|
+
Never turn an email sweep, daily report, or watchlist into one cross-project
|
|
158
|
+
summary. Keep only one future-use topic in the summary; split unrelated
|
|
159
|
+
projects at the gate, and omit temporary meeting/awaiting-acceptance/pending-
|
|
160
|
+
feedback states unless they have an independent future action.
|
|
145
161
|
Calendar dates are strict. Each event may include an ISO-8601 UTC timestamp.
|
|
146
162
|
For a one-off relative date in the summary title or body, use the timestamp of
|
|
147
163
|
the supporting evidence event as the calendar anchor and emit an absolute
|
|
148
164
|
YYYY-MM-DD date. Resolve today/tomorrow/yesterday,
|
|
149
|
-
|
|
165
|
+
今天/明天/昨天/今日/明日/昨日, 本周X/这周X/下周X/上周X, and this/next/last weekday;
|
|
150
166
|
use Monday-Sunday calendar weeks for week expressions. Never leave these
|
|
151
167
|
strong relative forms in the summary, and never guess without a timestamp.
|
|
152
168
|
Recurring schedules such as 每天、每周三, or every Wednesday are not one-off
|
|
@@ -155,7 +171,12 @@ In automatic capture/process mode, a pure read-only query whose answer only
|
|
|
155
171
|
restates a related active memory is not a new candidate. It must have been
|
|
156
172
|
rejected by the gate; do not use duplicate_memory_id or update_memory_id to
|
|
157
173
|
append the query as a source. Only a newly confirmed fact or state change in
|
|
158
|
-
the current evidence may update an existing memory.
|
|
174
|
+
the current evidence may update an existing memory. If a related active memory
|
|
175
|
+
already covers the same future question/action and the current evidence adds no
|
|
176
|
+
confirmed change, retain the existing memory unchanged (the gate should emit
|
|
177
|
+
worth=false) rather than creating a sibling. When the current evidence is a
|
|
178
|
+
customer/user request that is not confirmed implemented, state it as
|
|
179
|
+
requested/proposed/pending and never as completed or deployed.
|
|
159
180
|
fields and JSON types are title (string), body (string), tags (string list),
|
|
160
181
|
type (one of preference, fact, project, todo, event, identity, other), scopes
|
|
161
182
|
(non-empty string list), and sources (non-empty object list). scope_source, if
|
|
@@ -210,7 +231,7 @@ RELATIVE_TIME_CORRECTION = (
|
|
|
210
231
|
"each one-off relative date from that timestamp and the stated weekday "
|
|
211
232
|
"semantics. In the summary title and body, every one-off calendar date must "
|
|
212
233
|
"be written only as YYYY-MM-DD: remove forms such as today/tomorrow/yesterday, "
|
|
213
|
-
"
|
|
234
|
+
"今天/明天/昨天/今日/明日/昨日, 本周三/这周三/下周三/上周三, and this/next/last Wednesday. "
|
|
214
235
|
"If a relative weekday is followed by a parenthesized numeric date and they "
|
|
215
236
|
"conflict, trust the event timestamp plus the weekday meaning, replace the "
|
|
216
237
|
"numeric date with the computed YYYY-MM-DD, and remove the relative wording "
|
|
@@ -220,6 +241,19 @@ RELATIVE_TIME_CORRECTION = (
|
|
|
220
241
|
)
|
|
221
242
|
|
|
222
243
|
|
|
244
|
+
MIXED_PROJECT_SCOPES_CORRECTION = (
|
|
245
|
+
"Previous output violated: mixed_project_scopes. Keep each worthy "
|
|
246
|
+
"candidate and summary atomic: it may contain at most one distinct "
|
|
247
|
+
"project:<name> scope. At the gate, split genuinely independent projects "
|
|
248
|
+
"into separate candidates, preserving their own evidence, or mark purely "
|
|
249
|
+
"temporary meeting/awaiting-acceptance/pending-feedback items worth=false. "
|
|
250
|
+
"In a summary, keep only one future-use project topic and its matching "
|
|
251
|
+
"details. global plus one project and valid domain/portfolio parent scopes "
|
|
252
|
+
"are allowed; do not remove those parent scopes merely to pass validation. "
|
|
253
|
+
"Return only the strict JSON object."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
223
257
|
DUPLICATE_TARGET_CORRECTION = (
|
|
224
258
|
"Previous output violated: duplicate_update_target. Merge every candidate "
|
|
225
259
|
"that refers to the same active memory target into one candidate for one "
|
|
@@ -52,6 +52,7 @@ MODEL_VALIDATION_DETAILS = frozenset(
|
|
|
52
52
|
"candidate_shape",
|
|
53
53
|
"duplicate_candidate_id",
|
|
54
54
|
"duplicate_update_target",
|
|
55
|
+
"mixed_project_scopes",
|
|
55
56
|
"invalid_evidence",
|
|
56
57
|
"invalid_flags",
|
|
57
58
|
"invalid_type",
|
|
@@ -72,7 +73,7 @@ _RELATIVE_CALENDAR_EXPRESSION = re.compile(
|
|
|
72
73
|
r"(?<![A-Za-z])(?:today|tomorrow|yesterday)(?![A-Za-z])"
|
|
73
74
|
r"|(?<![A-Za-z])(?:this|next|last)\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)(?![A-Za-z])"
|
|
74
75
|
r"|(?:本|这|下|上)(?:个)?(?:周|星期|礼拜)\s*(?:一|二|三|四|五|六|日|天|末|[1-7])"
|
|
75
|
-
r"|(
|
|
76
|
+
r"|(?:今天|明天|昨天|今日|明日|昨日)"
|
|
76
77
|
r")",
|
|
77
78
|
re.IGNORECASE,
|
|
78
79
|
)
|
|
@@ -228,6 +229,25 @@ def _scopes(value: Any, scope_source: Any) -> list[str]:
|
|
|
228
229
|
return result
|
|
229
230
|
|
|
230
231
|
|
|
232
|
+
def _reject_mixed_project_scopes(scopes: Iterable[str]) -> None:
|
|
233
|
+
"""Keep one worthy memory atomic across independent project scopes.
|
|
234
|
+
|
|
235
|
+
Parent scopes (global/domain/portfolio) may accompany one project scope;
|
|
236
|
+
only two or more distinct project scopes represent an ambiguous aggregate.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
project_scopes = {
|
|
240
|
+
scope.casefold()
|
|
241
|
+
for scope in scopes
|
|
242
|
+
if isinstance(scope, str) and scope.partition(":")[0] == "project"
|
|
243
|
+
}
|
|
244
|
+
if len(project_scopes) > 1:
|
|
245
|
+
raise ModelOutputError(
|
|
246
|
+
"one memory cannot cover multiple project scopes",
|
|
247
|
+
validation_detail="mixed_project_scopes",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
231
251
|
def _memory_id(value: Any, field: str) -> str:
|
|
232
252
|
result = _string(value, field)
|
|
233
253
|
if "/" in result or "\\" in result or result in (".", ".."):
|
|
@@ -535,7 +555,9 @@ def validate_gate_output(
|
|
|
535
555
|
target_ids.add(target_key)
|
|
536
556
|
if not isinstance(item["scope_source"], str) or item["scope_source"] not in SCOPE_SOURCES:
|
|
537
557
|
raise ModelOutputError("invalid scope_source", validation_detail="invalid_scope_source")
|
|
538
|
-
_scopes(item["scopes"], item["scope_source"])
|
|
558
|
+
item["scopes"] = _scopes(item["scopes"], item["scope_source"])
|
|
559
|
+
if item["worth"]:
|
|
560
|
+
_reject_mixed_project_scopes(item["scopes"])
|
|
539
561
|
if "reason" in item:
|
|
540
562
|
_string(item["reason"], "reason", nonempty=False)
|
|
541
563
|
if len(item["reason"]) > 30:
|
|
@@ -659,6 +681,7 @@ def validate_summarize_output(
|
|
|
659
681
|
validation_detail="invalid_type",
|
|
660
682
|
)
|
|
661
683
|
item["scopes"] = _scopes(item["scopes"], item.get("scope_source"))
|
|
684
|
+
_reject_mixed_project_scopes(item["scopes"])
|
|
662
685
|
item["scope_operations"] = _scope_operations(
|
|
663
686
|
item.get("scope_operations"),
|
|
664
687
|
summary_scopes=item["scopes"],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
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.6。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.6 通过 PyPI 分发。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
30
30
|
## 项目定位
|
|
@@ -1734,6 +1734,23 @@ class HermesProviderTests(unittest.TestCase):
|
|
|
1734
1734
|
self.assertIsNotNone(fields)
|
|
1735
1735
|
self.assertEqual(fields[4], "duplicate_update_target")
|
|
1736
1736
|
|
|
1737
|
+
def test_mixed_project_scope_validation_detail_is_preserved(self) -> None:
|
|
1738
|
+
value = {
|
|
1739
|
+
"isError": True,
|
|
1740
|
+
"structuredContent": {
|
|
1741
|
+
"error": {
|
|
1742
|
+
"code": "model_invalid_response",
|
|
1743
|
+
"stage": "gate",
|
|
1744
|
+
"validation_reason": "schema_violation",
|
|
1745
|
+
"validation_detail": "mixed_project_scopes",
|
|
1746
|
+
"attempt_count": 3,
|
|
1747
|
+
}
|
|
1748
|
+
},
|
|
1749
|
+
}
|
|
1750
|
+
fields = provider_module._mcp_error_fields(value)
|
|
1751
|
+
self.assertIsNotNone(fields)
|
|
1752
|
+
self.assertEqual(fields[4], "mixed_project_scopes")
|
|
1753
|
+
|
|
1737
1754
|
def test_relative_time_validation_detail_is_preserved_and_logged(self) -> None:
|
|
1738
1755
|
value = {
|
|
1739
1756
|
"isError": True,
|
|
@@ -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.6", (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:
|
|
@@ -12,6 +12,7 @@ from memleaf.inbox import complete_turns, parse_inbox
|
|
|
12
12
|
from memleaf.prompts import (
|
|
13
13
|
DUPLICATE_TARGET_CORRECTION,
|
|
14
14
|
GATE_SYSTEM,
|
|
15
|
+
MIXED_PROJECT_SCOPES_CORRECTION,
|
|
15
16
|
RELATIVE_TIME_CORRECTION,
|
|
16
17
|
SUMMARY_TARGET_CORRECTION,
|
|
17
18
|
SUMMARY_TYPE_CORRECTION,
|
|
@@ -184,6 +185,7 @@ class StageB1Test(unittest.TestCase):
|
|
|
184
185
|
self.assertIn("ISO-8601 UTC timestamp", prompt_system)
|
|
185
186
|
self.assertIn("YYYY-MM-DD", prompt_system)
|
|
186
187
|
self.assertIn("本周X/这周X/下周X/上周X", prompt_system)
|
|
188
|
+
self.assertIn("今日/明日/昨日", prompt_system)
|
|
187
189
|
self.assertIn("every Wednesday", prompt_system)
|
|
188
190
|
|
|
189
191
|
def test_relative_summary_is_rejected_then_absolute_retry_is_committed(self):
|
|
@@ -1179,6 +1181,11 @@ class ValidationTest(unittest.TestCase):
|
|
|
1179
1181
|
self.assertIn("immutable", SUMMARY_TARGET_CORRECTION.lower())
|
|
1180
1182
|
self.assertIn("exactly equal", SUMMARY_TYPE_CORRECTION.lower())
|
|
1181
1183
|
|
|
1184
|
+
def test_correction_prompt_explains_project_scope_atomicity(self):
|
|
1185
|
+
self.assertIn("mixed_project_scopes", MIXED_PROJECT_SCOPES_CORRECTION)
|
|
1186
|
+
self.assertIn("split", MIXED_PROJECT_SCOPES_CORRECTION.lower())
|
|
1187
|
+
self.assertIn("global plus one project", MIXED_PROJECT_SCOPES_CORRECTION.lower())
|
|
1188
|
+
|
|
1182
1189
|
def test_mcp_model_diagnostics_preserve_duplicate_target_detail(self):
|
|
1183
1190
|
error = ModelOutputError(
|
|
1184
1191
|
"safe",
|
|
@@ -1331,6 +1338,63 @@ class ValidationTest(unittest.TestCase):
|
|
|
1331
1338
|
["event-a"],
|
|
1332
1339
|
)
|
|
1333
1340
|
|
|
1341
|
+
def test_worthy_memory_rejects_multiple_project_scopes_but_allows_parent_context(self):
|
|
1342
|
+
base = {
|
|
1343
|
+
"candidate_id": "c",
|
|
1344
|
+
"memory": "one future-use topic",
|
|
1345
|
+
"evidence_event_ids": ["event-a"],
|
|
1346
|
+
"duplicate": False,
|
|
1347
|
+
"worth": True,
|
|
1348
|
+
"type": "project",
|
|
1349
|
+
"scope_source": "model",
|
|
1350
|
+
}
|
|
1351
|
+
invalid = dict(base, scopes=["project:cn", "project:fund"])
|
|
1352
|
+
with self.assertRaises(ModelOutputError) as raised:
|
|
1353
|
+
parse_gate_output(json.dumps({"candidates": [invalid]}), ["event-a"])
|
|
1354
|
+
self.assertEqual(raised.exception.validation_detail, "mixed_project_scopes")
|
|
1355
|
+
|
|
1356
|
+
for scopes in (
|
|
1357
|
+
["global", "project:cn"],
|
|
1358
|
+
["portfolio:finance", "project:cn"],
|
|
1359
|
+
["domain:work", "portfolio:finance", "project:cn"],
|
|
1360
|
+
):
|
|
1361
|
+
with self.subTest(scopes=scopes):
|
|
1362
|
+
parsed = parse_gate_output(
|
|
1363
|
+
json.dumps({"candidates": [dict(base, scopes=scopes)]}),
|
|
1364
|
+
["event-a"],
|
|
1365
|
+
)
|
|
1366
|
+
self.assertEqual(parsed["candidates"][0]["scopes"], scopes)
|
|
1367
|
+
|
|
1368
|
+
summary = {
|
|
1369
|
+
"title": "One topic",
|
|
1370
|
+
"body": "One future-use topic.",
|
|
1371
|
+
"tags": [],
|
|
1372
|
+
"type": "project",
|
|
1373
|
+
"scopes": ["project:cn", "project:fund"],
|
|
1374
|
+
"scope_source": "model",
|
|
1375
|
+
"sources": [{"event_key": "event-a"}],
|
|
1376
|
+
}
|
|
1377
|
+
with self.assertRaises(ModelOutputError) as raised:
|
|
1378
|
+
parse_summarize_output(json.dumps(summary), current_event_keys=["event-a"])
|
|
1379
|
+
self.assertEqual(raised.exception.validation_detail, "mixed_project_scopes")
|
|
1380
|
+
|
|
1381
|
+
def test_chinese_relative_date_aliases_are_rejected(self):
|
|
1382
|
+
for relative in ("今日", "明日", "昨日"):
|
|
1383
|
+
with self.subTest(relative=relative):
|
|
1384
|
+
summary = {
|
|
1385
|
+
"title": f"截止{relative}",
|
|
1386
|
+
"body": "完成项目交付。",
|
|
1387
|
+
"tags": [],
|
|
1388
|
+
"type": "todo",
|
|
1389
|
+
"scopes": ["global"],
|
|
1390
|
+
"scope_source": "model",
|
|
1391
|
+
"sources": [{"event_key": "event-a"}],
|
|
1392
|
+
"status": "active",
|
|
1393
|
+
}
|
|
1394
|
+
with self.assertRaises(ModelOutputError) as raised:
|
|
1395
|
+
parse_summarize_output(json.dumps(summary), current_event_keys=["event-a"])
|
|
1396
|
+
self.assertEqual(raised.exception.validation_detail, "relative_time")
|
|
1397
|
+
|
|
1334
1398
|
def test_summary_evidence_and_sources_are_limited_to_current_turn_fields(self):
|
|
1335
1399
|
summary = {
|
|
1336
1400
|
"title": "Traceable",
|
|
@@ -182,6 +182,205 @@ class StageB2ATest(unittest.TestCase):
|
|
|
182
182
|
self.assertEqual(len(backend.calls), calls_after_first)
|
|
183
183
|
self.assertEqual(len(self.knowledge(service)), 2)
|
|
184
184
|
|
|
185
|
+
def test_mixed_project_digest_retries_to_atomic_project_outputs(self):
|
|
186
|
+
service = self.service(name="mixed-project-digest")
|
|
187
|
+
history_memory = service.create_memory(
|
|
188
|
+
memory_id="mem-zhongyin-history",
|
|
189
|
+
title="中银国际历史需求",
|
|
190
|
+
body="中银国际历史需求已完整覆盖:需求清单逐项确认、信创测试环境部署、历史数据库和附件全量迁移。",
|
|
191
|
+
type="project",
|
|
192
|
+
scopes=["project:zhongyin"],
|
|
193
|
+
)
|
|
194
|
+
existing = service.create_memory(
|
|
195
|
+
memory_id="mem-zhongyin-plan",
|
|
196
|
+
title="中银国际实施计划",
|
|
197
|
+
body="中银国际实施计划当前按原始安排执行。",
|
|
198
|
+
type="project",
|
|
199
|
+
scopes=["project:zhongyin"],
|
|
200
|
+
)
|
|
201
|
+
config = service.vault.config()
|
|
202
|
+
config["llm"]["diagnostic_logging"] = True
|
|
203
|
+
save_config(service.vault.config_path, config)
|
|
204
|
+
user_key, assistant_key = self.capture_turn(
|
|
205
|
+
service,
|
|
206
|
+
session="mixed-project-digest",
|
|
207
|
+
user_event="mixed-digest-user",
|
|
208
|
+
assistant_event="mixed-digest-assistant",
|
|
209
|
+
user=(
|
|
210
|
+
"邮箱巡检:中银国际历史需求已覆盖需求清单逐项确认、信创测试环境部署、"
|
|
211
|
+
"历史数据库和附件全量迁移;客户提出单点登录提前并行、数据文件规范提前确认、"
|
|
212
|
+
"重新压实实施计划;摩根基金本周三排查证券申报类型;会议待确认;"
|
|
213
|
+
"Orion待PM受理;金元顺安待反馈。"
|
|
214
|
+
),
|
|
215
|
+
assistant="会议和待办已整理,后续按项目分别跟进。",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def scoped(candidate_id, *, memory, type, scopes, worth=True, update_memory_id=None):
|
|
219
|
+
value = self.candidate(
|
|
220
|
+
candidate_id,
|
|
221
|
+
[user_key, assistant_key],
|
|
222
|
+
memory=memory,
|
|
223
|
+
type=type,
|
|
224
|
+
worth=worth,
|
|
225
|
+
update_memory_id=update_memory_id,
|
|
226
|
+
)
|
|
227
|
+
value["scopes"] = list(scopes)
|
|
228
|
+
return value
|
|
229
|
+
|
|
230
|
+
mixed = scoped(
|
|
231
|
+
"digest",
|
|
232
|
+
memory="2026-09-01邮箱巡检需关注事项",
|
|
233
|
+
type="fact",
|
|
234
|
+
scopes=[
|
|
235
|
+
"project:zhongyin",
|
|
236
|
+
"project:morgan",
|
|
237
|
+
"project:orion",
|
|
238
|
+
"project:jinyuan",
|
|
239
|
+
],
|
|
240
|
+
)
|
|
241
|
+
corrected = [
|
|
242
|
+
scoped(
|
|
243
|
+
"zhongyin-plan",
|
|
244
|
+
memory="中银国际实施计划新增客户建议:单点登录提前并行、数据文件规范提前确认、重新压实实施计划",
|
|
245
|
+
type="project",
|
|
246
|
+
scopes=["project:zhongyin"],
|
|
247
|
+
update_memory_id=existing.memory_id,
|
|
248
|
+
),
|
|
249
|
+
scoped(
|
|
250
|
+
"morgan-todo",
|
|
251
|
+
memory="摩根基金排查证券申报类型",
|
|
252
|
+
type="todo",
|
|
253
|
+
scopes=["project:morgan"],
|
|
254
|
+
),
|
|
255
|
+
scoped(
|
|
256
|
+
"orion-pending",
|
|
257
|
+
memory="Orion待PM受理",
|
|
258
|
+
type=None,
|
|
259
|
+
scopes=["project:orion"],
|
|
260
|
+
worth=False,
|
|
261
|
+
),
|
|
262
|
+
scoped(
|
|
263
|
+
"jinyuan-pending",
|
|
264
|
+
memory="金元顺安待反馈",
|
|
265
|
+
type=None,
|
|
266
|
+
scopes=["project:jinyuan"],
|
|
267
|
+
worth=False,
|
|
268
|
+
),
|
|
269
|
+
]
|
|
270
|
+
backend = QueueBackend(
|
|
271
|
+
[
|
|
272
|
+
self.gate([mixed]),
|
|
273
|
+
self.gate(corrected),
|
|
274
|
+
self.summary(
|
|
275
|
+
user_key,
|
|
276
|
+
title="中银国际实施计划",
|
|
277
|
+
body=(
|
|
278
|
+
"客户提出单点登录提前并行、数据文件规范提前确认、重新压实实施计划;"
|
|
279
|
+
"当前待纳入,尚未表示已落地。"
|
|
280
|
+
),
|
|
281
|
+
type="project",
|
|
282
|
+
scopes=["project:zhongyin"],
|
|
283
|
+
update_memory_id=existing.memory_id,
|
|
284
|
+
),
|
|
285
|
+
self.summary(
|
|
286
|
+
user_key,
|
|
287
|
+
title="摩根基金证券申报类型排查",
|
|
288
|
+
body="待排查摩根基金证券申报类型,截止日期为2026-09-02。",
|
|
289
|
+
type="todo",
|
|
290
|
+
scopes=["project:morgan"],
|
|
291
|
+
status="active",
|
|
292
|
+
),
|
|
293
|
+
]
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
result = service.process(
|
|
297
|
+
source="codex",
|
|
298
|
+
session_id="mixed-project-digest",
|
|
299
|
+
model=backend,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
self.assertEqual(result["processed_turns"], 1)
|
|
303
|
+
self.assertEqual(result["memories_written"], 2)
|
|
304
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "summarize", "summarize"])
|
|
305
|
+
self.assertIn("Previous output violated: mixed_project_scopes.", backend.calls[1]["prompt"])
|
|
306
|
+
active = self.knowledge(service)
|
|
307
|
+
self.assertEqual(len(active), 3)
|
|
308
|
+
active_by_id = {record.memory.memory_id: record.memory for record in active}
|
|
309
|
+
self.assertIn(history_memory.memory_id, active_by_id)
|
|
310
|
+
self.assertIn(existing.memory_id, active_by_id)
|
|
311
|
+
self.assertTrue(all(sum(scope.startswith("project:") for scope in record.memory.scopes) <= 1 for record in active))
|
|
312
|
+
self.assertEqual(active_by_id[existing.memory_id].type, "project")
|
|
313
|
+
self.assertIn("客户提出", active_by_id[existing.memory_id].body)
|
|
314
|
+
self.assertIn("待纳入", active_by_id[existing.memory_id].body)
|
|
315
|
+
todo = next(
|
|
316
|
+
memory
|
|
317
|
+
for memory_id, memory in active_by_id.items()
|
|
318
|
+
if memory_id not in {history_memory.memory_id, existing.memory_id}
|
|
319
|
+
)
|
|
320
|
+
self.assertEqual(
|
|
321
|
+
set(active_by_id),
|
|
322
|
+
{history_memory.memory_id, existing.memory_id, todo.memory_id},
|
|
323
|
+
)
|
|
324
|
+
self.assertEqual(todo.type, "todo")
|
|
325
|
+
self.assertEqual(todo.scopes, ["project:morgan"])
|
|
326
|
+
self.assertEqual(active_by_id[history_memory.memory_id].body, history_memory.body)
|
|
327
|
+
self.assertIn("单点登录提前并行", active_by_id[existing.memory_id].body)
|
|
328
|
+
self.assertNotIn("需求清单逐项确认", active_by_id[existing.memory_id].body)
|
|
329
|
+
self.assertNotIn("历史数据库和附件全量迁移", active_by_id[existing.memory_id].body)
|
|
330
|
+
history = service._read_memories_unlocked("history")
|
|
331
|
+
self.assertEqual(len(history), 1)
|
|
332
|
+
self.assertEqual(history[0].memory.extra["active_memory_id"], existing.memory_id)
|
|
333
|
+
self.assertEqual(history[0].memory.body, existing.body)
|
|
334
|
+
bodies = "\n".join(record.memory.body for record in active)
|
|
335
|
+
self.assertNotIn("邮箱巡检", bodies)
|
|
336
|
+
self.assertNotIn("需关注事项", bodies)
|
|
337
|
+
self.assertNotIn("待PM受理", bodies)
|
|
338
|
+
self.assertNotIn("待反馈", bodies)
|
|
339
|
+
diagnostics = [
|
|
340
|
+
json.loads(line)
|
|
341
|
+
for line in (service.vault.logs_path / "model-diagnostics.jsonl").read_text(encoding="utf-8").splitlines()
|
|
342
|
+
]
|
|
343
|
+
self.assertEqual(diagnostics[0]["validation_detail"], "mixed_project_scopes")
|
|
344
|
+
|
|
345
|
+
def test_three_mixed_project_gate_failures_keep_inbox_and_watermark(self):
|
|
346
|
+
backend = QueueBackend()
|
|
347
|
+
service = self.service(backend, name="mixed-gate-failure")
|
|
348
|
+
user_key, assistant_key = self.capture_turn(
|
|
349
|
+
service,
|
|
350
|
+
session="mixed-gate-failure",
|
|
351
|
+
user_event="mixed-gate-failure-user",
|
|
352
|
+
assistant_event="mixed-gate-failure-assistant",
|
|
353
|
+
)
|
|
354
|
+
mixed = self.candidate(
|
|
355
|
+
"mixed-gate-candidate",
|
|
356
|
+
[user_key, assistant_key],
|
|
357
|
+
memory="cross-project digest",
|
|
358
|
+
type="fact",
|
|
359
|
+
)
|
|
360
|
+
mixed["scopes"] = ["project:zhongyin", "project:morgan"]
|
|
361
|
+
backend.responses.extend([self.gate([mixed])] * 3)
|
|
362
|
+
|
|
363
|
+
with self.assertRaises(ModelOutputError) as raised:
|
|
364
|
+
service.process(
|
|
365
|
+
source="codex",
|
|
366
|
+
session_id="mixed-gate-failure",
|
|
367
|
+
model=backend,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
self.assertEqual(raised.exception.validation_detail, "mixed_project_scopes")
|
|
371
|
+
self.assertEqual(raised.exception.stage, "gate")
|
|
372
|
+
self.assertEqual(raised.exception.attempt_count, 3)
|
|
373
|
+
marker = self.processed(service)["sessions"]["codex/mixed-gate-failure"]["processing"]
|
|
374
|
+
self.assertEqual(marker["failure_stage"], "gate")
|
|
375
|
+
self.assertEqual(marker["validation_detail"], "mixed_project_scopes")
|
|
376
|
+
self.assertEqual(marker["attempt_count"], 3)
|
|
377
|
+
self.assertEqual(
|
|
378
|
+
self.processed(service)["sessions"]["codex/mixed-gate-failure"].get("watermark", 0),
|
|
379
|
+
0,
|
|
380
|
+
)
|
|
381
|
+
self.assertTrue((service.vault.inbox_path / "codex" / "mixed-gate-failure.md").is_file())
|
|
382
|
+
self.assertEqual(self.knowledge(service), [])
|
|
383
|
+
|
|
185
384
|
def test_draft_turn_is_discarded_and_final_email_state_is_the_only_memory(self):
|
|
186
385
|
backend = QueueBackend()
|
|
187
386
|
service = self.service(backend, name="email-final-only")
|
|
@@ -531,6 +730,154 @@ class StageB2ATest(unittest.TestCase):
|
|
|
531
730
|
self.assertEqual(service.read(existing.memory_id).body, "The fact is updated.")
|
|
532
731
|
self.assertEqual(len(service.vault.list_markdown("history")), 1)
|
|
533
732
|
|
|
733
|
+
def test_mixed_project_summary_retries_before_writing(self):
|
|
734
|
+
backend = QueueBackend()
|
|
735
|
+
service = self.service(backend, name="mixed-summary-retry")
|
|
736
|
+
user_key, _ = self.capture_turn(
|
|
737
|
+
service,
|
|
738
|
+
turn="mixed-summary",
|
|
739
|
+
user_event="mixed-summary-user",
|
|
740
|
+
assistant_event="mixed-summary-assistant",
|
|
741
|
+
)
|
|
742
|
+
candidate = self.candidate(
|
|
743
|
+
"mixed-summary-candidate",
|
|
744
|
+
[user_key],
|
|
745
|
+
memory="one project topic",
|
|
746
|
+
type="project",
|
|
747
|
+
)
|
|
748
|
+
candidate["scopes"] = ["project:zhongyin"]
|
|
749
|
+
mixed_summary = self.summary(
|
|
750
|
+
user_key,
|
|
751
|
+
title="One topic",
|
|
752
|
+
body="One project topic.",
|
|
753
|
+
type="project",
|
|
754
|
+
scopes=["project:zhongyin", "project:morgan"],
|
|
755
|
+
)
|
|
756
|
+
corrected_summary = self.summary(
|
|
757
|
+
user_key,
|
|
758
|
+
title="One topic",
|
|
759
|
+
body="One project topic.",
|
|
760
|
+
type="project",
|
|
761
|
+
scopes=["project:zhongyin"],
|
|
762
|
+
)
|
|
763
|
+
backend.responses.extend([self.gate([candidate]), mixed_summary, corrected_summary])
|
|
764
|
+
|
|
765
|
+
result = service.process()
|
|
766
|
+
|
|
767
|
+
self.assertEqual(result["processed_turns"], 1)
|
|
768
|
+
self.assertEqual(result["memories_written"], 1)
|
|
769
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize", "summarize"])
|
|
770
|
+
self.assertIn("Previous output violated: mixed_project_scopes.", backend.calls[2]["prompt"])
|
|
771
|
+
self.assertEqual(service._read_memories_unlocked("knowledge")[0].memory.scopes, ["project:zhongyin"])
|
|
772
|
+
|
|
773
|
+
def test_three_mixed_project_summary_failures_keep_inbox_and_watermark(self):
|
|
774
|
+
backend = QueueBackend()
|
|
775
|
+
service = self.service(backend, name="mixed-summary-failure")
|
|
776
|
+
user_key, _ = self.capture_turn(
|
|
777
|
+
service,
|
|
778
|
+
turn="mixed-summary-failure",
|
|
779
|
+
user_event="mixed-summary-failure-user",
|
|
780
|
+
assistant_event="mixed-summary-failure-assistant",
|
|
781
|
+
)
|
|
782
|
+
candidate = self.candidate(
|
|
783
|
+
"mixed-summary-failure-candidate",
|
|
784
|
+
[user_key],
|
|
785
|
+
memory="one project topic",
|
|
786
|
+
type="project",
|
|
787
|
+
)
|
|
788
|
+
candidate["scopes"] = ["project:zhongyin"]
|
|
789
|
+
mixed_summary = self.summary(
|
|
790
|
+
user_key,
|
|
791
|
+
title="One topic",
|
|
792
|
+
body="One project topic.",
|
|
793
|
+
type="project",
|
|
794
|
+
scopes=["project:zhongyin", "project:morgan"],
|
|
795
|
+
)
|
|
796
|
+
backend.responses.extend([self.gate([candidate]), mixed_summary, mixed_summary, mixed_summary])
|
|
797
|
+
|
|
798
|
+
with self.assertRaises(ModelOutputError) as raised:
|
|
799
|
+
service.process()
|
|
800
|
+
|
|
801
|
+
self.assertEqual(raised.exception.validation_detail, "mixed_project_scopes")
|
|
802
|
+
self.assertEqual(raised.exception.stage, "summarize")
|
|
803
|
+
self.assertEqual(raised.exception.attempt_count, 3)
|
|
804
|
+
marker = self.processed(service)["sessions"]["codex/s"]["processing"]
|
|
805
|
+
self.assertEqual(marker["failure_stage"], "summarize")
|
|
806
|
+
self.assertEqual(marker["validation_detail"], "mixed_project_scopes")
|
|
807
|
+
self.assertEqual(marker["attempt_count"], 3)
|
|
808
|
+
self.assertEqual(self.processed(service)["sessions"]["codex/s"].get("watermark", 0), 0)
|
|
809
|
+
self.assertTrue((service.vault.inbox_path / "codex" / "s.md").is_file())
|
|
810
|
+
self.assertEqual(service._read_memories_unlocked("knowledge"), [])
|
|
811
|
+
|
|
812
|
+
def test_chinese_relative_date_alias_retries_to_absolute_date(self):
|
|
813
|
+
anchor = "2026-09-01T02:01:41Z"
|
|
814
|
+
backend = QueueBackend()
|
|
815
|
+
with patch("memleaf.capture._timestamp", return_value=anchor):
|
|
816
|
+
service = self.service(backend, name="chinese-relative-retry-anchored")
|
|
817
|
+
user_key, _ = self.capture_turn(
|
|
818
|
+
service,
|
|
819
|
+
turn="chinese-relative",
|
|
820
|
+
user_event="chinese-relative-user",
|
|
821
|
+
assistant_event="chinese-relative-assistant",
|
|
822
|
+
)
|
|
823
|
+
candidate = self.candidate(
|
|
824
|
+
"chinese-relative-candidate",
|
|
825
|
+
[user_key],
|
|
826
|
+
memory="完成截止日期",
|
|
827
|
+
type="todo",
|
|
828
|
+
)
|
|
829
|
+
backend.responses.extend(
|
|
830
|
+
[
|
|
831
|
+
self.gate([candidate]),
|
|
832
|
+
self.summary(user_key, title="截止昨日", body="昨日完成。", type="todo", status="active"),
|
|
833
|
+
self.summary(user_key, title="截止日期", body="截止日期为2026-08-31。", type="todo", status="active"),
|
|
834
|
+
]
|
|
835
|
+
)
|
|
836
|
+
result = service.process()
|
|
837
|
+
|
|
838
|
+
self.assertEqual(result["processed_turns"], 1)
|
|
839
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize", "summarize"])
|
|
840
|
+
self.assertIn("今日/明日/昨日", backend.calls[2]["prompt"])
|
|
841
|
+
self.assertEqual(service._read_memories_unlocked("knowledge")[0].memory.body, "截止日期为2026-08-31。")
|
|
842
|
+
|
|
843
|
+
def test_three_chinese_relative_date_failures_keep_inbox_and_watermark(self):
|
|
844
|
+
anchor = "2026-09-01T02:01:41Z"
|
|
845
|
+
backend = QueueBackend()
|
|
846
|
+
service = self.service(backend, name="chinese-relative-failure")
|
|
847
|
+
with patch("memleaf.capture._timestamp", return_value=anchor):
|
|
848
|
+
user_key, _ = self.capture_turn(
|
|
849
|
+
service,
|
|
850
|
+
turn="chinese-relative-failure",
|
|
851
|
+
user_event="chinese-relative-failure-user",
|
|
852
|
+
assistant_event="chinese-relative-failure-assistant",
|
|
853
|
+
)
|
|
854
|
+
candidate = self.candidate(
|
|
855
|
+
"chinese-relative-failure-candidate",
|
|
856
|
+
[user_key],
|
|
857
|
+
memory="完成截止日期",
|
|
858
|
+
type="todo",
|
|
859
|
+
)
|
|
860
|
+
bad_summary = self.summary(
|
|
861
|
+
user_key,
|
|
862
|
+
title="截止今日",
|
|
863
|
+
body="今日完成。",
|
|
864
|
+
type="todo",
|
|
865
|
+
status="active",
|
|
866
|
+
)
|
|
867
|
+
backend.responses.extend([self.gate([candidate]), bad_summary, bad_summary, bad_summary])
|
|
868
|
+
|
|
869
|
+
with self.assertRaises(ModelOutputError) as raised:
|
|
870
|
+
service.process()
|
|
871
|
+
|
|
872
|
+
self.assertEqual(raised.exception.validation_detail, "relative_time")
|
|
873
|
+
self.assertEqual(raised.exception.stage, "summarize")
|
|
874
|
+
self.assertEqual(raised.exception.attempt_count, 3)
|
|
875
|
+
marker = self.processed(service)["sessions"]["codex/s"]["processing"]
|
|
876
|
+
self.assertEqual(marker["validation_detail"], "relative_time")
|
|
877
|
+
self.assertEqual(marker["attempt_count"], 3)
|
|
878
|
+
self.assertEqual(self.processed(service)["sessions"]["codex/s"].get("watermark", 0), 0)
|
|
879
|
+
self.assertTrue((service.vault.inbox_path / "codex" / "s.md").is_file())
|
|
880
|
+
|
|
534
881
|
def test_duplicate_update_target_is_rejected_at_gate_and_retried(self):
|
|
535
882
|
"""Duplicate targets are corrected before the writer commit phase."""
|
|
536
883
|
|
|
@@ -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.6"},
|
|
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.6")
|
|
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
|
|
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
|