memleaf 0.2.8__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.8 → memleaf-0.2.10}/CHANGELOG.md +23 -0
- {memleaf-0.2.8/src/memleaf.egg-info → memleaf-0.2.10}/PKG-INFO +3 -3
- {memleaf-0.2.8 → memleaf-0.2.10}/README.en.md +2 -2
- {memleaf-0.2.8 → memleaf-0.2.10}/README.md +2 -2
- {memleaf-0.2.8 → memleaf-0.2.10}/pyproject.toml +1 -1
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/hermes_provider/__init__.py +1 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/processing.py +260 -17
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/prompts.py +12 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/validation.py +46 -21
- {memleaf-0.2.8 → memleaf-0.2.10/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_maintenance_v2.py +27 -14
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_pypi_install.py +1 -1
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b2a.py +482 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b2b.py +19 -5
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3b_native_context.py +6 -6
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_c3_packaging.py +1 -1
- {memleaf-0.2.8 → memleaf-0.2.10}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/LICENSE +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/MANIFEST.in +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/examples/README.md +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/examples/basic_usage.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/install.ps1 +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/install.sh +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/setup.cfg +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/config.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/index.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/models.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/service.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf.egg-info/SOURCES.txt +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/__init__.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_admission_noise.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_hermes_provider.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_host_events.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_install.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b1.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,29 @@
|
|
|
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
|
+
|
|
17
|
+
## 0.2.9 — 2026-09-01
|
|
18
|
+
|
|
19
|
+
- Reject update and duplicate targets that are relevant only to another item
|
|
20
|
+
in the same aggregate turn, preventing cross-project memory overwrites.
|
|
21
|
+
- Retry an unrelated gate target with an explicit correction, then recover
|
|
22
|
+
deterministically after the bounded final attempt: preserve unrelated
|
|
23
|
+
updates as independent candidates and discard unsafe duplicate claims.
|
|
24
|
+
- Query related memories from each candidate's own complete topic instead of
|
|
25
|
+
the whole mailbox turn, while allowing the remaining valid candidates to be
|
|
26
|
+
processed without holding the retained inbox turn hostage.
|
|
27
|
+
|
|
5
28
|
## 0.2.8 — 2026-09-01
|
|
6
29
|
|
|
7
30
|
- Let the automatic summarizer independently reject an over-admitted gate
|
|
@@ -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
|
|
@@ -42,18 +42,24 @@ from .prompts import (
|
|
|
42
42
|
SUMMARY_TARGET_CORRECTION,
|
|
43
43
|
SUMMARY_TYPE_CORRECTION,
|
|
44
44
|
SUMMARIZE_SYSTEM,
|
|
45
|
+
TARGET_RELEVANCE_CORRECTION,
|
|
45
46
|
UPDATE_TARGET_TYPE_CORRECTION,
|
|
46
47
|
gate_prompt,
|
|
47
48
|
summarize_prompt,
|
|
48
49
|
)
|
|
49
50
|
from .redaction import redact_text
|
|
50
51
|
from .retrieval import candidate_matches_query, filter_by_scope, normalize_term
|
|
51
|
-
from .scope_state import
|
|
52
|
+
from .scope_state import (
|
|
53
|
+
ScopeError,
|
|
54
|
+
normalize_scopes,
|
|
55
|
+
register_scope_nodes,
|
|
56
|
+
)
|
|
52
57
|
from .scope_maintenance import ScopeMaintainer, ScopeMaintenanceError, scope_registry_projection
|
|
53
58
|
from .validation import (
|
|
54
59
|
MODEL_VALIDATION_DETAILS,
|
|
55
60
|
ModelOutputError,
|
|
56
61
|
NO_CHANGE_DECISION,
|
|
62
|
+
_model_scope_grounding_evidence,
|
|
57
63
|
parse_gate_output,
|
|
58
64
|
parse_summarize_output,
|
|
59
65
|
)
|
|
@@ -493,6 +499,8 @@ class Processor:
|
|
|
493
499
|
return GATE_TYPE_CORRECTION
|
|
494
500
|
if stage == "gate" and hint == "scope_not_grounded":
|
|
495
501
|
return SCOPE_GROUNDING_CORRECTION
|
|
502
|
+
if stage == "gate" and hint == "target_not_relevant":
|
|
503
|
+
return TARGET_RELEVANCE_CORRECTION
|
|
496
504
|
if stage == "summarize" and hint == "scope_drift":
|
|
497
505
|
return SUMMARY_SCOPE_CORRECTION
|
|
498
506
|
if hint == "relative_time":
|
|
@@ -1419,6 +1427,157 @@ class Processor:
|
|
|
1419
1427
|
strict_relevance=True,
|
|
1420
1428
|
)
|
|
1421
1429
|
|
|
1430
|
+
def _target_is_relevant_to_candidate(
|
|
1431
|
+
self,
|
|
1432
|
+
turn: InboxTurn,
|
|
1433
|
+
candidate: Mapping[str, Any],
|
|
1434
|
+
*,
|
|
1435
|
+
scope_directory: Optional[list[dict[str, Any]]] = None,
|
|
1436
|
+
scope_directory_complete: bool = True,
|
|
1437
|
+
) -> bool:
|
|
1438
|
+
"""Check a model-selected target against the candidate topic only.
|
|
1439
|
+
|
|
1440
|
+
The ordinary related-memory prompt may include several independent
|
|
1441
|
+
items from one mailbox turn. Target selection must not inherit that
|
|
1442
|
+
aggregate relevance: only the candidate's own extracted topic may
|
|
1443
|
+
make an active target eligible. A complete scope directory remains a
|
|
1444
|
+
bounded escape hatch for an explicitly indirect user/session scope;
|
|
1445
|
+
it never turns the target into an unconditional priority hit for a
|
|
1446
|
+
direct model-attributed candidate.
|
|
1447
|
+
"""
|
|
1448
|
+
|
|
1449
|
+
target = next(
|
|
1450
|
+
(
|
|
1451
|
+
candidate.get(field)
|
|
1452
|
+
for field in ("duplicate_memory_id", "update_memory_id")
|
|
1453
|
+
if isinstance(candidate.get(field), str) and candidate.get(field)
|
|
1454
|
+
),
|
|
1455
|
+
None,
|
|
1456
|
+
)
|
|
1457
|
+
memory = candidate.get("memory")
|
|
1458
|
+
candidate_scopes = candidate.get("scopes")
|
|
1459
|
+
if (
|
|
1460
|
+
not isinstance(target, str)
|
|
1461
|
+
or not isinstance(memory, str)
|
|
1462
|
+
or not isinstance(candidate_scopes, list)
|
|
1463
|
+
):
|
|
1464
|
+
return False
|
|
1465
|
+
|
|
1466
|
+
target_key = target.casefold()
|
|
1467
|
+
candidate_scope_source = candidate.get("scope_source")
|
|
1468
|
+
|
|
1469
|
+
# A complete bounded directory is the existing indirect resolution
|
|
1470
|
+
# path. It contains only active IDs from the requested inherited
|
|
1471
|
+
# scope, so preserve its exact-ID selection semantics rather than
|
|
1472
|
+
# forcing a body relevance check that the directory intentionally
|
|
1473
|
+
# cannot provide.
|
|
1474
|
+
if scope_directory is not None:
|
|
1475
|
+
if not scope_directory_complete:
|
|
1476
|
+
return False
|
|
1477
|
+
selected = next(
|
|
1478
|
+
(
|
|
1479
|
+
item
|
|
1480
|
+
for item in scope_directory
|
|
1481
|
+
if isinstance(item, Mapping)
|
|
1482
|
+
and isinstance(item.get("memory_id"), str)
|
|
1483
|
+
and item["memory_id"].casefold() == target_key
|
|
1484
|
+
),
|
|
1485
|
+
None,
|
|
1486
|
+
)
|
|
1487
|
+
if selected is not None and selected.get("type") == candidate.get("type"):
|
|
1488
|
+
return True
|
|
1489
|
+
|
|
1490
|
+
# Model-attributed, directly named scopes must match the active
|
|
1491
|
+
# target's stable topic title. Matching the whole old body is too
|
|
1492
|
+
# permissive: two different project memories can share implementation
|
|
1493
|
+
# details such as milestones while serving different future uses.
|
|
1494
|
+
if candidate_scope_source == "model":
|
|
1495
|
+
scope_terms: list[str] = []
|
|
1496
|
+
try:
|
|
1497
|
+
scope_config = self.service.vault.config()
|
|
1498
|
+
configured_scopes = scope_config.get("scopes", {})
|
|
1499
|
+
except (OSError, UnicodeError, ValueError, TypeError):
|
|
1500
|
+
scope_config = {}
|
|
1501
|
+
configured_scopes = {}
|
|
1502
|
+
for candidate_scope in candidate_scopes:
|
|
1503
|
+
if not isinstance(candidate_scope, str) or not candidate_scope.startswith("project:"):
|
|
1504
|
+
continue
|
|
1505
|
+
scope_terms.append(candidate_scope.partition(":")[2])
|
|
1506
|
+
metadata = (
|
|
1507
|
+
configured_scopes.get(candidate_scope)
|
|
1508
|
+
if isinstance(configured_scopes, Mapping)
|
|
1509
|
+
else None
|
|
1510
|
+
)
|
|
1511
|
+
aliases = metadata.get("aliases") if isinstance(metadata, Mapping) else None
|
|
1512
|
+
if isinstance(aliases, list):
|
|
1513
|
+
scope_terms.extend(
|
|
1514
|
+
alias for alias in aliases if isinstance(alias, str) and alias
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
# The regression is project-scope drift inside aggregate turns.
|
|
1518
|
+
# Global/domain/portfolio targets keep their established semantic
|
|
1519
|
+
# gate behavior because there is no project identity to remove
|
|
1520
|
+
# before comparing stable topics.
|
|
1521
|
+
if not scope_terms:
|
|
1522
|
+
return True
|
|
1523
|
+
|
|
1524
|
+
visible_turn = " ".join(
|
|
1525
|
+
event.content for event in turn.events if isinstance(event.content, str)
|
|
1526
|
+
)
|
|
1527
|
+
if scope_terms and not any(
|
|
1528
|
+
normalize_term(term) in normalize_term(visible_turn) for term in scope_terms
|
|
1529
|
+
):
|
|
1530
|
+
# The visible turn is elliptical (for example, "this
|
|
1531
|
+
# project"); the inherited scope and bounded related context
|
|
1532
|
+
# already resolved its target.
|
|
1533
|
+
return True
|
|
1534
|
+
|
|
1535
|
+
target_memory = None
|
|
1536
|
+
with self.service.vault.lock():
|
|
1537
|
+
for record in self.service._read_memories_unlocked("knowledge"):
|
|
1538
|
+
if record.memory.memory_id.casefold() == target_key:
|
|
1539
|
+
target_memory = record.memory
|
|
1540
|
+
break
|
|
1541
|
+
if target_memory is None:
|
|
1542
|
+
for item in self._planned_related:
|
|
1543
|
+
if (
|
|
1544
|
+
isinstance(item, Mapping)
|
|
1545
|
+
and isinstance(item.get("memory_id"), str)
|
|
1546
|
+
and item["memory_id"].casefold() == target_key
|
|
1547
|
+
):
|
|
1548
|
+
try:
|
|
1549
|
+
target_memory = Memory.from_mapping(item)
|
|
1550
|
+
except (TypeError, ValueError):
|
|
1551
|
+
target_memory = None
|
|
1552
|
+
break
|
|
1553
|
+
if target_memory is None:
|
|
1554
|
+
return False
|
|
1555
|
+
if not filter_by_scope([target_memory], candidate_scopes, scope_config):
|
|
1556
|
+
return False
|
|
1557
|
+
|
|
1558
|
+
def without_scope_terms(value: str) -> str:
|
|
1559
|
+
result = value
|
|
1560
|
+
for term in sorted(set(scope_terms), key=len, reverse=True):
|
|
1561
|
+
result = re.sub(re.escape(term), "", result, flags=re.IGNORECASE)
|
|
1562
|
+
return result.strip()
|
|
1563
|
+
|
|
1564
|
+
topic_title = without_scope_terms(target_memory.title)
|
|
1565
|
+
topic_query = without_scope_terms(memory)
|
|
1566
|
+
if not topic_title or not topic_query:
|
|
1567
|
+
return False
|
|
1568
|
+
topic_memory = Memory(
|
|
1569
|
+
memory_id=target_memory.memory_id,
|
|
1570
|
+
title=topic_query,
|
|
1571
|
+
body="",
|
|
1572
|
+
type=target_memory.type,
|
|
1573
|
+
scopes=target_memory.scopes,
|
|
1574
|
+
)
|
|
1575
|
+
return candidate_matches_query(topic_memory, topic_title)
|
|
1576
|
+
|
|
1577
|
+
# User/session scopes are authoritative indirect context. The base
|
|
1578
|
+
# parser has already limited the target to an active related memory.
|
|
1579
|
+
return candidate_scope_source in {"user", "session_context"}
|
|
1580
|
+
|
|
1422
1581
|
def _defer_candidate(
|
|
1423
1582
|
self,
|
|
1424
1583
|
turn_ref: tuple[str, str, str],
|
|
@@ -1643,6 +1802,104 @@ class Processor:
|
|
|
1643
1802
|
list(summary["scopes"]),
|
|
1644
1803
|
)
|
|
1645
1804
|
|
|
1805
|
+
gate_attempt_count = 0
|
|
1806
|
+
|
|
1807
|
+
def parse_gate(raw: str) -> dict[str, Any]:
|
|
1808
|
+
nonlocal gate_attempt_count
|
|
1809
|
+
gate_attempt_count += 1
|
|
1810
|
+
parsed = parse_gate_output(
|
|
1811
|
+
raw,
|
|
1812
|
+
current_event_keys=turn.event_keys,
|
|
1813
|
+
related_memory_ids=gate_related_memory_ids,
|
|
1814
|
+
related_memory_types=gate_related_memory_types,
|
|
1815
|
+
scope_registry=validation_scope_registry,
|
|
1816
|
+
enforce_model_scope_grounding=gate_attempt_count < 3,
|
|
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
|
+
|
|
1861
|
+
invalid_targets: dict[str, set[str]] = {}
|
|
1862
|
+
for candidate in parsed["candidates"]:
|
|
1863
|
+
target_fields = {
|
|
1864
|
+
field
|
|
1865
|
+
for field in ("duplicate_memory_id", "update_memory_id")
|
|
1866
|
+
if isinstance(candidate.get(field), str) and candidate.get(field)
|
|
1867
|
+
}
|
|
1868
|
+
if target_fields and not self._target_is_relevant_to_candidate(
|
|
1869
|
+
turn,
|
|
1870
|
+
candidate,
|
|
1871
|
+
scope_directory=scope_directory,
|
|
1872
|
+
scope_directory_complete=scope_directory_complete,
|
|
1873
|
+
):
|
|
1874
|
+
invalid_targets[candidate["candidate_id"].casefold()] = target_fields
|
|
1875
|
+
|
|
1876
|
+
if invalid_targets and gate_attempt_count < 3:
|
|
1877
|
+
raise ModelOutputError(
|
|
1878
|
+
"selected target is not relevant to the candidate topic",
|
|
1879
|
+
validation_detail="target_not_relevant",
|
|
1880
|
+
)
|
|
1881
|
+
|
|
1882
|
+
if invalid_targets:
|
|
1883
|
+
# A persistently non-converging model must not hold an entire
|
|
1884
|
+
# inbox turn hostage. An unrelated update target can safely
|
|
1885
|
+
# become an independent CREATE candidate; an unrelated
|
|
1886
|
+
# duplicate has no independent fact to write and is dropped.
|
|
1887
|
+
candidates: list[dict[str, Any]] = []
|
|
1888
|
+
for candidate in parsed["candidates"]:
|
|
1889
|
+
fields = invalid_targets.get(candidate["candidate_id"].casefold())
|
|
1890
|
+
if not fields:
|
|
1891
|
+
candidates.append(candidate)
|
|
1892
|
+
continue
|
|
1893
|
+
if "duplicate_memory_id" in fields:
|
|
1894
|
+
continue
|
|
1895
|
+
if "update_memory_id" in fields and candidate.get("worth"):
|
|
1896
|
+
independent = dict(candidate)
|
|
1897
|
+
independent.pop("update_memory_id", None)
|
|
1898
|
+
candidates.append(independent)
|
|
1899
|
+
parsed = dict(parsed)
|
|
1900
|
+
parsed["candidates"] = candidates
|
|
1901
|
+
return parsed
|
|
1902
|
+
|
|
1646
1903
|
gate = self._complete_json_stage(
|
|
1647
1904
|
backend,
|
|
1648
1905
|
gate_prompt(
|
|
@@ -1655,13 +1912,7 @@ class Processor:
|
|
|
1655
1912
|
),
|
|
1656
1913
|
system=GATE_SYSTEM,
|
|
1657
1914
|
purpose="gate",
|
|
1658
|
-
parser=
|
|
1659
|
-
raw,
|
|
1660
|
-
current_event_keys=turn.event_keys,
|
|
1661
|
-
related_memory_ids=gate_related_memory_ids,
|
|
1662
|
-
related_memory_types=gate_related_memory_types,
|
|
1663
|
-
scope_registry=validation_scope_registry,
|
|
1664
|
-
),
|
|
1915
|
+
parser=parse_gate,
|
|
1665
1916
|
diagnostic_context={
|
|
1666
1917
|
"source": turn.source,
|
|
1667
1918
|
"session_id": turn.session_id,
|
|
@@ -1710,15 +1961,7 @@ class Processor:
|
|
|
1710
1961
|
candidate_related, candidate_scope_background, candidate_native_refs, _ = self._related_query(
|
|
1711
1962
|
turn,
|
|
1712
1963
|
state,
|
|
1713
|
-
(
|
|
1714
|
-
str(candidate.get("memory", ""))
|
|
1715
|
-
+ "\n"
|
|
1716
|
-
+ " ".join(
|
|
1717
|
-
event.get("content", "")
|
|
1718
|
-
for event in events
|
|
1719
|
-
if isinstance(event.get("content"), str)
|
|
1720
|
-
)
|
|
1721
|
-
).strip(),
|
|
1964
|
+
str(candidate.get("memory", "")).strip(),
|
|
1722
1965
|
candidate_scopes,
|
|
1723
1966
|
overlay=self._planned_related,
|
|
1724
1967
|
priority_memory_ids=[
|
|
@@ -335,6 +335,18 @@ SUMMARY_SCOPE_CORRECTION = (
|
|
|
335
335
|
)
|
|
336
336
|
|
|
337
337
|
|
|
338
|
+
TARGET_RELEVANCE_CORRECTION = (
|
|
339
|
+
"Previous output violated: target_not_relevant. An update or duplicate "
|
|
340
|
+
"target must match the candidate's own memory topic through the supplied "
|
|
341
|
+
"active memory; do not borrow relevance from another event in the same "
|
|
342
|
+
"mailbox turn, aggregate context, or a priority ID. For a different "
|
|
343
|
+
"future use, remove the target and emit a separate atomic candidate. An "
|
|
344
|
+
"indirect same-use update may use a complete scope directory or session "
|
|
345
|
+
"context only when exactly one supplied active target is clear; otherwise "
|
|
346
|
+
"leave the target unset or defer it. Return only the strict gate JSON object."
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
338
350
|
SCOPE_GROUNDING_CORRECTION = (
|
|
339
351
|
"Previous output violated: scope_not_grounded. For each worthy candidate "
|
|
340
352
|
"with scope_source=model, choose a project scope named by that candidate's "
|
|
@@ -60,6 +60,7 @@ MODEL_VALIDATION_DETAILS = frozenset(
|
|
|
60
60
|
"duplicate_update_target",
|
|
61
61
|
"mixed_project_scopes",
|
|
62
62
|
"update_target_type_mismatch",
|
|
63
|
+
"target_not_relevant",
|
|
63
64
|
"scope_not_grounded",
|
|
64
65
|
"scope_drift",
|
|
65
66
|
"invalid_evidence",
|
|
@@ -257,17 +258,16 @@ def _reject_mixed_project_scopes(scopes: Iterable[str]) -> None:
|
|
|
257
258
|
)
|
|
258
259
|
|
|
259
260
|
|
|
260
|
-
def
|
|
261
|
+
def _model_scope_grounding_evidence(
|
|
261
262
|
memory: str,
|
|
262
263
|
scopes: Iterable[str],
|
|
263
264
|
scope_registry: Mapping[str, Any] | None,
|
|
264
|
-
) ->
|
|
265
|
-
"""
|
|
265
|
+
) -> tuple[dict[str, str], dict[str, str]]:
|
|
266
|
+
"""Return model scope ownership and memory-grounded matches.
|
|
266
267
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
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)
|
|
271
271
|
"""
|
|
272
272
|
|
|
273
273
|
selected = {
|
|
@@ -276,16 +276,14 @@ def _reject_ungrounded_project_scope(
|
|
|
276
276
|
if isinstance(scope, str) and scope.partition(":")[0] == "project"
|
|
277
277
|
}
|
|
278
278
|
if not selected:
|
|
279
|
-
return
|
|
279
|
+
return {}, {}
|
|
280
280
|
try:
|
|
281
281
|
registry = validate_scope_registry(scope_registry or {})
|
|
282
282
|
except ScopeError as error:
|
|
283
283
|
raise ModelOutputError("invalid scope registry", validation_detail="invalid_scope") from error
|
|
284
284
|
|
|
285
|
-
# Resolve
|
|
286
|
-
#
|
|
287
|
-
# source may remain selectable as an alias of its target; treating both as
|
|
288
|
-
# 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.
|
|
289
287
|
selected_owners: dict[str, str] = {}
|
|
290
288
|
for selected_scope in selected.values():
|
|
291
289
|
selected_owner = selected_scope
|
|
@@ -310,19 +308,42 @@ def _reject_ungrounded_project_scope(
|
|
|
310
308
|
selected_owners[selected_scope.casefold()] = selected_owner.casefold()
|
|
311
309
|
|
|
312
310
|
# Include unregistered selections as temporary nodes: new scopes must be
|
|
313
|
-
# grounded by their own project name
|
|
314
|
-
#
|
|
315
|
-
# 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.
|
|
316
313
|
candidate_registry = dict(registry)
|
|
317
|
-
for
|
|
318
|
-
owner = selected_owners[
|
|
314
|
+
for selected_scope in selected.values():
|
|
315
|
+
owner = selected_owners[selected_scope.casefold()]
|
|
319
316
|
if owner not in {registered.casefold() for registered in registry}:
|
|
320
|
-
candidate_registry.setdefault(
|
|
317
|
+
candidate_registry.setdefault(selected_scope, {})
|
|
318
|
+
|
|
321
319
|
matches = {
|
|
322
|
-
scope.casefold()
|
|
320
|
+
scope.casefold(): scope
|
|
323
321
|
for scope in project_scope_matches_text(memory, {"scopes": candidate_registry})
|
|
324
322
|
}
|
|
325
|
-
|
|
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())):
|
|
326
347
|
raise ModelOutputError(
|
|
327
348
|
"model project scope is not grounded by this candidate",
|
|
328
349
|
validation_detail="scope_not_grounded",
|
|
@@ -501,6 +522,7 @@ def validate_gate_output(
|
|
|
501
522
|
related_memory_ids: Iterable[Any] | None = None,
|
|
502
523
|
related_memory_types: Mapping[str, Any] | None = None,
|
|
503
524
|
scope_registry: Mapping[str, Any] | None = None,
|
|
525
|
+
enforce_model_scope_grounding: bool = True,
|
|
504
526
|
) -> dict[str, Any]:
|
|
505
527
|
"""Validate and return a normalized gate object without writing anything."""
|
|
506
528
|
|
|
@@ -641,7 +663,8 @@ def validate_gate_output(
|
|
|
641
663
|
if item["worth"]:
|
|
642
664
|
_reject_mixed_project_scopes(item["scopes"])
|
|
643
665
|
if item["scope_source"] == "model":
|
|
644
|
-
|
|
666
|
+
if enforce_model_scope_grounding:
|
|
667
|
+
_reject_ungrounded_project_scope(item["memory"], item["scopes"], scope_registry)
|
|
645
668
|
if "reason" in item:
|
|
646
669
|
_string(item["reason"], "reason", nonempty=False)
|
|
647
670
|
if len(item["reason"]) > 30:
|
|
@@ -659,6 +682,7 @@ def parse_gate_output(
|
|
|
659
682
|
related_memory_ids: Iterable[Any] | None = None,
|
|
660
683
|
related_memory_types: Mapping[str, Any] | None = None,
|
|
661
684
|
scope_registry: Mapping[str, Any] | None = None,
|
|
685
|
+
enforce_model_scope_grounding: bool = True,
|
|
662
686
|
) -> dict[str, Any]:
|
|
663
687
|
try:
|
|
664
688
|
parsed = parse_strict_json(raw)
|
|
@@ -674,6 +698,7 @@ def parse_gate_output(
|
|
|
674
698
|
related_memory_ids=related_memory_ids,
|
|
675
699
|
related_memory_types=related_memory_types,
|
|
676
700
|
scope_registry=scope_registry,
|
|
701
|
+
enforce_model_scope_grounding=enforce_model_scope_grounding,
|
|
677
702
|
)
|
|
678
703
|
except ModelOutputError as error:
|
|
679
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
|
## 项目定位
|