memleaf 0.2.7__tar.gz → 0.2.8__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.7 → memleaf-0.2.8}/CHANGELOG.md +13 -0
- {memleaf-0.2.7/src/memleaf.egg-info → memleaf-0.2.8}/PKG-INFO +3 -3
- {memleaf-0.2.7 → memleaf-0.2.8}/README.md +2 -2
- {memleaf-0.2.7 → memleaf-0.2.8}/pyproject.toml +1 -1
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/hermes_provider/__init__.py +2 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/processing.py +42 -10
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/prompts.py +51 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/scope_state.py +14 -2
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/validation.py +120 -2
- {memleaf-0.2.7 → memleaf-0.2.8/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_admission_noise.py +9 -11
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_cross_host_acceptance.py +4 -1
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_hermes_provider.py +19 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_maintenance_v2.py +8 -6
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_pypi_install.py +1 -1
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_session_lineage.py +2 -2
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b1.py +143 -3
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b2a.py +258 -16
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3b_scope.py +29 -8
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3d_scope_maintenance.py +11 -2
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_c3_packaging.py +1 -1
- {memleaf-0.2.7 → memleaf-0.2.8}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/LICENSE +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/MANIFEST.in +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/README.en.md +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/examples/README.md +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/examples/basic_usage.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/install.ps1 +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/install.sh +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/setup.cfg +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/config.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/index.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/models.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/service.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf.egg-info/SOURCES.txt +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/__init__.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_host_events.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_install.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.8}/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.8 — 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Let the automatic summarizer independently reject an over-admitted gate
|
|
8
|
+
candidate with an exact `NO_CHANGE` response, without writing memory,
|
|
9
|
+
history, or Scope state; explicit remember remains write-oriented and does
|
|
10
|
+
not permit this response.
|
|
11
|
+
- Ground every model-attributed project Scope in the candidate's own project
|
|
12
|
+
name or registered alias, and prevent the summarizer from drifting away
|
|
13
|
+
from the gate-selected Scope.
|
|
14
|
+
- Register session and Vault Scopes only for committed memories or trusted
|
|
15
|
+
active duplicate targets, so temporary, deferred, and no-change mailbox
|
|
16
|
+
items cannot pollute Scope state.
|
|
17
|
+
|
|
5
18
|
## 0.2.7 — 2026-09-01
|
|
6
19
|
|
|
7
20
|
- Distinguish an invalid gate type enum from a candidate type that conflicts
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
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.8。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.8 通过 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.8。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.8 通过 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.8"
|
|
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
|
|
@@ -37,6 +37,8 @@ from .prompts import (
|
|
|
37
37
|
MIXED_PROJECT_SCOPES_CORRECTION,
|
|
38
38
|
RELATIVE_TIME_CORRECTION,
|
|
39
39
|
GATE_SYSTEM,
|
|
40
|
+
SCOPE_GROUNDING_CORRECTION,
|
|
41
|
+
SUMMARY_SCOPE_CORRECTION,
|
|
40
42
|
SUMMARY_TARGET_CORRECTION,
|
|
41
43
|
SUMMARY_TYPE_CORRECTION,
|
|
42
44
|
SUMMARIZE_SYSTEM,
|
|
@@ -51,6 +53,7 @@ from .scope_maintenance import ScopeMaintainer, ScopeMaintenanceError, scope_reg
|
|
|
51
53
|
from .validation import (
|
|
52
54
|
MODEL_VALIDATION_DETAILS,
|
|
53
55
|
ModelOutputError,
|
|
56
|
+
NO_CHANGE_DECISION,
|
|
54
57
|
parse_gate_output,
|
|
55
58
|
parse_summarize_output,
|
|
56
59
|
)
|
|
@@ -488,6 +491,10 @@ class Processor:
|
|
|
488
491
|
return UPDATE_TARGET_TYPE_CORRECTION
|
|
489
492
|
if stage == "gate" and hint == "invalid_type":
|
|
490
493
|
return GATE_TYPE_CORRECTION
|
|
494
|
+
if stage == "gate" and hint == "scope_not_grounded":
|
|
495
|
+
return SCOPE_GROUNDING_CORRECTION
|
|
496
|
+
if stage == "summarize" and hint == "scope_drift":
|
|
497
|
+
return SUMMARY_SCOPE_CORRECTION
|
|
491
498
|
if hint == "relative_time":
|
|
492
499
|
return RELATIVE_TIME_CORRECTION
|
|
493
500
|
if stage == "summarize" and hint == "invalid_update_target":
|
|
@@ -1614,6 +1621,7 @@ class Processor:
|
|
|
1614
1621
|
related_native_ids=related_native_ids,
|
|
1615
1622
|
related_memory_ids=related_memory_ids,
|
|
1616
1623
|
scope_registry=validation_scope_registry,
|
|
1624
|
+
allow_no_change=False,
|
|
1617
1625
|
),
|
|
1618
1626
|
diagnostic_context={
|
|
1619
1627
|
"source": turn.source,
|
|
@@ -1652,6 +1660,7 @@ class Processor:
|
|
|
1652
1660
|
current_event_keys=turn.event_keys,
|
|
1653
1661
|
related_memory_ids=gate_related_memory_ids,
|
|
1654
1662
|
related_memory_types=gate_related_memory_types,
|
|
1663
|
+
scope_registry=validation_scope_registry,
|
|
1655
1664
|
),
|
|
1656
1665
|
diagnostic_context={
|
|
1657
1666
|
"source": turn.source,
|
|
@@ -1663,13 +1672,6 @@ class Processor:
|
|
|
1663
1672
|
observed_scopes: list[str] = []
|
|
1664
1673
|
for candidate in gate["candidates"]:
|
|
1665
1674
|
candidate_scopes = list(candidate["scopes"])
|
|
1666
|
-
if (
|
|
1667
|
-
candidate_scopes != ["unscoped"]
|
|
1668
|
-
and candidate.get("scope_source") != "insufficient_context"
|
|
1669
|
-
):
|
|
1670
|
-
for observed_scope in candidate_scopes:
|
|
1671
|
-
if observed_scope not in observed_scopes:
|
|
1672
|
-
observed_scopes.append(observed_scope)
|
|
1673
1675
|
# An automatic candidate with no reliable project attribution is
|
|
1674
1676
|
# retained as a retryable inbox turn, never silently promoted to
|
|
1675
1677
|
# global knowledge. The processed ledger records only a compact
|
|
@@ -1758,6 +1760,27 @@ class Processor:
|
|
|
1758
1760
|
native_refs=candidate_native_refs,
|
|
1759
1761
|
)
|
|
1760
1762
|
)
|
|
1763
|
+
# Automatic duplicate observations are metadata no-ops;
|
|
1764
|
+
# only the already-active target's scopes are trustworthy
|
|
1765
|
+
# session context, never a transient model-provided scope.
|
|
1766
|
+
duplicate_scopes = next(
|
|
1767
|
+
(
|
|
1768
|
+
item.get("scopes")
|
|
1769
|
+
for item in candidate_related
|
|
1770
|
+
if isinstance(item, Mapping)
|
|
1771
|
+
and isinstance(item.get("memory_id"), str)
|
|
1772
|
+
and item["memory_id"].casefold() == duplicate_memory_id.casefold()
|
|
1773
|
+
and isinstance(item.get("scopes"), list)
|
|
1774
|
+
),
|
|
1775
|
+
[],
|
|
1776
|
+
)
|
|
1777
|
+
for observed_scope in duplicate_scopes:
|
|
1778
|
+
if (
|
|
1779
|
+
isinstance(observed_scope, str)
|
|
1780
|
+
and observed_scope != "unscoped"
|
|
1781
|
+
and observed_scope not in observed_scopes
|
|
1782
|
+
):
|
|
1783
|
+
observed_scopes.append(observed_scope)
|
|
1761
1784
|
continue
|
|
1762
1785
|
|
|
1763
1786
|
gate_update_target = candidate.get("update_memory_id")
|
|
@@ -1793,6 +1816,9 @@ class Processor:
|
|
|
1793
1816
|
related_native_ids=candidate_native_ids,
|
|
1794
1817
|
related_memory_ids=candidate_memory_ids,
|
|
1795
1818
|
scope_registry=validation_scope_registry,
|
|
1819
|
+
expected_scopes=candidate["scopes"],
|
|
1820
|
+
expected_scope_source=candidate["scope_source"],
|
|
1821
|
+
allow_no_change=True,
|
|
1796
1822
|
expected_type=(
|
|
1797
1823
|
candidate.get("type")
|
|
1798
1824
|
if (
|
|
@@ -1814,6 +1840,8 @@ class Processor:
|
|
|
1814
1840
|
"turn_index": turn.turn_index,
|
|
1815
1841
|
},
|
|
1816
1842
|
)
|
|
1843
|
+
if summary.get("decision") == NO_CHANGE_DECISION:
|
|
1844
|
+
continue
|
|
1817
1845
|
if gate_update_target is not None:
|
|
1818
1846
|
summary_update_target = summary.get("update_memory_id")
|
|
1819
1847
|
if summary_update_target is None:
|
|
@@ -1839,9 +1867,6 @@ class Processor:
|
|
|
1839
1867
|
scope_source=summary.get("scope_source"),
|
|
1840
1868
|
)
|
|
1841
1869
|
continue
|
|
1842
|
-
for observed_scope in summary["scopes"]:
|
|
1843
|
-
if observed_scope not in observed_scopes:
|
|
1844
|
-
observed_scopes.append(observed_scope)
|
|
1845
1870
|
requests.append(
|
|
1846
1871
|
self._request(
|
|
1847
1872
|
summary,
|
|
@@ -1851,6 +1876,13 @@ class Processor:
|
|
|
1851
1876
|
native_refs=candidate_native_refs,
|
|
1852
1877
|
)
|
|
1853
1878
|
)
|
|
1879
|
+
for observed_scope in summary["scopes"]:
|
|
1880
|
+
if (
|
|
1881
|
+
isinstance(observed_scope, str)
|
|
1882
|
+
and observed_scope != "unscoped"
|
|
1883
|
+
and observed_scope not in observed_scopes
|
|
1884
|
+
):
|
|
1885
|
+
observed_scopes.append(observed_scope)
|
|
1854
1886
|
return requests, observed_scopes
|
|
1855
1887
|
|
|
1856
1888
|
def _state_for_snapshot_unlocked(self, snapshot: _Snapshot, processed: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
@@ -34,6 +34,15 @@ useful. Related active memories only identify a complete duplicate or valid
|
|
|
34
34
|
state-update target; they are not current evidence and cannot make a non-worthy
|
|
35
35
|
operational/test result worthy. Their item, body, and serialized-character
|
|
36
36
|
context is bounded; ellipses and omissions do not prove a fact is absent.
|
|
37
|
+
For a worthy candidate with scope_source=model, each selected project scope must
|
|
38
|
+
be grounded by this candidate's own memory text: use the project's registered
|
|
39
|
+
scope name or one of its registered aliases; for a new unregistered project
|
|
40
|
+
scope, use that project's name itself. Do not borrow a project name from another
|
|
41
|
+
event, the session background, related memories, or an aggregate mailbox turn.
|
|
42
|
+
If the candidate text does not identify exactly one selected project, choose an
|
|
43
|
+
evidence-supported project, use unscoped with insufficient_context, defer it, or
|
|
44
|
+
set worth=false. user and session_context scope sources may rely on their
|
|
45
|
+
authoritative scope context.
|
|
37
46
|
An update target's existing type is immutable: when the same future use is being
|
|
38
47
|
updated, candidate type must exactly equal the related active target type. If a
|
|
39
48
|
fact serves a different future use, do not force it onto that target; omit
|
|
@@ -156,6 +165,16 @@ When the gate candidate supplies update_memory_id, that target is immutable:
|
|
|
156
165
|
omit update_memory_id or copy that exact active ID, never replace it with a
|
|
157
166
|
different, guessed, native, or historical ID. Keep the summary type identical
|
|
158
167
|
to the gate candidate type and to the active update target type.
|
|
168
|
+
For automatic capture/process, independently re-check whether this candidate
|
|
169
|
+
still has a concrete future-use fact or action. If not, return exactly
|
|
170
|
+
{"decision":"NO_CHANGE"}; this is the only no-write summary response. Use it
|
|
171
|
+
for temporary delegation, waiting for feedback, a one-off meeting arrangement with
|
|
172
|
+
no durable outcome/decision/project constraint, or a subject/attachment-only item
|
|
173
|
+
without a concrete impact or conclusion.
|
|
174
|
+
Explicit remember mode never permits NO_CHANGE and must return the normal summary
|
|
175
|
+
object. When producing a normal automatic summary, copy the gate candidate's
|
|
176
|
+
scopes exactly. scope_source may be omitted to inherit the gate value, but an
|
|
177
|
+
explicit value must match it; never drift to another project.
|
|
159
178
|
For the same use, preserve still-valid facts, add confirmed progress, replace
|
|
160
179
|
contradictions, and never reduce the memory to the latest operation or create a
|
|
161
180
|
sibling with a new label.
|
|
@@ -306,6 +325,28 @@ SUMMARY_TYPE_CORRECTION = (
|
|
|
306
325
|
)
|
|
307
326
|
|
|
308
327
|
|
|
328
|
+
SUMMARY_SCOPE_CORRECTION = (
|
|
329
|
+
"Previous output violated: scope_drift. For a normal automatic summary, copy "
|
|
330
|
+
"the gate candidate scopes exactly and keep its scope_source unchanged. If "
|
|
331
|
+
"scope_source was omitted, inherit the gate value; an explicit value must "
|
|
332
|
+
"match it. Do not move the memory to another project. If the candidate has "
|
|
333
|
+
"no independent future-use fact or action, return exactly "
|
|
334
|
+
'{"decision":"NO_CHANGE"}. Return only the strict JSON object.'
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
SCOPE_GROUNDING_CORRECTION = (
|
|
339
|
+
"Previous output violated: scope_not_grounded. For each worthy candidate "
|
|
340
|
+
"with scope_source=model, choose a project scope named by that candidate's "
|
|
341
|
+
"own memory text: use the registered project name or alias, or the name "
|
|
342
|
+
"itself for a new scope. Do not borrow a name from another event, related "
|
|
343
|
+
"memory, session background, or an aggregate mailbox turn. If exactly one "
|
|
344
|
+
"project cannot be supported, choose the evidence-supported scope, use "
|
|
345
|
+
"unscoped with insufficient_context, defer it, or set worth=false. Return "
|
|
346
|
+
"only the strict gate JSON object."
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
309
350
|
COMPACT_SYSTEM = """You are memleaf's memory compactor. Return JSON only.
|
|
310
351
|
Merge only the supplied low-priority memories when they express compatible
|
|
311
352
|
information. Return an object with a memories array; [] is a safe no-op.
|
|
@@ -398,6 +439,14 @@ def summarize_prompt(
|
|
|
398
439
|
f"Current scope registry (safe projection; no paths):\n"
|
|
399
440
|
f"{_json(scope_registry if scope_registry is not None else [])}\n"
|
|
400
441
|
)
|
|
442
|
+
if not explicit:
|
|
443
|
+
prompt += (
|
|
444
|
+
"Automatic admission re-check: if the candidate has no independent "
|
|
445
|
+
"future-use fact or action after reviewing the evidence, return exactly "
|
|
446
|
+
'{"decision":"NO_CHANGE"}; do not return an empty or partial memory object. '
|
|
447
|
+
"For a normal summary, copy Candidate scopes exactly; omit scope_source "
|
|
448
|
+
"to inherit it or repeat the same value, never choose another scope.\n"
|
|
449
|
+
)
|
|
401
450
|
example_key = _first_event_key(events)
|
|
402
451
|
if example_key is not None:
|
|
403
452
|
candidate_type = candidate.get("type") if isinstance(candidate, dict) else None
|
|
@@ -431,6 +480,8 @@ def summarize_prompt(
|
|
|
431
480
|
}
|
|
432
481
|
)
|
|
433
482
|
)
|
|
483
|
+
if not explicit:
|
|
484
|
+
return prompt + '\nReturn one summary JSON object or exactly {"decision":"NO_CHANGE"}.'
|
|
434
485
|
return prompt + "\nReturn one summary JSON object."
|
|
435
486
|
|
|
436
487
|
|
|
@@ -256,9 +256,20 @@ def _term_matches(query: str, term: str) -> bool:
|
|
|
256
256
|
def resolve_query_project_scope(query: Any, config: Mapping[str, Any]) -> str | None:
|
|
257
257
|
"""Return one unambiguous project scope named or aliased by ``query``."""
|
|
258
258
|
|
|
259
|
+
matches = project_scope_matches_text(query, config)
|
|
260
|
+
return matches[0] if len(matches) == 1 else None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def project_scope_matches_text(query: Any, config: Mapping[str, Any]) -> list[str]:
|
|
264
|
+
"""Return registered project scopes named or aliased by text.
|
|
265
|
+
|
|
266
|
+
Matching uses the same token/substring rules as query scope resolution,
|
|
267
|
+
but retains every match so callers can reject ambiguous model attribution.
|
|
268
|
+
"""
|
|
269
|
+
|
|
259
270
|
query_value = _query_text(query)
|
|
260
271
|
if not query_value:
|
|
261
|
-
return
|
|
272
|
+
return []
|
|
262
273
|
registry = validate_scope_registry(config.get("scopes", {}) if isinstance(config, Mapping) else {})
|
|
263
274
|
matches: set[str] = set()
|
|
264
275
|
for scope, node in registry.items():
|
|
@@ -271,7 +282,7 @@ def resolve_query_project_scope(query: Any, config: Mapping[str, Any]) -> str |
|
|
|
271
282
|
terms.extend(item for item in aliases if isinstance(item, str))
|
|
272
283
|
if any(_term_matches(query_value, term) for term in terms):
|
|
273
284
|
matches.add(scope)
|
|
274
|
-
return
|
|
285
|
+
return sorted(matches, key=str.casefold)
|
|
275
286
|
|
|
276
287
|
|
|
277
288
|
def _resolved_path(value: str | Path, *, base_dir: Path | None = None) -> Path:
|
|
@@ -320,6 +331,7 @@ def resolve_project_path_scope(
|
|
|
320
331
|
__all__ = [
|
|
321
332
|
"ScopeError",
|
|
322
333
|
"normalize_scopes",
|
|
334
|
+
"project_scope_matches_text",
|
|
323
335
|
"register_scope_nodes",
|
|
324
336
|
"resolve_project_path_scope",
|
|
325
337
|
"resolve_query_project_scope",
|
|
@@ -6,7 +6,12 @@ import json
|
|
|
6
6
|
import re
|
|
7
7
|
from typing import Any, Iterable, Mapping
|
|
8
8
|
|
|
9
|
-
from .scope_state import
|
|
9
|
+
from .scope_state import (
|
|
10
|
+
ScopeError,
|
|
11
|
+
project_scope_matches_text,
|
|
12
|
+
validate_scope_key,
|
|
13
|
+
validate_scope_registry,
|
|
14
|
+
)
|
|
10
15
|
|
|
11
16
|
|
|
12
17
|
class ModelOutputError(ValueError):
|
|
@@ -41,6 +46,7 @@ class ModelOutputError(ValueError):
|
|
|
41
46
|
MEMORY_TYPES = frozenset(("preference", "fact", "project", "todo", "event", "identity", "other"))
|
|
42
47
|
SCOPE_SOURCES = frozenset(("model", "user", "session_context", "insufficient_context"))
|
|
43
48
|
TODO_STATUSES = frozenset(("active", "completed", "cancelled"))
|
|
49
|
+
NO_CHANGE_DECISION = "NO_CHANGE"
|
|
44
50
|
MODEL_VALIDATION_REASONS = frozenset(
|
|
45
51
|
("empty_content", "invalid_json", "schema_violation", "response_shape")
|
|
46
52
|
)
|
|
@@ -54,6 +60,8 @@ MODEL_VALIDATION_DETAILS = frozenset(
|
|
|
54
60
|
"duplicate_update_target",
|
|
55
61
|
"mixed_project_scopes",
|
|
56
62
|
"update_target_type_mismatch",
|
|
63
|
+
"scope_not_grounded",
|
|
64
|
+
"scope_drift",
|
|
57
65
|
"invalid_evidence",
|
|
58
66
|
"invalid_flags",
|
|
59
67
|
"invalid_type",
|
|
@@ -249,6 +257,78 @@ def _reject_mixed_project_scopes(scopes: Iterable[str]) -> None:
|
|
|
249
257
|
)
|
|
250
258
|
|
|
251
259
|
|
|
260
|
+
def _reject_ungrounded_project_scope(
|
|
261
|
+
memory: str,
|
|
262
|
+
scopes: Iterable[str],
|
|
263
|
+
scope_registry: Mapping[str, Any] | None,
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Require model-attributed project scopes to be named by this candidate.
|
|
266
|
+
|
|
267
|
+
The gate's memory text is the only candidate-local attribution evidence.
|
|
268
|
+
Session background, other events, and related-memory bodies are
|
|
269
|
+
intentionally excluded so an aggregate mailbox turn cannot lend a
|
|
270
|
+
project name to an unrelated candidate.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
selected = {
|
|
274
|
+
scope.casefold(): scope
|
|
275
|
+
for scope in scopes
|
|
276
|
+
if isinstance(scope, str) and scope.partition(":")[0] == "project"
|
|
277
|
+
}
|
|
278
|
+
if not selected:
|
|
279
|
+
return
|
|
280
|
+
try:
|
|
281
|
+
registry = validate_scope_registry(scope_registry or {})
|
|
282
|
+
except ScopeError as error:
|
|
283
|
+
raise ModelOutputError("invalid scope registry", validation_detail="invalid_scope") from error
|
|
284
|
+
|
|
285
|
+
# Resolve a selected project key through a registered canonical scope when
|
|
286
|
+
# it is an alias (including the full ``project:...`` spelling). A merged
|
|
287
|
+
# source may remain selectable as an alias of its target; treating both as
|
|
288
|
+
# separate projects would incorrectly reject that otherwise valid update.
|
|
289
|
+
selected_owners: dict[str, str] = {}
|
|
290
|
+
for selected_scope in selected.values():
|
|
291
|
+
selected_owner = selected_scope
|
|
292
|
+
for registered_scope, node in registry.items():
|
|
293
|
+
if not registered_scope.startswith("project:"):
|
|
294
|
+
continue
|
|
295
|
+
terms = {
|
|
296
|
+
registered_scope.casefold(),
|
|
297
|
+
registered_scope.split(":", 1)[1].casefold(),
|
|
298
|
+
}
|
|
299
|
+
aliases = node.get("aliases", [])
|
|
300
|
+
if isinstance(aliases, list):
|
|
301
|
+
for alias in aliases:
|
|
302
|
+
if isinstance(alias, str):
|
|
303
|
+
alias_key = alias.casefold()
|
|
304
|
+
terms.add(alias_key)
|
|
305
|
+
if ":" not in alias:
|
|
306
|
+
terms.add(f"project:{alias_key}")
|
|
307
|
+
if selected_scope.casefold() in terms:
|
|
308
|
+
selected_owner = registered_scope
|
|
309
|
+
break
|
|
310
|
+
selected_owners[selected_scope.casefold()] = selected_owner.casefold()
|
|
311
|
+
|
|
312
|
+
# Include unregistered selections as temporary nodes: new scopes must be
|
|
313
|
+
# grounded by their own project name, while registered scopes also accept
|
|
314
|
+
# their configured aliases. All known projects are checked to reject an
|
|
315
|
+
# otherwise ambiguous candidate that names multiple projects.
|
|
316
|
+
candidate_registry = dict(registry)
|
|
317
|
+
for scope in selected.values():
|
|
318
|
+
owner = selected_owners[scope.casefold()]
|
|
319
|
+
if owner not in {registered.casefold() for registered in registry}:
|
|
320
|
+
candidate_registry.setdefault(scope, {})
|
|
321
|
+
matches = {
|
|
322
|
+
scope.casefold()
|
|
323
|
+
for scope in project_scope_matches_text(memory, {"scopes": candidate_registry})
|
|
324
|
+
}
|
|
325
|
+
if len(matches) != 1 or not matches.intersection(selected_owners.values()):
|
|
326
|
+
raise ModelOutputError(
|
|
327
|
+
"model project scope is not grounded by this candidate",
|
|
328
|
+
validation_detail="scope_not_grounded",
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
252
332
|
def _memory_id(value: Any, field: str) -> str:
|
|
253
333
|
result = _string(value, field)
|
|
254
334
|
if "/" in result or "\\" in result or result in (".", ".."):
|
|
@@ -420,6 +500,7 @@ def validate_gate_output(
|
|
|
420
500
|
current_event_keys: Iterable[Any] | None = None,
|
|
421
501
|
related_memory_ids: Iterable[Any] | None = None,
|
|
422
502
|
related_memory_types: Mapping[str, Any] | None = None,
|
|
503
|
+
scope_registry: Mapping[str, Any] | None = None,
|
|
423
504
|
) -> dict[str, Any]:
|
|
424
505
|
"""Validate and return a normalized gate object without writing anything."""
|
|
425
506
|
|
|
@@ -559,6 +640,8 @@ def validate_gate_output(
|
|
|
559
640
|
item["scopes"] = _scopes(item["scopes"], item["scope_source"])
|
|
560
641
|
if item["worth"]:
|
|
561
642
|
_reject_mixed_project_scopes(item["scopes"])
|
|
643
|
+
if item["scope_source"] == "model":
|
|
644
|
+
_reject_ungrounded_project_scope(item["memory"], item["scopes"], scope_registry)
|
|
562
645
|
if "reason" in item:
|
|
563
646
|
_string(item["reason"], "reason", nonempty=False)
|
|
564
647
|
if len(item["reason"]) > 30:
|
|
@@ -575,6 +658,7 @@ def parse_gate_output(
|
|
|
575
658
|
current_event_keys: Iterable[Any] | None = None,
|
|
576
659
|
related_memory_ids: Iterable[Any] | None = None,
|
|
577
660
|
related_memory_types: Mapping[str, Any] | None = None,
|
|
661
|
+
scope_registry: Mapping[str, Any] | None = None,
|
|
578
662
|
) -> dict[str, Any]:
|
|
579
663
|
try:
|
|
580
664
|
parsed = parse_strict_json(raw)
|
|
@@ -589,6 +673,7 @@ def parse_gate_output(
|
|
|
589
673
|
current_event_keys=current_event_keys,
|
|
590
674
|
related_memory_ids=related_memory_ids,
|
|
591
675
|
related_memory_types=related_memory_types,
|
|
676
|
+
scope_registry=scope_registry,
|
|
592
677
|
)
|
|
593
678
|
except ModelOutputError as error:
|
|
594
679
|
if error.validation_reason is None:
|
|
@@ -637,6 +722,9 @@ def validate_summarize_output(
|
|
|
637
722
|
expected_type: str | None = None,
|
|
638
723
|
expected_update_memory_id: str | None = None,
|
|
639
724
|
expected_target_type: str | None = None,
|
|
725
|
+
expected_scopes: Iterable[Any] | None = None,
|
|
726
|
+
expected_scope_source: str | None = None,
|
|
727
|
+
allow_no_change: bool = False,
|
|
640
728
|
) -> dict[str, Any]:
|
|
641
729
|
"""Validate one atomic memory summary; this function has no filesystem effects."""
|
|
642
730
|
|
|
@@ -644,6 +732,13 @@ def validate_summarize_output(
|
|
|
644
732
|
value = parse_strict_json(value)
|
|
645
733
|
if not isinstance(value, Mapping):
|
|
646
734
|
raise ModelOutputError("summarize output must be an object", validation_detail="root_shape")
|
|
735
|
+
if "decision" in value:
|
|
736
|
+
if allow_no_change and dict(value) == {"decision": NO_CHANGE_DECISION}:
|
|
737
|
+
return {"decision": NO_CHANGE_DECISION}
|
|
738
|
+
raise ModelOutputError(
|
|
739
|
+
"NO_CHANGE is only valid as the exact automatic no-write response",
|
|
740
|
+
validation_detail="unknown_fields",
|
|
741
|
+
)
|
|
647
742
|
allowed = {
|
|
648
743
|
"memory_id",
|
|
649
744
|
"update_memory_id",
|
|
@@ -681,8 +776,24 @@ def validate_summarize_output(
|
|
|
681
776
|
"summary type does not match update target",
|
|
682
777
|
validation_detail="invalid_type",
|
|
683
778
|
)
|
|
684
|
-
|
|
779
|
+
summary_scope_source = item.get("scope_source")
|
|
780
|
+
if "scope_source" not in item and expected_scope_source is not None:
|
|
781
|
+
summary_scope_source = expected_scope_source
|
|
782
|
+
item["scope_source"] = summary_scope_source
|
|
783
|
+
item["scopes"] = _scopes(item["scopes"], summary_scope_source)
|
|
685
784
|
_reject_mixed_project_scopes(item["scopes"])
|
|
785
|
+
if expected_scopes is not None:
|
|
786
|
+
expected_scope_values = list(expected_scopes)
|
|
787
|
+
if item["scopes"] != expected_scope_values:
|
|
788
|
+
raise ModelOutputError(
|
|
789
|
+
"summary scopes differ from gate candidate",
|
|
790
|
+
validation_detail="scope_drift",
|
|
791
|
+
)
|
|
792
|
+
if expected_scope_source is not None and summary_scope_source != expected_scope_source:
|
|
793
|
+
raise ModelOutputError(
|
|
794
|
+
"summary scope source differs from gate candidate",
|
|
795
|
+
validation_detail="scope_drift",
|
|
796
|
+
)
|
|
686
797
|
item["scope_operations"] = _scope_operations(
|
|
687
798
|
item.get("scope_operations"),
|
|
688
799
|
summary_scopes=item["scopes"],
|
|
@@ -770,6 +881,9 @@ def parse_summarize_output(
|
|
|
770
881
|
expected_type: str | None = None,
|
|
771
882
|
expected_update_memory_id: str | None = None,
|
|
772
883
|
expected_target_type: str | None = None,
|
|
884
|
+
expected_scopes: Iterable[Any] | None = None,
|
|
885
|
+
expected_scope_source: str | None = None,
|
|
886
|
+
allow_no_change: bool = False,
|
|
773
887
|
) -> dict[str, Any]:
|
|
774
888
|
try:
|
|
775
889
|
parsed = parse_strict_json(raw)
|
|
@@ -787,6 +901,9 @@ def parse_summarize_output(
|
|
|
787
901
|
expected_type=expected_type,
|
|
788
902
|
expected_update_memory_id=expected_update_memory_id,
|
|
789
903
|
expected_target_type=expected_target_type,
|
|
904
|
+
expected_scopes=expected_scopes,
|
|
905
|
+
expected_scope_source=expected_scope_source,
|
|
906
|
+
allow_no_change=allow_no_change,
|
|
790
907
|
)
|
|
791
908
|
except ModelOutputError as error:
|
|
792
909
|
if error.validation_reason is None:
|
|
@@ -894,6 +1011,7 @@ __all__ = [
|
|
|
894
1011
|
"MODEL_VALIDATION_DETAILS",
|
|
895
1012
|
"MODEL_VALIDATION_REASONS",
|
|
896
1013
|
"ModelOutputError",
|
|
1014
|
+
"NO_CHANGE_DECISION",
|
|
897
1015
|
"SCOPE_SOURCES",
|
|
898
1016
|
"TODO_STATUSES",
|
|
899
1017
|
"parse_gate",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
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.8。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.8 通过 PyPI 分发。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
30
30
|
## 项目定位
|
|
@@ -430,19 +430,17 @@ class AdmissionFlowTests(unittest.TestCase):
|
|
|
430
430
|
)
|
|
431
431
|
)
|
|
432
432
|
query_summary["scope_source"] = "session_context"
|
|
433
|
+
query_candidate = candidate(
|
|
434
|
+
"owner-query-update",
|
|
435
|
+
[user_key, assistant_key],
|
|
436
|
+
memory=active_before.body,
|
|
437
|
+
type="identity",
|
|
438
|
+
update_memory_id=old.memory_id,
|
|
439
|
+
)
|
|
440
|
+
query_candidate["scope_source"] = "session_context"
|
|
433
441
|
backend.responses.extend(
|
|
434
442
|
[
|
|
435
|
-
gate(
|
|
436
|
-
[
|
|
437
|
-
candidate(
|
|
438
|
-
"owner-query-update",
|
|
439
|
-
[user_key, assistant_key],
|
|
440
|
-
memory=active_before.body,
|
|
441
|
-
type="identity",
|
|
442
|
-
update_memory_id=old.memory_id,
|
|
443
|
-
)
|
|
444
|
-
]
|
|
445
|
-
),
|
|
443
|
+
gate([query_candidate]),
|
|
446
444
|
json.dumps(query_summary, ensure_ascii=False),
|
|
447
445
|
]
|
|
448
446
|
)
|
|
@@ -66,7 +66,10 @@ class CrossHostAcceptanceTests(unittest.TestCase):
|
|
|
66
66
|
session = f"{writer_host}-session-a"
|
|
67
67
|
turn = "turn-1"
|
|
68
68
|
title = f"{writer_host} to {reader_host}"
|
|
69
|
-
body =
|
|
69
|
+
body = (
|
|
70
|
+
f"cross-host project durable fact written by {writer_host} "
|
|
71
|
+
f"and recalled by {reader_host}."
|
|
72
|
+
)
|
|
70
73
|
|
|
71
74
|
writer_service = Memleaf(
|
|
72
75
|
vault,
|
|
@@ -1768,6 +1768,25 @@ class HermesProviderTests(unittest.TestCase):
|
|
|
1768
1768
|
self.assertIsNotNone(fields)
|
|
1769
1769
|
self.assertEqual(fields[4], "update_target_type_mismatch")
|
|
1770
1770
|
|
|
1771
|
+
def test_scope_validation_details_are_preserved(self) -> None:
|
|
1772
|
+
for detail in ("scope_not_grounded", "scope_drift"):
|
|
1773
|
+
with self.subTest(detail=detail):
|
|
1774
|
+
value = {
|
|
1775
|
+
"isError": True,
|
|
1776
|
+
"structuredContent": {
|
|
1777
|
+
"error": {
|
|
1778
|
+
"code": "model_invalid_response",
|
|
1779
|
+
"stage": "gate" if detail == "scope_not_grounded" else "summarize",
|
|
1780
|
+
"validation_reason": "schema_violation",
|
|
1781
|
+
"validation_detail": detail,
|
|
1782
|
+
"attempt_count": 3,
|
|
1783
|
+
}
|
|
1784
|
+
},
|
|
1785
|
+
}
|
|
1786
|
+
fields = provider_module._mcp_error_fields(value)
|
|
1787
|
+
self.assertIsNotNone(fields)
|
|
1788
|
+
self.assertEqual(fields[4], detail)
|
|
1789
|
+
|
|
1771
1790
|
def test_relative_time_validation_detail_is_preserved_and_logged(self) -> None:
|
|
1772
1791
|
value = {
|
|
1773
1792
|
"isError": True,
|