memleaf 0.2.9__tar.gz → 0.2.10__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.9 → memleaf-0.2.10}/CHANGELOG.md +12 -0
- {memleaf-0.2.9/src/memleaf.egg-info → memleaf-0.2.10}/PKG-INFO +3 -3
- {memleaf-0.2.9 → memleaf-0.2.10}/README.en.md +2 -2
- {memleaf-0.2.9 → memleaf-0.2.10}/README.md +2 -2
- {memleaf-0.2.9 → memleaf-0.2.10}/pyproject.toml +1 -1
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/processing.py +50 -1
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/validation.py +45 -21
- {memleaf-0.2.9 → memleaf-0.2.10/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_pypi_install.py +1 -1
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b2a.py +273 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_c3_packaging.py +1 -1
- {memleaf-0.2.9 → memleaf-0.2.10}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/LICENSE +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/MANIFEST.in +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/examples/README.md +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/examples/basic_usage.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/install.ps1 +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/install.sh +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/setup.cfg +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/config.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/index.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/models.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/prompts.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/service.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf.egg-info/SOURCES.txt +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/__init__.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_admission_noise.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_hermes_provider.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_host_events.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_install.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_maintenance_v2.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b1.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.10 — 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Keep strict candidate-local project Scope grounding for the first two gate
|
|
8
|
+
attempts, then isolate a persistently invalid candidate instead of failing
|
|
9
|
+
the entire mailbox turn.
|
|
10
|
+
- Correct a final-attempt model Scope only when the candidate text identifies
|
|
11
|
+
exactly one registered project name or alias; defer zero-match and
|
|
12
|
+
multi-project candidates as insufficient context without writing memory.
|
|
13
|
+
- Preserve valid sibling candidates, new explicitly named project Scopes, and
|
|
14
|
+
the existing update-target relevance guard when Scope and target errors occur
|
|
15
|
+
together.
|
|
16
|
+
|
|
5
17
|
## 0.2.9 — 2026-09-01
|
|
6
18
|
|
|
7
19
|
- Reject update and duplicate targets that are relevant only to another item
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.10
|
|
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.10。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.10 通过 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.10.**
|
|
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.10 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.10。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.10 通过 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.10"
|
|
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
|
|
@@ -49,12 +49,17 @@ from .prompts import (
|
|
|
49
49
|
)
|
|
50
50
|
from .redaction import redact_text
|
|
51
51
|
from .retrieval import candidate_matches_query, filter_by_scope, normalize_term
|
|
52
|
-
from .scope_state import
|
|
52
|
+
from .scope_state import (
|
|
53
|
+
ScopeError,
|
|
54
|
+
normalize_scopes,
|
|
55
|
+
register_scope_nodes,
|
|
56
|
+
)
|
|
53
57
|
from .scope_maintenance import ScopeMaintainer, ScopeMaintenanceError, scope_registry_projection
|
|
54
58
|
from .validation import (
|
|
55
59
|
MODEL_VALIDATION_DETAILS,
|
|
56
60
|
ModelOutputError,
|
|
57
61
|
NO_CHANGE_DECISION,
|
|
62
|
+
_model_scope_grounding_evidence,
|
|
58
63
|
parse_gate_output,
|
|
59
64
|
parse_summarize_output,
|
|
60
65
|
)
|
|
@@ -1808,7 +1813,51 @@ class Processor:
|
|
|
1808
1813
|
related_memory_ids=gate_related_memory_ids,
|
|
1809
1814
|
related_memory_types=gate_related_memory_types,
|
|
1810
1815
|
scope_registry=validation_scope_registry,
|
|
1816
|
+
enforce_model_scope_grounding=gate_attempt_count < 3,
|
|
1811
1817
|
)
|
|
1818
|
+
if gate_attempt_count >= 3:
|
|
1819
|
+
candidates = []
|
|
1820
|
+
for candidate in parsed["candidates"]:
|
|
1821
|
+
candidate_scopes = [
|
|
1822
|
+
item
|
|
1823
|
+
for item in candidate.get("scopes", [])
|
|
1824
|
+
if isinstance(item, str)
|
|
1825
|
+
]
|
|
1826
|
+
if (
|
|
1827
|
+
candidate.get("scope_source") == "model"
|
|
1828
|
+
and candidate.get("worth") is True
|
|
1829
|
+
and any(item.partition(":")[0] == "project" for item in candidate_scopes)
|
|
1830
|
+
):
|
|
1831
|
+
selected_owners, matches = _model_scope_grounding_evidence(
|
|
1832
|
+
str(candidate.get("memory", "")),
|
|
1833
|
+
candidate_scopes,
|
|
1834
|
+
validation_scope_registry,
|
|
1835
|
+
)
|
|
1836
|
+
candidate = dict(candidate)
|
|
1837
|
+
if not selected_owners:
|
|
1838
|
+
candidates.append(candidate)
|
|
1839
|
+
continue
|
|
1840
|
+
if len(matches) == 1:
|
|
1841
|
+
grounded_scope = next(iter(matches.values()))
|
|
1842
|
+
if grounded_scope.casefold() not in set(selected_owners.values()):
|
|
1843
|
+
non_project_scopes = [
|
|
1844
|
+
item
|
|
1845
|
+
for item in candidate_scopes
|
|
1846
|
+
if item.partition(":")[0] != "project"
|
|
1847
|
+
]
|
|
1848
|
+
if all(
|
|
1849
|
+
item.casefold() != grounded_scope.casefold()
|
|
1850
|
+
for item in non_project_scopes
|
|
1851
|
+
):
|
|
1852
|
+
non_project_scopes.append(grounded_scope)
|
|
1853
|
+
candidate["scopes"] = non_project_scopes
|
|
1854
|
+
else:
|
|
1855
|
+
candidate["scopes"] = ["unscoped"]
|
|
1856
|
+
candidate["scope_source"] = "insufficient_context"
|
|
1857
|
+
candidates.append(candidate)
|
|
1858
|
+
parsed = dict(parsed)
|
|
1859
|
+
parsed["candidates"] = candidates
|
|
1860
|
+
|
|
1812
1861
|
invalid_targets: dict[str, set[str]] = {}
|
|
1813
1862
|
for candidate in parsed["candidates"]:
|
|
1814
1863
|
target_fields = {
|
|
@@ -258,17 +258,16 @@ def _reject_mixed_project_scopes(scopes: Iterable[str]) -> None:
|
|
|
258
258
|
)
|
|
259
259
|
|
|
260
260
|
|
|
261
|
-
def
|
|
261
|
+
def _model_scope_grounding_evidence(
|
|
262
262
|
memory: str,
|
|
263
263
|
scopes: Iterable[str],
|
|
264
264
|
scope_registry: Mapping[str, Any] | None,
|
|
265
|
-
) ->
|
|
266
|
-
"""
|
|
265
|
+
) -> tuple[dict[str, str], dict[str, str]]:
|
|
266
|
+
"""Return model scope ownership and memory-grounded matches.
|
|
267
267
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
project name to an unrelated candidate.
|
|
268
|
+
Returns a tuple of:
|
|
269
|
+
- selected project scopes -> selected owner scope (casefold)
|
|
270
|
+
- grounded project scope matches from candidate memory (casefold -> canonical)
|
|
272
271
|
"""
|
|
273
272
|
|
|
274
273
|
selected = {
|
|
@@ -277,16 +276,14 @@ def _reject_ungrounded_project_scope(
|
|
|
277
276
|
if isinstance(scope, str) and scope.partition(":")[0] == "project"
|
|
278
277
|
}
|
|
279
278
|
if not selected:
|
|
280
|
-
return
|
|
279
|
+
return {}, {}
|
|
281
280
|
try:
|
|
282
281
|
registry = validate_scope_registry(scope_registry or {})
|
|
283
282
|
except ScopeError as error:
|
|
284
283
|
raise ModelOutputError("invalid scope registry", validation_detail="invalid_scope") from error
|
|
285
284
|
|
|
286
|
-
# Resolve
|
|
287
|
-
#
|
|
288
|
-
# source may remain selectable as an alias of its target; treating both as
|
|
289
|
-
# separate projects would incorrectly reject that otherwise valid update.
|
|
285
|
+
# Resolve each selected project scope to its canonical owner when it is an
|
|
286
|
+
# alias of a registered project scope.
|
|
290
287
|
selected_owners: dict[str, str] = {}
|
|
291
288
|
for selected_scope in selected.values():
|
|
292
289
|
selected_owner = selected_scope
|
|
@@ -311,19 +308,42 @@ def _reject_ungrounded_project_scope(
|
|
|
311
308
|
selected_owners[selected_scope.casefold()] = selected_owner.casefold()
|
|
312
309
|
|
|
313
310
|
# Include unregistered selections as temporary nodes: new scopes must be
|
|
314
|
-
# grounded by their own project name
|
|
315
|
-
#
|
|
316
|
-
# otherwise ambiguous candidate that names multiple projects.
|
|
311
|
+
# grounded by their own project name. All known projects are still checked
|
|
312
|
+
# to reject ambiguous memory wording.
|
|
317
313
|
candidate_registry = dict(registry)
|
|
318
|
-
for
|
|
319
|
-
owner = selected_owners[
|
|
314
|
+
for selected_scope in selected.values():
|
|
315
|
+
owner = selected_owners[selected_scope.casefold()]
|
|
320
316
|
if owner not in {registered.casefold() for registered in registry}:
|
|
321
|
-
candidate_registry.setdefault(
|
|
317
|
+
candidate_registry.setdefault(selected_scope, {})
|
|
318
|
+
|
|
322
319
|
matches = {
|
|
323
|
-
scope.casefold()
|
|
320
|
+
scope.casefold(): scope
|
|
324
321
|
for scope in project_scope_matches_text(memory, {"scopes": candidate_registry})
|
|
325
322
|
}
|
|
326
|
-
|
|
323
|
+
return selected_owners, matches
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _reject_ungrounded_project_scope(
|
|
327
|
+
memory: str,
|
|
328
|
+
scopes: Iterable[str],
|
|
329
|
+
scope_registry: Mapping[str, Any] | None,
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Require model-attributed project scopes to be named by this candidate.
|
|
332
|
+
|
|
333
|
+
The gate's memory text is the only candidate-local attribution evidence.
|
|
334
|
+
Session background, other events, and related-memory bodies are
|
|
335
|
+
intentionally excluded so an aggregate mailbox turn cannot lend a
|
|
336
|
+
project name to an unrelated candidate.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
selected_owners, matches = _model_scope_grounding_evidence(
|
|
340
|
+
memory,
|
|
341
|
+
scopes,
|
|
342
|
+
scope_registry,
|
|
343
|
+
)
|
|
344
|
+
if not selected_owners:
|
|
345
|
+
return
|
|
346
|
+
if len(matches) != 1 or matches.keys().isdisjoint(set(selected_owners.values())):
|
|
327
347
|
raise ModelOutputError(
|
|
328
348
|
"model project scope is not grounded by this candidate",
|
|
329
349
|
validation_detail="scope_not_grounded",
|
|
@@ -502,6 +522,7 @@ def validate_gate_output(
|
|
|
502
522
|
related_memory_ids: Iterable[Any] | None = None,
|
|
503
523
|
related_memory_types: Mapping[str, Any] | None = None,
|
|
504
524
|
scope_registry: Mapping[str, Any] | None = None,
|
|
525
|
+
enforce_model_scope_grounding: bool = True,
|
|
505
526
|
) -> dict[str, Any]:
|
|
506
527
|
"""Validate and return a normalized gate object without writing anything."""
|
|
507
528
|
|
|
@@ -642,7 +663,8 @@ def validate_gate_output(
|
|
|
642
663
|
if item["worth"]:
|
|
643
664
|
_reject_mixed_project_scopes(item["scopes"])
|
|
644
665
|
if item["scope_source"] == "model":
|
|
645
|
-
|
|
666
|
+
if enforce_model_scope_grounding:
|
|
667
|
+
_reject_ungrounded_project_scope(item["memory"], item["scopes"], scope_registry)
|
|
646
668
|
if "reason" in item:
|
|
647
669
|
_string(item["reason"], "reason", nonempty=False)
|
|
648
670
|
if len(item["reason"]) > 30:
|
|
@@ -660,6 +682,7 @@ def parse_gate_output(
|
|
|
660
682
|
related_memory_ids: Iterable[Any] | None = None,
|
|
661
683
|
related_memory_types: Mapping[str, Any] | None = None,
|
|
662
684
|
scope_registry: Mapping[str, Any] | None = None,
|
|
685
|
+
enforce_model_scope_grounding: bool = True,
|
|
663
686
|
) -> dict[str, Any]:
|
|
664
687
|
try:
|
|
665
688
|
parsed = parse_strict_json(raw)
|
|
@@ -675,6 +698,7 @@ def parse_gate_output(
|
|
|
675
698
|
related_memory_ids=related_memory_ids,
|
|
676
699
|
related_memory_types=related_memory_types,
|
|
677
700
|
scope_registry=scope_registry,
|
|
701
|
+
enforce_model_scope_grounding=enforce_model_scope_grounding,
|
|
678
702
|
)
|
|
679
703
|
except ModelOutputError as error:
|
|
680
704
|
if error.validation_reason is None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.10
|
|
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.10。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.10 通过 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.10", (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:
|
|
@@ -266,6 +266,279 @@ class StageB2ATest(unittest.TestCase):
|
|
|
266
266
|
self.assertNotIn("project:浙江东方", state.get("scopes", []))
|
|
267
267
|
self.assertNotIn("project:浙江东方", service.vault.config()["scopes"])
|
|
268
268
|
|
|
269
|
+
def test_gate_scope_not_grounded_final_retry_corrects_by_unique_memory_match(self):
|
|
270
|
+
backend = QueueBackend()
|
|
271
|
+
service = self.service(backend, name="scope-final-correct")
|
|
272
|
+
config = service.vault.config()
|
|
273
|
+
config["scopes"] = {
|
|
274
|
+
"project:zhongyin": {"aliases": ["中银国际"]},
|
|
275
|
+
"project:金元顺安": {"aliases": ["金元顺安"]},
|
|
276
|
+
}
|
|
277
|
+
save_config(service.vault.config_path, config)
|
|
278
|
+
user_key, _ = self.capture_turn(
|
|
279
|
+
service,
|
|
280
|
+
turn="scope-final-correct",
|
|
281
|
+
user_event="scope-final-correct-user",
|
|
282
|
+
assistant_event="scope-final-correct-assistant",
|
|
283
|
+
user="中银国际实施计划需要更新。",
|
|
284
|
+
assistant="确认中银国际计划更新。",
|
|
285
|
+
)
|
|
286
|
+
wrong = self.candidate(
|
|
287
|
+
"wrong-zg-scope",
|
|
288
|
+
[user_key],
|
|
289
|
+
memory="中银国际实施计划需要更新。",
|
|
290
|
+
type="project",
|
|
291
|
+
)
|
|
292
|
+
wrong["scopes"] = ["project:金元顺安"]
|
|
293
|
+
backend.responses.extend(
|
|
294
|
+
[
|
|
295
|
+
self.gate([wrong]),
|
|
296
|
+
self.gate([wrong]),
|
|
297
|
+
self.gate([wrong]),
|
|
298
|
+
self.summary(
|
|
299
|
+
user_key,
|
|
300
|
+
title="中银国际实施计划",
|
|
301
|
+
body="中银国际实施计划需要更新。",
|
|
302
|
+
type="project",
|
|
303
|
+
scopes=["project:zhongyin"],
|
|
304
|
+
),
|
|
305
|
+
]
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
result = service.process(source="codex", session_id="s", model=backend)
|
|
309
|
+
|
|
310
|
+
self.assertEqual(result["memories_written"], 1)
|
|
311
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate", "summarize"])
|
|
312
|
+
self.assertIn("Previous output violated: scope_not_grounded.", backend.calls[1]["prompt"])
|
|
313
|
+
self.assertEqual(self.knowledge(service)[0].memory.scopes, ["project:zhongyin"])
|
|
314
|
+
self.assertIn("project:zhongyin", service.vault.config()["scopes"])
|
|
315
|
+
|
|
316
|
+
def test_scope_not_grounded_final_retry_keeps_other_valid_candidates(self):
|
|
317
|
+
backend = QueueBackend()
|
|
318
|
+
service = self.service(backend, name="scope-final-keep-valid")
|
|
319
|
+
config = service.vault.config()
|
|
320
|
+
config["scopes"] = {
|
|
321
|
+
"project:zhongyin": {"aliases": ["中银国际"]},
|
|
322
|
+
"project:xinyuan": {"aliases": ["鑫元基金"]},
|
|
323
|
+
}
|
|
324
|
+
save_config(service.vault.config_path, config)
|
|
325
|
+
user_key, assistant_key = self.capture_turn(
|
|
326
|
+
service,
|
|
327
|
+
turn="scope-final-keep-valid",
|
|
328
|
+
user_event="scope-final-keep-valid-user",
|
|
329
|
+
assistant_event="scope-final-keep-valid-assistant",
|
|
330
|
+
user="中银国际计划已更新;鑫元待反馈。",
|
|
331
|
+
assistant="待确认中银国际与鑫元相关事项。",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
valid = self.candidate(
|
|
335
|
+
"zhongyin-plan",
|
|
336
|
+
[user_key],
|
|
337
|
+
memory="中银国际计划已更新。",
|
|
338
|
+
type="project",
|
|
339
|
+
)
|
|
340
|
+
valid["scopes"] = ["project:zhongyin"]
|
|
341
|
+
bad = self.candidate(
|
|
342
|
+
"xinyuan-ambiguous",
|
|
343
|
+
[assistant_key],
|
|
344
|
+
memory="鑫元 待反馈。",
|
|
345
|
+
type="event",
|
|
346
|
+
)
|
|
347
|
+
bad["scopes"] = ["project:xinyuan"]
|
|
348
|
+
backend.responses.extend(
|
|
349
|
+
[
|
|
350
|
+
self.gate([bad, valid]),
|
|
351
|
+
self.gate([bad, valid]),
|
|
352
|
+
self.gate([bad, valid]),
|
|
353
|
+
self.summary(
|
|
354
|
+
user_key,
|
|
355
|
+
title="中银国际计划",
|
|
356
|
+
body="中银国际计划已更新。",
|
|
357
|
+
type="project",
|
|
358
|
+
scopes=["project:zhongyin"],
|
|
359
|
+
),
|
|
360
|
+
]
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
result = service.process(source="codex", session_id="s", model=backend)
|
|
364
|
+
|
|
365
|
+
self.assertEqual(result["memories_written"], 1)
|
|
366
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate", "summarize"])
|
|
367
|
+
self.assertIn("Previous output violated: scope_not_grounded.", backend.calls[1]["prompt"])
|
|
368
|
+
self.assertEqual(len(self.knowledge(service)), 1)
|
|
369
|
+
self.assertEqual(self.knowledge(service)[0].memory.scopes, ["project:zhongyin"])
|
|
370
|
+
turn_entry = self.processed(service)["sessions"]["codex/s"]["processed_turns"][0]
|
|
371
|
+
deferred = turn_entry["deferred_candidates"]
|
|
372
|
+
self.assertEqual(len(deferred), 1)
|
|
373
|
+
self.assertEqual(deferred[0]["candidate_id"], "xinyuan-ambiguous")
|
|
374
|
+
self.assertNotIn("鑫元", "\n".join(record.memory.body for record in self.knowledge(service)))
|
|
375
|
+
|
|
376
|
+
def test_scope_not_grounded_final_retry_ambiguous_mention_no_write(self):
|
|
377
|
+
backend = QueueBackend()
|
|
378
|
+
service = self.service(backend, name="scope-final-ambiguous")
|
|
379
|
+
config = service.vault.config()
|
|
380
|
+
config["scopes"] = {
|
|
381
|
+
"project:zhongyin": {"aliases": ["中银国际"]},
|
|
382
|
+
"project:jinyuan": {"aliases": ["金元顺安"]},
|
|
383
|
+
}
|
|
384
|
+
save_config(service.vault.config_path, config)
|
|
385
|
+
user_key, _ = self.capture_turn(
|
|
386
|
+
service,
|
|
387
|
+
turn="scope-final-ambiguous",
|
|
388
|
+
user_event="scope-final-ambiguous-user",
|
|
389
|
+
assistant_event="scope-final-ambiguous-assistant",
|
|
390
|
+
user="中银国际和金元顺安均有更新。",
|
|
391
|
+
assistant="已确认两个项目的进展。",
|
|
392
|
+
)
|
|
393
|
+
wrong = self.candidate(
|
|
394
|
+
"both-mentioned",
|
|
395
|
+
[user_key],
|
|
396
|
+
memory="中银国际和金元顺安均有更新。",
|
|
397
|
+
type="fact",
|
|
398
|
+
)
|
|
399
|
+
wrong["scopes"] = ["project:zhongyin"]
|
|
400
|
+
backend.responses.extend([self.gate([wrong]), self.gate([wrong]), self.gate([wrong])])
|
|
401
|
+
|
|
402
|
+
result = service.process(source="codex", session_id="s", model=backend)
|
|
403
|
+
|
|
404
|
+
self.assertEqual(result["memories_written"], 0)
|
|
405
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate"])
|
|
406
|
+
self.assertEqual(self.knowledge(service), [])
|
|
407
|
+
turn_entry = self.processed(service)["sessions"]["codex/s"]["processed_turns"][0]
|
|
408
|
+
deferred = turn_entry["deferred_candidates"]
|
|
409
|
+
self.assertEqual(len(deferred), 1)
|
|
410
|
+
self.assertEqual(deferred[0]["candidate_id"], "both-mentioned")
|
|
411
|
+
self.assertEqual(deferred[0]["reason"], "scope_required")
|
|
412
|
+
self.assertEqual(self.processed(service)["sessions"]["codex/s"]["processing"]["status"], "idle")
|
|
413
|
+
|
|
414
|
+
def test_scope_not_grounded_final_retry_wrong_scope_and_wrong_update_target_is_safe(self):
|
|
415
|
+
backend = QueueBackend()
|
|
416
|
+
service = self.service(backend, name="scope-final-wrong-scope-target")
|
|
417
|
+
config = service.vault.config()
|
|
418
|
+
config["scopes"] = {
|
|
419
|
+
"project:alpha": {"aliases": ["阿尔法"]},
|
|
420
|
+
"project:beta": {"aliases": ["贝塔"]},
|
|
421
|
+
}
|
|
422
|
+
save_config(service.vault.config_path, config)
|
|
423
|
+
old = service.create_memory(
|
|
424
|
+
memory_id="alpha-existing",
|
|
425
|
+
title="阿尔法 项目负责人",
|
|
426
|
+
body="阿尔法 项目负责人当前为甲。",
|
|
427
|
+
type="identity",
|
|
428
|
+
scopes=["project:alpha"],
|
|
429
|
+
)
|
|
430
|
+
user_key, _ = self.capture_turn(
|
|
431
|
+
service,
|
|
432
|
+
turn="scope-final-wrong-scope-target",
|
|
433
|
+
user_event="scope-final-wrong-scope-target-user",
|
|
434
|
+
assistant_event="scope-final-wrong-scope-target-assistant",
|
|
435
|
+
user="阿尔法 项目负责人当前为甲;贝塔 项目负责人更新为乙。",
|
|
436
|
+
assistant="已确认两个项目的负责人信息。",
|
|
437
|
+
)
|
|
438
|
+
wrong = self.candidate(
|
|
439
|
+
"wrong-scope-target",
|
|
440
|
+
[user_key],
|
|
441
|
+
memory="贝塔 项目负责人更新为乙。",
|
|
442
|
+
type="identity",
|
|
443
|
+
update_memory_id=old.memory_id,
|
|
444
|
+
)
|
|
445
|
+
wrong["scopes"] = ["project:alpha"]
|
|
446
|
+
backend.responses.extend(
|
|
447
|
+
[
|
|
448
|
+
self.gate([wrong]),
|
|
449
|
+
self.gate([wrong]),
|
|
450
|
+
self.gate([wrong]),
|
|
451
|
+
self.summary(
|
|
452
|
+
user_key,
|
|
453
|
+
title="贝塔 项目负责人",
|
|
454
|
+
body="贝塔 项目负责人更新为乙。",
|
|
455
|
+
type="identity",
|
|
456
|
+
scopes=["project:beta"],
|
|
457
|
+
),
|
|
458
|
+
]
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
result = service.process(source="codex", session_id="s", model=backend)
|
|
462
|
+
|
|
463
|
+
self.assertEqual(result["memories_written"], 1)
|
|
464
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate", "summarize"])
|
|
465
|
+
self.assertIn("Previous output violated: scope_not_grounded.", backend.calls[1]["prompt"])
|
|
466
|
+
self.assertEqual(service.read(old.memory_id).body, old.body)
|
|
467
|
+
self.assertEqual(len(self.knowledge(service)), 2)
|
|
468
|
+
self.assertIn(
|
|
469
|
+
"贝塔 项目负责人更新为乙。",
|
|
470
|
+
"\n".join(record.memory.body for record in self.knowledge(service)),
|
|
471
|
+
)
|
|
472
|
+
self.assertEqual(
|
|
473
|
+
{
|
|
474
|
+
tuple(record.memory.scopes): record.memory.body
|
|
475
|
+
for record in self.knowledge(service)
|
|
476
|
+
}[("project:beta",)],
|
|
477
|
+
"贝塔 项目负责人更新为乙。",
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def test_scope_not_grounded_final_retry_preserves_unregistered_project_scope(self):
|
|
481
|
+
backend = QueueBackend()
|
|
482
|
+
service = self.service(backend, name="scope-final-unregistered-legal")
|
|
483
|
+
config = service.vault.config()
|
|
484
|
+
config["scopes"] = {
|
|
485
|
+
"project:alpha": {"aliases": ["阿尔法"]},
|
|
486
|
+
}
|
|
487
|
+
save_config(service.vault.config_path, config)
|
|
488
|
+
old = service.create_memory(
|
|
489
|
+
memory_id="alpha-existing",
|
|
490
|
+
title="阿尔法 项目状态",
|
|
491
|
+
body="阿尔法 项目状态保持不变。",
|
|
492
|
+
type="project",
|
|
493
|
+
scopes=["project:alpha"],
|
|
494
|
+
)
|
|
495
|
+
user_key, _ = self.capture_turn(
|
|
496
|
+
service,
|
|
497
|
+
turn="scope-final-unregistered-legal",
|
|
498
|
+
user_event="scope-final-unregistered-legal-user",
|
|
499
|
+
assistant_event="scope-final-unregistered-legal-assistant",
|
|
500
|
+
user="阿尔法项目状态不变;浙江东方实施计划待确认。",
|
|
501
|
+
assistant="已确认两个项目的状态。",
|
|
502
|
+
)
|
|
503
|
+
wrong = self.candidate(
|
|
504
|
+
"new-unregistered-final-retry",
|
|
505
|
+
[user_key],
|
|
506
|
+
memory="浙江东方实施计划待确认。",
|
|
507
|
+
type="project",
|
|
508
|
+
update_memory_id=old.memory_id,
|
|
509
|
+
)
|
|
510
|
+
wrong["scopes"] = ["project:浙江东方"]
|
|
511
|
+
backend.responses.extend(
|
|
512
|
+
[
|
|
513
|
+
self.gate([wrong]),
|
|
514
|
+
self.gate([wrong]),
|
|
515
|
+
self.gate([wrong]),
|
|
516
|
+
self.summary(
|
|
517
|
+
user_key,
|
|
518
|
+
title="浙江东方实施计划",
|
|
519
|
+
body="浙江东方实施计划待确认。",
|
|
520
|
+
type="project",
|
|
521
|
+
scopes=["project:浙江东方"],
|
|
522
|
+
),
|
|
523
|
+
]
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
result = service.process(source="codex", session_id="s", model=backend)
|
|
527
|
+
|
|
528
|
+
self.assertEqual(result["processed_turns"], 1)
|
|
529
|
+
self.assertEqual(result["memories_written"], 1)
|
|
530
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate", "summarize"])
|
|
531
|
+
self.assertIn("Previous output violated: target_not_relevant.", backend.calls[1]["prompt"])
|
|
532
|
+
self.assertEqual(service.read(old.memory_id).body, old.body)
|
|
533
|
+
self.assertEqual(len(self.knowledge(service)), 2)
|
|
534
|
+
unregistered = [
|
|
535
|
+
record
|
|
536
|
+
for record in self.knowledge(service)
|
|
537
|
+
if record.memory.scopes == ["project:浙江东方"]
|
|
538
|
+
]
|
|
539
|
+
self.assertEqual(len(unregistered), 1)
|
|
540
|
+
self.assertEqual(unregistered[0].memory.body, "浙江东方实施计划待确认。")
|
|
541
|
+
|
|
269
542
|
def test_summary_scope_drift_retries_before_writing(self):
|
|
270
543
|
backend = QueueBackend()
|
|
271
544
|
service = self.service(backend, name="summary-scope-drift")
|
|
@@ -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.10"},
|
|
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.10")
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|