memleaf 0.2.8__tar.gz → 0.2.9__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.9}/CHANGELOG.md +11 -0
- {memleaf-0.2.8/src/memleaf.egg-info → memleaf-0.2.9}/PKG-INFO +3 -3
- {memleaf-0.2.8 → memleaf-0.2.9}/README.en.md +2 -2
- {memleaf-0.2.8 → memleaf-0.2.9}/README.md +2 -2
- {memleaf-0.2.8 → memleaf-0.2.9}/pyproject.toml +1 -1
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/hermes_provider/__init__.py +1 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/processing.py +210 -16
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/prompts.py +12 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/validation.py +1 -0
- {memleaf-0.2.8 → memleaf-0.2.9/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_maintenance_v2.py +27 -14
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_pypi_install.py +1 -1
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b2a.py +209 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b2b.py +19 -5
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3b_native_context.py +6 -6
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_c3_packaging.py +1 -1
- {memleaf-0.2.8 → memleaf-0.2.9}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/LICENSE +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/MANIFEST.in +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/examples/README.md +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/examples/basic_usage.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/install.ps1 +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/install.sh +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/setup.cfg +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/config.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/index.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/models.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/service.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf.egg-info/SOURCES.txt +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/__init__.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_admission_noise.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_hermes_provider.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_host_events.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_install.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b1.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.9 — 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Reject update and duplicate targets that are relevant only to another item
|
|
8
|
+
in the same aggregate turn, preventing cross-project memory overwrites.
|
|
9
|
+
- Retry an unrelated gate target with an explicit correction, then recover
|
|
10
|
+
deterministically after the bounded final attempt: preserve unrelated
|
|
11
|
+
updates as independent candidates and discard unsafe duplicate claims.
|
|
12
|
+
- Query related memories from each candidate's own complete topic instead of
|
|
13
|
+
the whole mailbox turn, while allowing the remaining valid candidates to be
|
|
14
|
+
processed without holding the retained inbox turn hostage.
|
|
15
|
+
|
|
5
16
|
## 0.2.8 — 2026-09-01
|
|
6
17
|
|
|
7
18
|
- 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.9
|
|
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.9。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 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.9.**
|
|
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.9 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.9。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 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.9"
|
|
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,6 +42,7 @@ 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,
|
|
@@ -493,6 +494,8 @@ class Processor:
|
|
|
493
494
|
return GATE_TYPE_CORRECTION
|
|
494
495
|
if stage == "gate" and hint == "scope_not_grounded":
|
|
495
496
|
return SCOPE_GROUNDING_CORRECTION
|
|
497
|
+
if stage == "gate" and hint == "target_not_relevant":
|
|
498
|
+
return TARGET_RELEVANCE_CORRECTION
|
|
496
499
|
if stage == "summarize" and hint == "scope_drift":
|
|
497
500
|
return SUMMARY_SCOPE_CORRECTION
|
|
498
501
|
if hint == "relative_time":
|
|
@@ -1419,6 +1422,157 @@ class Processor:
|
|
|
1419
1422
|
strict_relevance=True,
|
|
1420
1423
|
)
|
|
1421
1424
|
|
|
1425
|
+
def _target_is_relevant_to_candidate(
|
|
1426
|
+
self,
|
|
1427
|
+
turn: InboxTurn,
|
|
1428
|
+
candidate: Mapping[str, Any],
|
|
1429
|
+
*,
|
|
1430
|
+
scope_directory: Optional[list[dict[str, Any]]] = None,
|
|
1431
|
+
scope_directory_complete: bool = True,
|
|
1432
|
+
) -> bool:
|
|
1433
|
+
"""Check a model-selected target against the candidate topic only.
|
|
1434
|
+
|
|
1435
|
+
The ordinary related-memory prompt may include several independent
|
|
1436
|
+
items from one mailbox turn. Target selection must not inherit that
|
|
1437
|
+
aggregate relevance: only the candidate's own extracted topic may
|
|
1438
|
+
make an active target eligible. A complete scope directory remains a
|
|
1439
|
+
bounded escape hatch for an explicitly indirect user/session scope;
|
|
1440
|
+
it never turns the target into an unconditional priority hit for a
|
|
1441
|
+
direct model-attributed candidate.
|
|
1442
|
+
"""
|
|
1443
|
+
|
|
1444
|
+
target = next(
|
|
1445
|
+
(
|
|
1446
|
+
candidate.get(field)
|
|
1447
|
+
for field in ("duplicate_memory_id", "update_memory_id")
|
|
1448
|
+
if isinstance(candidate.get(field), str) and candidate.get(field)
|
|
1449
|
+
),
|
|
1450
|
+
None,
|
|
1451
|
+
)
|
|
1452
|
+
memory = candidate.get("memory")
|
|
1453
|
+
candidate_scopes = candidate.get("scopes")
|
|
1454
|
+
if (
|
|
1455
|
+
not isinstance(target, str)
|
|
1456
|
+
or not isinstance(memory, str)
|
|
1457
|
+
or not isinstance(candidate_scopes, list)
|
|
1458
|
+
):
|
|
1459
|
+
return False
|
|
1460
|
+
|
|
1461
|
+
target_key = target.casefold()
|
|
1462
|
+
candidate_scope_source = candidate.get("scope_source")
|
|
1463
|
+
|
|
1464
|
+
# A complete bounded directory is the existing indirect resolution
|
|
1465
|
+
# path. It contains only active IDs from the requested inherited
|
|
1466
|
+
# scope, so preserve its exact-ID selection semantics rather than
|
|
1467
|
+
# forcing a body relevance check that the directory intentionally
|
|
1468
|
+
# cannot provide.
|
|
1469
|
+
if scope_directory is not None:
|
|
1470
|
+
if not scope_directory_complete:
|
|
1471
|
+
return False
|
|
1472
|
+
selected = next(
|
|
1473
|
+
(
|
|
1474
|
+
item
|
|
1475
|
+
for item in scope_directory
|
|
1476
|
+
if isinstance(item, Mapping)
|
|
1477
|
+
and isinstance(item.get("memory_id"), str)
|
|
1478
|
+
and item["memory_id"].casefold() == target_key
|
|
1479
|
+
),
|
|
1480
|
+
None,
|
|
1481
|
+
)
|
|
1482
|
+
if selected is not None and selected.get("type") == candidate.get("type"):
|
|
1483
|
+
return True
|
|
1484
|
+
|
|
1485
|
+
# Model-attributed, directly named scopes must match the active
|
|
1486
|
+
# target's stable topic title. Matching the whole old body is too
|
|
1487
|
+
# permissive: two different project memories can share implementation
|
|
1488
|
+
# details such as milestones while serving different future uses.
|
|
1489
|
+
if candidate_scope_source == "model":
|
|
1490
|
+
scope_terms: list[str] = []
|
|
1491
|
+
try:
|
|
1492
|
+
scope_config = self.service.vault.config()
|
|
1493
|
+
configured_scopes = scope_config.get("scopes", {})
|
|
1494
|
+
except (OSError, UnicodeError, ValueError, TypeError):
|
|
1495
|
+
scope_config = {}
|
|
1496
|
+
configured_scopes = {}
|
|
1497
|
+
for candidate_scope in candidate_scopes:
|
|
1498
|
+
if not isinstance(candidate_scope, str) or not candidate_scope.startswith("project:"):
|
|
1499
|
+
continue
|
|
1500
|
+
scope_terms.append(candidate_scope.partition(":")[2])
|
|
1501
|
+
metadata = (
|
|
1502
|
+
configured_scopes.get(candidate_scope)
|
|
1503
|
+
if isinstance(configured_scopes, Mapping)
|
|
1504
|
+
else None
|
|
1505
|
+
)
|
|
1506
|
+
aliases = metadata.get("aliases") if isinstance(metadata, Mapping) else None
|
|
1507
|
+
if isinstance(aliases, list):
|
|
1508
|
+
scope_terms.extend(
|
|
1509
|
+
alias for alias in aliases if isinstance(alias, str) and alias
|
|
1510
|
+
)
|
|
1511
|
+
|
|
1512
|
+
# The regression is project-scope drift inside aggregate turns.
|
|
1513
|
+
# Global/domain/portfolio targets keep their established semantic
|
|
1514
|
+
# gate behavior because there is no project identity to remove
|
|
1515
|
+
# before comparing stable topics.
|
|
1516
|
+
if not scope_terms:
|
|
1517
|
+
return True
|
|
1518
|
+
|
|
1519
|
+
visible_turn = " ".join(
|
|
1520
|
+
event.content for event in turn.events if isinstance(event.content, str)
|
|
1521
|
+
)
|
|
1522
|
+
if scope_terms and not any(
|
|
1523
|
+
normalize_term(term) in normalize_term(visible_turn) for term in scope_terms
|
|
1524
|
+
):
|
|
1525
|
+
# The visible turn is elliptical (for example, "this
|
|
1526
|
+
# project"); the inherited scope and bounded related context
|
|
1527
|
+
# already resolved its target.
|
|
1528
|
+
return True
|
|
1529
|
+
|
|
1530
|
+
target_memory = None
|
|
1531
|
+
with self.service.vault.lock():
|
|
1532
|
+
for record in self.service._read_memories_unlocked("knowledge"):
|
|
1533
|
+
if record.memory.memory_id.casefold() == target_key:
|
|
1534
|
+
target_memory = record.memory
|
|
1535
|
+
break
|
|
1536
|
+
if target_memory is None:
|
|
1537
|
+
for item in self._planned_related:
|
|
1538
|
+
if (
|
|
1539
|
+
isinstance(item, Mapping)
|
|
1540
|
+
and isinstance(item.get("memory_id"), str)
|
|
1541
|
+
and item["memory_id"].casefold() == target_key
|
|
1542
|
+
):
|
|
1543
|
+
try:
|
|
1544
|
+
target_memory = Memory.from_mapping(item)
|
|
1545
|
+
except (TypeError, ValueError):
|
|
1546
|
+
target_memory = None
|
|
1547
|
+
break
|
|
1548
|
+
if target_memory is None:
|
|
1549
|
+
return False
|
|
1550
|
+
if not filter_by_scope([target_memory], candidate_scopes, scope_config):
|
|
1551
|
+
return False
|
|
1552
|
+
|
|
1553
|
+
def without_scope_terms(value: str) -> str:
|
|
1554
|
+
result = value
|
|
1555
|
+
for term in sorted(set(scope_terms), key=len, reverse=True):
|
|
1556
|
+
result = re.sub(re.escape(term), "", result, flags=re.IGNORECASE)
|
|
1557
|
+
return result.strip()
|
|
1558
|
+
|
|
1559
|
+
topic_title = without_scope_terms(target_memory.title)
|
|
1560
|
+
topic_query = without_scope_terms(memory)
|
|
1561
|
+
if not topic_title or not topic_query:
|
|
1562
|
+
return False
|
|
1563
|
+
topic_memory = Memory(
|
|
1564
|
+
memory_id=target_memory.memory_id,
|
|
1565
|
+
title=topic_query,
|
|
1566
|
+
body="",
|
|
1567
|
+
type=target_memory.type,
|
|
1568
|
+
scopes=target_memory.scopes,
|
|
1569
|
+
)
|
|
1570
|
+
return candidate_matches_query(topic_memory, topic_title)
|
|
1571
|
+
|
|
1572
|
+
# User/session scopes are authoritative indirect context. The base
|
|
1573
|
+
# parser has already limited the target to an active related memory.
|
|
1574
|
+
return candidate_scope_source in {"user", "session_context"}
|
|
1575
|
+
|
|
1422
1576
|
def _defer_candidate(
|
|
1423
1577
|
self,
|
|
1424
1578
|
turn_ref: tuple[str, str, str],
|
|
@@ -1643,6 +1797,60 @@ class Processor:
|
|
|
1643
1797
|
list(summary["scopes"]),
|
|
1644
1798
|
)
|
|
1645
1799
|
|
|
1800
|
+
gate_attempt_count = 0
|
|
1801
|
+
|
|
1802
|
+
def parse_gate(raw: str) -> dict[str, Any]:
|
|
1803
|
+
nonlocal gate_attempt_count
|
|
1804
|
+
gate_attempt_count += 1
|
|
1805
|
+
parsed = parse_gate_output(
|
|
1806
|
+
raw,
|
|
1807
|
+
current_event_keys=turn.event_keys,
|
|
1808
|
+
related_memory_ids=gate_related_memory_ids,
|
|
1809
|
+
related_memory_types=gate_related_memory_types,
|
|
1810
|
+
scope_registry=validation_scope_registry,
|
|
1811
|
+
)
|
|
1812
|
+
invalid_targets: dict[str, set[str]] = {}
|
|
1813
|
+
for candidate in parsed["candidates"]:
|
|
1814
|
+
target_fields = {
|
|
1815
|
+
field
|
|
1816
|
+
for field in ("duplicate_memory_id", "update_memory_id")
|
|
1817
|
+
if isinstance(candidate.get(field), str) and candidate.get(field)
|
|
1818
|
+
}
|
|
1819
|
+
if target_fields and not self._target_is_relevant_to_candidate(
|
|
1820
|
+
turn,
|
|
1821
|
+
candidate,
|
|
1822
|
+
scope_directory=scope_directory,
|
|
1823
|
+
scope_directory_complete=scope_directory_complete,
|
|
1824
|
+
):
|
|
1825
|
+
invalid_targets[candidate["candidate_id"].casefold()] = target_fields
|
|
1826
|
+
|
|
1827
|
+
if invalid_targets and gate_attempt_count < 3:
|
|
1828
|
+
raise ModelOutputError(
|
|
1829
|
+
"selected target is not relevant to the candidate topic",
|
|
1830
|
+
validation_detail="target_not_relevant",
|
|
1831
|
+
)
|
|
1832
|
+
|
|
1833
|
+
if invalid_targets:
|
|
1834
|
+
# A persistently non-converging model must not hold an entire
|
|
1835
|
+
# inbox turn hostage. An unrelated update target can safely
|
|
1836
|
+
# become an independent CREATE candidate; an unrelated
|
|
1837
|
+
# duplicate has no independent fact to write and is dropped.
|
|
1838
|
+
candidates: list[dict[str, Any]] = []
|
|
1839
|
+
for candidate in parsed["candidates"]:
|
|
1840
|
+
fields = invalid_targets.get(candidate["candidate_id"].casefold())
|
|
1841
|
+
if not fields:
|
|
1842
|
+
candidates.append(candidate)
|
|
1843
|
+
continue
|
|
1844
|
+
if "duplicate_memory_id" in fields:
|
|
1845
|
+
continue
|
|
1846
|
+
if "update_memory_id" in fields and candidate.get("worth"):
|
|
1847
|
+
independent = dict(candidate)
|
|
1848
|
+
independent.pop("update_memory_id", None)
|
|
1849
|
+
candidates.append(independent)
|
|
1850
|
+
parsed = dict(parsed)
|
|
1851
|
+
parsed["candidates"] = candidates
|
|
1852
|
+
return parsed
|
|
1853
|
+
|
|
1646
1854
|
gate = self._complete_json_stage(
|
|
1647
1855
|
backend,
|
|
1648
1856
|
gate_prompt(
|
|
@@ -1655,13 +1863,7 @@ class Processor:
|
|
|
1655
1863
|
),
|
|
1656
1864
|
system=GATE_SYSTEM,
|
|
1657
1865
|
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
|
-
),
|
|
1866
|
+
parser=parse_gate,
|
|
1665
1867
|
diagnostic_context={
|
|
1666
1868
|
"source": turn.source,
|
|
1667
1869
|
"session_id": turn.session_id,
|
|
@@ -1710,15 +1912,7 @@ class Processor:
|
|
|
1710
1912
|
candidate_related, candidate_scope_background, candidate_native_refs, _ = self._related_query(
|
|
1711
1913
|
turn,
|
|
1712
1914
|
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(),
|
|
1915
|
+
str(candidate.get("memory", "")).strip(),
|
|
1722
1916
|
candidate_scopes,
|
|
1723
1917
|
overlay=self._planned_related,
|
|
1724
1918
|
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 "
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
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.9。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 PyPI 分发。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
30
30
|
## 项目定位
|
|
@@ -326,7 +326,7 @@ class MaintenanceV2Tests(unittest.TestCase):
|
|
|
326
326
|
candidate(
|
|
327
327
|
"priority-update",
|
|
328
328
|
[user_event, assistant_event],
|
|
329
|
-
"priority project 状态更新",
|
|
329
|
+
"priority project plan 状态更新",
|
|
330
330
|
scopes=["project:priority"],
|
|
331
331
|
type="project",
|
|
332
332
|
update_memory_id=target.memory_id,
|
|
@@ -1026,24 +1026,37 @@ class MaintenanceV2Tests(unittest.TestCase):
|
|
|
1026
1026
|
"alpha 项目负责人更新为乙。",
|
|
1027
1027
|
"已确认 alpha 负责人为乙;beta 项目负责人仍为丙。",
|
|
1028
1028
|
)
|
|
1029
|
-
|
|
1029
|
+
invalid_gate = gate(
|
|
1030
1030
|
[
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
scopes=["project:alpha"],
|
|
1038
|
-
update_memory_id="mem-beta-owner",
|
|
1039
|
-
)
|
|
1040
|
-
]
|
|
1031
|
+
candidate(
|
|
1032
|
+
"wrong-target",
|
|
1033
|
+
[user_event, assistant_event],
|
|
1034
|
+
"alpha 项目负责人更新为乙。",
|
|
1035
|
+
scopes=["project:alpha"],
|
|
1036
|
+
update_memory_id="mem-beta-owner",
|
|
1041
1037
|
)
|
|
1042
1038
|
]
|
|
1043
1039
|
)
|
|
1040
|
+
backend = QueueBackend(
|
|
1041
|
+
[
|
|
1042
|
+
invalid_gate,
|
|
1043
|
+
invalid_gate,
|
|
1044
|
+
invalid_gate,
|
|
1045
|
+
summary(
|
|
1046
|
+
user_event,
|
|
1047
|
+
"alpha 项目负责人更新为乙。",
|
|
1048
|
+
title="alpha 项目负责人",
|
|
1049
|
+
scopes=["project:alpha"],
|
|
1050
|
+
),
|
|
1051
|
+
]
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
result = self.service.process(source="hermes", session_id="isolation", model=backend)
|
|
1044
1055
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1056
|
+
self.assertEqual(result["memories_written"], 1)
|
|
1057
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate", "summarize"])
|
|
1058
|
+
self.assertIn("Previous output violated: target_not_relevant.", backend.calls[1]["prompt"])
|
|
1059
|
+
self.assertEqual(self.processed()["sessions"]["hermes/isolation"]["processing"]["status"], "idle")
|
|
1047
1060
|
|
|
1048
1061
|
self.assertEqual(self.service.read("mem-alpha-owner").body, "alpha 项目负责人是甲。")
|
|
1049
1062
|
self.assertEqual(self.service.read("mem-beta-owner").body, "beta 项目负责人是丙。")
|
|
@@ -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.9", (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:
|
|
@@ -873,6 +873,215 @@ class StageB2ATest(unittest.TestCase):
|
|
|
873
873
|
self.assertEqual(service.read(other.memory_id).body, other.body)
|
|
874
874
|
self.assertEqual(len(service.vault.list_markdown("history")), 1)
|
|
875
875
|
|
|
876
|
+
def test_same_project_different_future_use_target_retries_then_creates(self):
|
|
877
|
+
backend = QueueBackend()
|
|
878
|
+
service = self.service(backend, name="same-project-wrong-target")
|
|
879
|
+
old = service.create_memory(
|
|
880
|
+
memory_id="mem-orion-sync",
|
|
881
|
+
title="金元顺安项目任务同步到 Orion 系统",
|
|
882
|
+
body=(
|
|
883
|
+
"金元顺安项目任务已同步到 Orion;里程碑包含信创环境搭建、"
|
|
884
|
+
"功能验证测试和上线安排。"
|
|
885
|
+
),
|
|
886
|
+
type="todo",
|
|
887
|
+
scopes=["project:金元顺安"],
|
|
888
|
+
)
|
|
889
|
+
user_key, assistant_key = self.capture_turn(
|
|
890
|
+
service,
|
|
891
|
+
turn="same-project-wrong-target",
|
|
892
|
+
user_event="same-project-wrong-target-user",
|
|
893
|
+
assistant_event="same-project-wrong-target-assistant",
|
|
894
|
+
user="金元顺安实施计划六项建议;同时需要查看 Orion 任务同步。",
|
|
895
|
+
assistant="已整理金元顺安实施计划与 Orion 任务同步。",
|
|
896
|
+
)
|
|
897
|
+
wrong = self.candidate(
|
|
898
|
+
"wrong-jinyuan-target",
|
|
899
|
+
[user_key, assistant_key],
|
|
900
|
+
memory="金元顺安实施计划六项建议含里程碑、信创环境、测试和上线安排。",
|
|
901
|
+
type="todo",
|
|
902
|
+
update_memory_id=old.memory_id,
|
|
903
|
+
)
|
|
904
|
+
wrong["scopes"] = ["project:金元顺安"]
|
|
905
|
+
corrected = dict(wrong, candidate_id="jinyuan-independent")
|
|
906
|
+
corrected.pop("update_memory_id")
|
|
907
|
+
backend.responses.extend(
|
|
908
|
+
[
|
|
909
|
+
self.gate([wrong]),
|
|
910
|
+
self.gate([corrected]),
|
|
911
|
+
self.summary(
|
|
912
|
+
user_key,
|
|
913
|
+
title="金元顺安实施计划六项建议",
|
|
914
|
+
body="金元顺安实施计划六项建议含里程碑、信创环境、测试和上线安排。",
|
|
915
|
+
type="todo",
|
|
916
|
+
scopes=["project:金元顺安"],
|
|
917
|
+
status="active",
|
|
918
|
+
),
|
|
919
|
+
]
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
result = service.process(source="codex", session_id="s", model=backend)
|
|
923
|
+
|
|
924
|
+
self.assertEqual(result["memories_written"], 1)
|
|
925
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "summarize"])
|
|
926
|
+
self.assertIn("Previous output violated: target_not_relevant.", backend.calls[1]["prompt"])
|
|
927
|
+
self.assertEqual(service.read(old.memory_id).title, old.title)
|
|
928
|
+
self.assertEqual(service.read(old.memory_id).body, old.body)
|
|
929
|
+
self.assertEqual(len(service.vault.list_markdown("history")), 0)
|
|
930
|
+
active = service._read_memories_unlocked("knowledge")
|
|
931
|
+
self.assertEqual(len(active), 2)
|
|
932
|
+
self.assertIn(
|
|
933
|
+
"金元顺安实施计划六项建议含里程碑、信创环境、测试和上线安排。",
|
|
934
|
+
"\n".join(item.memory.body for item in active),
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
def test_same_project_same_future_use_target_remains_valid(self):
|
|
938
|
+
backend = QueueBackend()
|
|
939
|
+
service = self.service(backend, name="same-project-valid-target")
|
|
940
|
+
old = service.create_memory(
|
|
941
|
+
memory_id="mem-jinyuan-plan",
|
|
942
|
+
title="金元顺安实施计划",
|
|
943
|
+
body="金元顺安实施计划按原方案执行。",
|
|
944
|
+
type="project",
|
|
945
|
+
scopes=["project:金元顺安"],
|
|
946
|
+
)
|
|
947
|
+
user_key, assistant_key = self.capture_turn(
|
|
948
|
+
service,
|
|
949
|
+
turn="same-project-valid-target",
|
|
950
|
+
user_event="same-project-valid-target-user",
|
|
951
|
+
assistant_event="same-project-valid-target-assistant",
|
|
952
|
+
user="金元顺安实施计划新增部署要求。",
|
|
953
|
+
assistant="已确认金元顺安实施计划需要纳入部署要求。",
|
|
954
|
+
)
|
|
955
|
+
candidate = self.candidate(
|
|
956
|
+
"valid-jinyuan-target",
|
|
957
|
+
[user_key, assistant_key],
|
|
958
|
+
memory="金元顺安实施计划新增部署要求。",
|
|
959
|
+
type="project",
|
|
960
|
+
update_memory_id=old.memory_id,
|
|
961
|
+
)
|
|
962
|
+
candidate["scopes"] = ["project:金元顺安"]
|
|
963
|
+
backend.responses.extend(
|
|
964
|
+
[
|
|
965
|
+
self.gate([candidate]),
|
|
966
|
+
self.summary(
|
|
967
|
+
user_key,
|
|
968
|
+
title="金元顺安实施计划",
|
|
969
|
+
body="金元顺安实施计划新增部署要求。",
|
|
970
|
+
type="project",
|
|
971
|
+
scopes=["project:金元顺安"],
|
|
972
|
+
update_memory_id=old.memory_id,
|
|
973
|
+
),
|
|
974
|
+
]
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
result = service.process(source="codex", session_id="s", model=backend)
|
|
978
|
+
|
|
979
|
+
self.assertEqual(result["memories_written"], 1)
|
|
980
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize"])
|
|
981
|
+
self.assertEqual(service.read(old.memory_id).body, "金元顺安实施计划新增部署要求。")
|
|
982
|
+
self.assertEqual(len(service.vault.list_markdown("history")), 1)
|
|
983
|
+
|
|
984
|
+
def test_same_project_wrong_update_target_final_retry_becomes_create(self):
|
|
985
|
+
backend = QueueBackend()
|
|
986
|
+
service = self.service(backend, name="same-project-wrong-target-final-retry")
|
|
987
|
+
old = service.create_memory(
|
|
988
|
+
memory_id="mem-beta-owner-final-retry",
|
|
989
|
+
title="beta 项目负责人",
|
|
990
|
+
body="beta 项目负责人是丙。",
|
|
991
|
+
type="identity",
|
|
992
|
+
scopes=["project:beta"],
|
|
993
|
+
)
|
|
994
|
+
user_key, assistant_key = self.capture_turn(
|
|
995
|
+
service,
|
|
996
|
+
turn="same-project-wrong-target-final-retry",
|
|
997
|
+
user_event="same-project-wrong-target-final-retry-user",
|
|
998
|
+
assistant_event="same-project-wrong-target-final-retry-assistant",
|
|
999
|
+
user="alpha 项目负责人更新为乙;beta 项目负责人仍为丙。",
|
|
1000
|
+
assistant="已确认 alpha 负责人为乙,beta 负责人仍为丙。",
|
|
1001
|
+
)
|
|
1002
|
+
wrong = self.candidate(
|
|
1003
|
+
"wrong-target-final-retry",
|
|
1004
|
+
[user_key, assistant_key],
|
|
1005
|
+
memory="alpha 项目负责人更新为乙。",
|
|
1006
|
+
type="identity",
|
|
1007
|
+
update_memory_id=old.memory_id,
|
|
1008
|
+
)
|
|
1009
|
+
wrong["scopes"] = ["project:alpha"]
|
|
1010
|
+
invalid_gate = self.gate([wrong])
|
|
1011
|
+
backend.responses.extend(
|
|
1012
|
+
[
|
|
1013
|
+
invalid_gate,
|
|
1014
|
+
invalid_gate,
|
|
1015
|
+
invalid_gate,
|
|
1016
|
+
self.summary(
|
|
1017
|
+
user_key,
|
|
1018
|
+
title="alpha 项目负责人",
|
|
1019
|
+
body="alpha 项目负责人已更新为乙。",
|
|
1020
|
+
type="identity",
|
|
1021
|
+
scopes=["project:alpha"],
|
|
1022
|
+
),
|
|
1023
|
+
]
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
result = service.process(source="codex", session_id="s", model=backend)
|
|
1027
|
+
|
|
1028
|
+
self.assertEqual(result["processed_turns"], 1)
|
|
1029
|
+
self.assertEqual(result["memories_written"], 1)
|
|
1030
|
+
self.assertEqual(
|
|
1031
|
+
[call["purpose"] for call in backend.calls],
|
|
1032
|
+
["gate", "gate", "gate", "summarize"],
|
|
1033
|
+
)
|
|
1034
|
+
self.assertIn("Previous output violated: target_not_relevant.", backend.calls[1]["prompt"])
|
|
1035
|
+
self.assertEqual(service.read(old.memory_id).body, old.body)
|
|
1036
|
+
self.assertEqual(len(service.vault.list_markdown("history")), 0)
|
|
1037
|
+
self.assertEqual(self.processed(service)["sessions"]["codex/s"]["processing"]["status"], "idle")
|
|
1038
|
+
self.assertIn(
|
|
1039
|
+
"alpha 项目负责人已更新为乙。",
|
|
1040
|
+
"\n".join(record.memory.body for record in service._read_memories_unlocked("knowledge")),
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
def test_same_project_wrong_duplicate_target_final_retry_is_dropped(self):
|
|
1044
|
+
backend = QueueBackend()
|
|
1045
|
+
service = self.service(backend, name="same-project-wrong-duplicate-final-retry")
|
|
1046
|
+
old = service.create_memory(
|
|
1047
|
+
memory_id="mem-beta-duplicate-final-retry",
|
|
1048
|
+
title="beta 项目状态",
|
|
1049
|
+
body="beta 项目状态保持不变。",
|
|
1050
|
+
type="fact",
|
|
1051
|
+
scopes=["project:beta"],
|
|
1052
|
+
)
|
|
1053
|
+
user_key, assistant_key = self.capture_turn(
|
|
1054
|
+
service,
|
|
1055
|
+
turn="same-project-wrong-duplicate-final-retry",
|
|
1056
|
+
user_event="same-project-wrong-duplicate-final-retry-user",
|
|
1057
|
+
assistant_event="same-project-wrong-duplicate-final-retry-assistant",
|
|
1058
|
+
user="alpha 项目状态保持不变;beta 项目状态也保持不变。",
|
|
1059
|
+
assistant="已确认两个项目状态均未变化。",
|
|
1060
|
+
)
|
|
1061
|
+
wrong = self.candidate(
|
|
1062
|
+
"wrong-duplicate-final-retry",
|
|
1063
|
+
[user_key, assistant_key],
|
|
1064
|
+
memory="alpha 项目状态保持不变。",
|
|
1065
|
+
duplicate=True,
|
|
1066
|
+
worth=False,
|
|
1067
|
+
type="fact",
|
|
1068
|
+
)
|
|
1069
|
+
wrong["duplicate_memory_id"] = old.memory_id
|
|
1070
|
+
wrong["scopes"] = ["project:alpha"]
|
|
1071
|
+
invalid_gate = self.gate([wrong])
|
|
1072
|
+
backend.responses.extend([invalid_gate, invalid_gate, invalid_gate])
|
|
1073
|
+
|
|
1074
|
+
result = service.process(source="codex", session_id="s", model=backend)
|
|
1075
|
+
|
|
1076
|
+
self.assertEqual(result["processed_turns"], 1)
|
|
1077
|
+
self.assertEqual(result["memories_written"], 0)
|
|
1078
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate"])
|
|
1079
|
+
self.assertIn("Previous output violated: target_not_relevant.", backend.calls[1]["prompt"])
|
|
1080
|
+
self.assertEqual(service.read(old.memory_id).body, old.body)
|
|
1081
|
+
self.assertEqual(len(service._read_memories_unlocked("knowledge")), 1)
|
|
1082
|
+
self.assertEqual(len(service.vault.list_markdown("history")), 0)
|
|
1083
|
+
self.assertEqual(self.processed(service)["sessions"]["codex/s"]["processing"]["status"], "idle")
|
|
1084
|
+
|
|
876
1085
|
def test_gate_and_summary_update_target_mismatch_fails_after_bounded_retries(self):
|
|
877
1086
|
backend = QueueBackend()
|
|
878
1087
|
service = self.service(backend, name="email-update-mismatch-failure")
|
|
@@ -150,7 +150,12 @@ class StageB2BTest(unittest.TestCase):
|
|
|
150
150
|
user=f"user {turn} b2b{related_context}",
|
|
151
151
|
assistant=f"assistant {turn}",
|
|
152
152
|
)
|
|
153
|
-
candidate = self.candidate(
|
|
153
|
+
candidate = self.candidate(
|
|
154
|
+
"candidate-" + turn,
|
|
155
|
+
[user_key],
|
|
156
|
+
memory=f"{active.title} {active.body}",
|
|
157
|
+
type=type,
|
|
158
|
+
)
|
|
154
159
|
summary_extra = dict(extra or {})
|
|
155
160
|
summary_extra["update_memory_id"] = active_id
|
|
156
161
|
backend.responses.extend(
|
|
@@ -333,7 +338,9 @@ class StageB2BTest(unittest.TestCase):
|
|
|
333
338
|
assistant_event="a2",
|
|
334
339
|
user="user t2 old",
|
|
335
340
|
)
|
|
336
|
-
candidate = self.candidate(
|
|
341
|
+
candidate = self.candidate(
|
|
342
|
+
"candidate-t2", [user_key], memory=f"{active.title} {active.body}"
|
|
343
|
+
)
|
|
337
344
|
responses = [
|
|
338
345
|
self.gate([candidate]),
|
|
339
346
|
self.summary(user_key, title="Updated", body="new", update_memory_id=active.memory_id),
|
|
@@ -373,7 +380,9 @@ class StageB2BTest(unittest.TestCase):
|
|
|
373
380
|
assistant_event="a2",
|
|
374
381
|
user="user t2 b2b old",
|
|
375
382
|
)
|
|
376
|
-
candidate = self.candidate(
|
|
383
|
+
candidate = self.candidate(
|
|
384
|
+
"candidate-t2", [user_key], memory=f"{active.title} {active.body}"
|
|
385
|
+
)
|
|
377
386
|
responses = [self.gate([candidate]), self.summary(user_key, title="Updated", body="b2b new", update_memory_id=active.memory_id)]
|
|
378
387
|
|
|
379
388
|
backend.responses.extend(responses)
|
|
@@ -405,7 +414,9 @@ class StageB2BTest(unittest.TestCase):
|
|
|
405
414
|
assistant_event="p-a2",
|
|
406
415
|
user="user t2 b2b old",
|
|
407
416
|
)
|
|
408
|
-
candidate2 = self.candidate(
|
|
417
|
+
candidate2 = self.candidate(
|
|
418
|
+
"candidate-t2", [user_key2], memory=f"{active2.title} {active2.body}"
|
|
419
|
+
)
|
|
409
420
|
responses2 = [self.gate([candidate2]), self.summary(user_key2, title="Updated", body="b2b new", update_memory_id=active2.memory_id)]
|
|
410
421
|
backend2.responses.extend(responses2)
|
|
411
422
|
import memleaf.processing as processing_module
|
|
@@ -543,7 +554,10 @@ class StageB2BTest(unittest.TestCase):
|
|
|
543
554
|
assistant_event="a2",
|
|
544
555
|
user="user t2 b2b old",
|
|
545
556
|
)
|
|
546
|
-
candidates = [
|
|
557
|
+
candidates = [
|
|
558
|
+
self.candidate("c1", [user_key], memory=f"{active.title} {active.body}"),
|
|
559
|
+
self.candidate("c2", [user_key], memory=f"{active.title} {active.body} second"),
|
|
560
|
+
]
|
|
547
561
|
backend.responses.extend(
|
|
548
562
|
[
|
|
549
563
|
self.gate(candidates),
|
|
@@ -56,10 +56,10 @@ class StageB3BNativeContextTest(unittest.TestCase):
|
|
|
56
56
|
return json.dumps({"candidates": [candidate] if candidate is not None else []})
|
|
57
57
|
|
|
58
58
|
@staticmethod
|
|
59
|
-
def candidate(event_key_value, *, duplicate=False, worth=True):
|
|
59
|
+
def candidate(event_key_value, *, memory="new state", duplicate=False, worth=True):
|
|
60
60
|
return {
|
|
61
61
|
"candidate_id": "candidate-1",
|
|
62
|
-
"memory":
|
|
62
|
+
"memory": memory,
|
|
63
63
|
"evidence_event_ids": [event_key_value],
|
|
64
64
|
"duplicate": duplicate,
|
|
65
65
|
"worth": worth,
|
|
@@ -188,7 +188,7 @@ class StageB3BNativeContextTest(unittest.TestCase):
|
|
|
188
188
|
event_key_value = self.capture_turn(user="legacy status is now replaced", assistant="confirmed")
|
|
189
189
|
invalid = QueueBackend(
|
|
190
190
|
[
|
|
191
|
-
self.gate(self.candidate(event_key_value)),
|
|
191
|
+
self.gate(self.candidate(event_key_value, memory="legacy status is now replaced")),
|
|
192
192
|
self.summary(event_key_value, "new status", shadow=["native-not-related"]),
|
|
193
193
|
self.summary(event_key_value, "new status", shadow=["native-not-related"]),
|
|
194
194
|
self.summary(event_key_value, "new status", shadow=["native-not-related"]),
|
|
@@ -200,7 +200,7 @@ class StageB3BNativeContextTest(unittest.TestCase):
|
|
|
200
200
|
|
|
201
201
|
backend = QueueBackend(
|
|
202
202
|
[
|
|
203
|
-
self.gate(self.candidate(event_key_value)),
|
|
203
|
+
self.gate(self.candidate(event_key_value, memory="legacy status is now replaced")),
|
|
204
204
|
self.summary(event_key_value, "new status", shadow=[native_id]),
|
|
205
205
|
]
|
|
206
206
|
)
|
|
@@ -239,9 +239,9 @@ class StageB3BNativeContextTest(unittest.TestCase):
|
|
|
239
239
|
|
|
240
240
|
event_key_value = self.capture_turn(user="legacy retry status is replaced", assistant="confirmed")
|
|
241
241
|
responses = [
|
|
242
|
-
self.gate(self.candidate(event_key_value)),
|
|
242
|
+
self.gate(self.candidate(event_key_value, memory="legacy retry status is replaced")),
|
|
243
243
|
self.summary(event_key_value, "retry status", shadow=[native_id]),
|
|
244
|
-
self.gate(self.candidate(event_key_value)),
|
|
244
|
+
self.gate(self.candidate(event_key_value, memory="legacy retry status is replaced")),
|
|
245
245
|
self.summary(event_key_value, "retry status", shadow=[native_id]),
|
|
246
246
|
]
|
|
247
247
|
backend = QueueBackend(responses)
|
|
@@ -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.9"},
|
|
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.9")
|
|
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
|