memleaf 0.2.15__tar.gz → 0.2.16__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.15 → memleaf-0.2.16}/CHANGELOG.md +9 -0
- {memleaf-0.2.15/src/memleaf.egg-info → memleaf-0.2.16}/PKG-INFO +3 -3
- {memleaf-0.2.15 → memleaf-0.2.16}/README.en.md +2 -2
- {memleaf-0.2.15 → memleaf-0.2.16}/README.md +2 -2
- {memleaf-0.2.15 → memleaf-0.2.16}/pyproject.toml +1 -1
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/hermes_provider/__init__.py +60 -2
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/processing.py +58 -0
- {memleaf-0.2.15 → memleaf-0.2.16/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_admission_noise.py +161 -28
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_hermes_provider.py +76 -5
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_pypi_install.py +5 -5
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_c3_packaging.py +1 -1
- {memleaf-0.2.15 → memleaf-0.2.16}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/LICENSE +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/MANIFEST.in +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/examples/README.md +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/examples/basic_usage.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/install.ps1 +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/install.sh +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/setup.cfg +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/config.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/index.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/models.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/prompts.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/service.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/validation.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf.egg-info/SOURCES.txt +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/__init__.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_extraction_quality_regressions.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_host_events.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_install.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_maintenance_v2.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b1.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b2a.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_update_target_recovery.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.16 — 2026-09-02
|
|
6
|
+
|
|
7
|
+
- Keep read-only operational queries, derived overdue counters, and one-time
|
|
8
|
+
execution receipts out of permanent-memory extraction; preserve explicitly
|
|
9
|
+
confirmed durable facts and preferences.
|
|
10
|
+
- Deduplicate Hermes tool observations across cumulative conversation payloads,
|
|
11
|
+
preventing historical reads from being re-audited as uncontrolled while
|
|
12
|
+
retaining diagnostics for genuinely mismatched current-turn reads.
|
|
13
|
+
|
|
5
14
|
## 0.2.15 — 2026-09-02
|
|
6
15
|
|
|
7
16
|
- Recover a candidate whose `update_memory_id` has the wrong type at the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.16
|
|
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.16。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.16 通过 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.16.**
|
|
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.16 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.16。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.16 通过 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.16"
|
|
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,6 +49,7 @@ _MAX_PENDING_TURN_NUMBERS = 128
|
|
|
49
49
|
_MAX_SESSION_ALIASES = 128
|
|
50
50
|
_MAX_LINEAGE_RETRIES = 2
|
|
51
51
|
_MAX_DEFERRED_PROCESS_SESSIONS = 128
|
|
52
|
+
_MAX_OBSERVED_TOOL_CALL_KEYS = 2048
|
|
52
53
|
_SAFE_COMPONENT_RE = re.compile(r"[^A-Za-z0-9_.-]+")
|
|
53
54
|
_PROVIDER_VERSION_RE = re.compile(r"^version:\s*([^\s#]+)\s*(?:#.*)?$", re.MULTILINE)
|
|
54
55
|
_ASCII_QUERY_TERM_RE = re.compile(r"[a-z0-9]+(?:[ ._-][a-z0-9]+)*")
|
|
@@ -628,6 +629,45 @@ def _tool_result_for_call(
|
|
|
628
629
|
return _CALL_FAILED
|
|
629
630
|
|
|
630
631
|
|
|
632
|
+
def _tool_observation_key(call: Mapping[str, Any], ordinal: int) -> str:
|
|
633
|
+
"""Build a stable in-memory identity for one visible tool call.
|
|
634
|
+
|
|
635
|
+
Hermes can pass the complete conversation back on every turn. The
|
|
636
|
+
retrieval token and result status are turn-relative, so they must not be
|
|
637
|
+
part of the identity: otherwise an old successful read is reclassified as
|
|
638
|
+
``uncontrolled_success`` when a newer token is observed. Call ids are
|
|
639
|
+
stable when Hermes supplies them; the ordinal/arguments fallback keeps
|
|
640
|
+
anonymous calls distinct within the cumulative message list.
|
|
641
|
+
"""
|
|
642
|
+
|
|
643
|
+
arguments = call.get("arguments")
|
|
644
|
+
try:
|
|
645
|
+
arguments_text = json.dumps(arguments, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
646
|
+
except (TypeError, ValueError):
|
|
647
|
+
arguments_text = ""
|
|
648
|
+
call_id = call.get("call_id") if isinstance(call.get("call_id"), str) else ""
|
|
649
|
+
name = call.get("name") if isinstance(call.get("name"), str) else ""
|
|
650
|
+
if call_id:
|
|
651
|
+
identity = f"{name}\x00call-id\x00{call_id}"
|
|
652
|
+
else:
|
|
653
|
+
identity = f"{name}\x00anonymous\x00{ordinal}\x00{arguments_text}"
|
|
654
|
+
return sha256(identity.encode("utf-8")).hexdigest()
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _record_tool_observation(seen: Any, key: str) -> bool:
|
|
658
|
+
"""Record one observation key and report whether it was new."""
|
|
659
|
+
|
|
660
|
+
if seen is None:
|
|
661
|
+
return True
|
|
662
|
+
if key in seen:
|
|
663
|
+
return False
|
|
664
|
+
if isinstance(seen, set):
|
|
665
|
+
seen.add(key)
|
|
666
|
+
else:
|
|
667
|
+
seen[key] = None
|
|
668
|
+
return True
|
|
669
|
+
|
|
670
|
+
|
|
631
671
|
def _hermes_read_status(value: Any) -> str:
|
|
632
672
|
"""Classify a visible read result without inspecting it in diagnostics."""
|
|
633
673
|
|
|
@@ -1125,6 +1165,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1125
1165
|
self._gate_turn_ids: "OrderedDict[Tuple[str, int], str]" = OrderedDict()
|
|
1126
1166
|
self._active_turn_numbers: "OrderedDict[str, int]" = OrderedDict()
|
|
1127
1167
|
self._active_retrieval_ids: "OrderedDict[str, Optional[str]]" = OrderedDict()
|
|
1168
|
+
self._observed_tool_call_keys: "OrderedDict[str, None]" = OrderedDict()
|
|
1128
1169
|
self._version_warning_emitted = False
|
|
1129
1170
|
# Hermes keeps one provider instance alive while compression rotates
|
|
1130
1171
|
# the physical session id. Keep only a bounded alias chain so a
|
|
@@ -1752,6 +1793,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1752
1793
|
if not continuous:
|
|
1753
1794
|
self._pending_lineage.clear()
|
|
1754
1795
|
self._deferred_process_sessions.clear()
|
|
1796
|
+
self._observed_tool_call_keys.clear()
|
|
1755
1797
|
if _as_bool(reset, False) or _as_bool(rewound, False):
|
|
1756
1798
|
self._clear_session_turn_state(old_session_id or next_session_id)
|
|
1757
1799
|
for key in list(self._retrieval_ids_by_turn):
|
|
@@ -1826,6 +1868,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1826
1868
|
self._gate_turn_ids.clear()
|
|
1827
1869
|
self._active_turn_numbers.clear()
|
|
1828
1870
|
self._active_retrieval_ids.clear()
|
|
1871
|
+
self._observed_tool_call_keys.clear()
|
|
1829
1872
|
self._session_aliases.clear()
|
|
1830
1873
|
self._pending_lineage.clear()
|
|
1831
1874
|
self._deferred_process_sessions.clear()
|
|
@@ -2073,6 +2116,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2073
2116
|
session_id: str = "",
|
|
2074
2117
|
turn_id: str = "",
|
|
2075
2118
|
vault_root: Optional[Path] = None,
|
|
2119
|
+
seen_call_keys: Any = None,
|
|
2076
2120
|
) -> str:
|
|
2077
2121
|
"""Observe explicit host MCP calls in public messages.
|
|
2078
2122
|
|
|
@@ -2087,25 +2131,32 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2087
2131
|
results = _visible_tool_results(messages)
|
|
2088
2132
|
statuses: list[str] = []
|
|
2089
2133
|
search_results_used: set[int] = set()
|
|
2134
|
+
search_ordinal = 0
|
|
2090
2135
|
for call in calls:
|
|
2091
2136
|
if call.get("name") != "mcp__memleaf__search":
|
|
2092
2137
|
continue
|
|
2138
|
+
search_ordinal += 1
|
|
2093
2139
|
arguments = call.get("arguments")
|
|
2094
2140
|
if not isinstance(arguments, Mapping) or arguments.get("retrieval_id") != retrieval_id:
|
|
2095
2141
|
continue
|
|
2096
2142
|
payload = _tool_result_for_call(call, calls, results, search_results_used)
|
|
2097
2143
|
if payload is not _CALL_FAILED:
|
|
2144
|
+
status = _hermes_search_status(payload)
|
|
2145
|
+
observation_key = _tool_observation_key(call, search_ordinal)
|
|
2146
|
+
if not _record_tool_observation(seen_call_keys, observation_key):
|
|
2147
|
+
continue
|
|
2098
2148
|
# Hermes has no public write-back hook for this soft observer.
|
|
2099
2149
|
# A valid current-turn result is enough to mark the local
|
|
2100
2150
|
# provider diagnostic; no Core ledger or body is touched here.
|
|
2101
|
-
statuses.append(
|
|
2151
|
+
statuses.append(status)
|
|
2102
2152
|
|
|
2103
2153
|
read_results_used: set[int] = set()
|
|
2104
2154
|
read_sequence = 0
|
|
2155
|
+
read_ordinal = 0
|
|
2105
2156
|
for call in calls:
|
|
2106
2157
|
if call.get("name") != "mcp__memleaf__read":
|
|
2107
2158
|
continue
|
|
2108
|
-
|
|
2159
|
+
read_ordinal += 1
|
|
2109
2160
|
arguments = call.get("arguments")
|
|
2110
2161
|
retrieval_present = isinstance(arguments, Mapping) and "retrieval_id" in arguments
|
|
2111
2162
|
retrieval_match = bool(
|
|
@@ -2120,6 +2171,10 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2120
2171
|
# mismatched gate binding. Do not make that look like a
|
|
2121
2172
|
# controlled success in the diagnostic stream.
|
|
2122
2173
|
result_status = "uncontrolled_success"
|
|
2174
|
+
observation_key = _tool_observation_key(call, read_ordinal)
|
|
2175
|
+
if not _record_tool_observation(seen_call_keys, observation_key):
|
|
2176
|
+
continue
|
|
2177
|
+
read_sequence += 1
|
|
2123
2178
|
logger.info(
|
|
2124
2179
|
"memleaf retrieval-read source=hermes session=%s turn=%s read_seq=%d retrieval_present=%s retrieval_match=%s result=%s",
|
|
2125
2180
|
_safe_component(session_id, "none") if session_id else "none",
|
|
@@ -2286,7 +2341,10 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2286
2341
|
session_id=effective_session,
|
|
2287
2342
|
turn_id=turn_id,
|
|
2288
2343
|
vault_root=_resolve_vault(self._config()),
|
|
2344
|
+
seen_call_keys=self._observed_tool_call_keys,
|
|
2289
2345
|
)
|
|
2346
|
+
while len(self._observed_tool_call_keys) > _MAX_OBSERVED_TOOL_CALL_KEYS:
|
|
2347
|
+
self._observed_tool_call_keys.popitem(last=False)
|
|
2290
2348
|
self._last_retrieval_observation = observation
|
|
2291
2349
|
lineage_ready = self._retry_pending_lineage(effective_session)
|
|
2292
2350
|
for role, content in visible_events:
|
|
@@ -87,6 +87,55 @@ _DIAGNOSTIC_GATE_ALLOWED = frozenset(("candidates",))
|
|
|
87
87
|
_DIAGNOSTIC_CANDIDATE_REQUIRED = frozenset(
|
|
88
88
|
("candidate_id", "memory", "evidence_event_ids", "duplicate", "worth", "type", "scopes", "scope_source")
|
|
89
89
|
)
|
|
90
|
+
|
|
91
|
+
_READ_ONLY_QUERY_MARKERS = (
|
|
92
|
+
"有没有", "有哪些", "是什么", "是谁", "多少", "查询", "查一下", "查下",
|
|
93
|
+
"看看", "查看", "汇总", "列出", "告诉我", "what", "which", "who", "show me",
|
|
94
|
+
)
|
|
95
|
+
_USER_ASSERTION_MARKERS = (
|
|
96
|
+
"已确认", "确认了", "已决定", "我决定", "改为", "更新为", "变更为", "以后",
|
|
97
|
+
"今后", "约定", "我的偏好", "我喜欢", "必须", "要求是", "截止日期为",
|
|
98
|
+
"负责人是", "负责人为",
|
|
99
|
+
)
|
|
100
|
+
_DERIVED_OVERDUE_RE = re.compile(
|
|
101
|
+
r"(?:逾期|超期|overdue)\s*\d+(?:\.\d+)?\s*(?:天|日|days?)",
|
|
102
|
+
re.IGNORECASE,
|
|
103
|
+
)
|
|
104
|
+
_EXECUTION_RECEIPT_RE = re.compile(
|
|
105
|
+
r"(?:核验归档|归档核验|服务器已接受(?:提交|投递)|server accepted)",
|
|
106
|
+
re.IGNORECASE,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _automatic_read_only_query(events: Iterable[Mapping[str, Any]]) -> bool:
|
|
111
|
+
"""Return true when the user only asks for information in this turn."""
|
|
112
|
+
|
|
113
|
+
user_text = "\n".join(
|
|
114
|
+
str(event.get("content", ""))
|
|
115
|
+
for event in events
|
|
116
|
+
if str(event.get("role", "")).casefold() == "user"
|
|
117
|
+
).strip()
|
|
118
|
+
if not user_text:
|
|
119
|
+
return False
|
|
120
|
+
folded = user_text.casefold()
|
|
121
|
+
query_prefix = re.sub(r"^(?:请|帮我|麻烦|请帮我)\s*", "", folded)
|
|
122
|
+
asks = "?" in user_text or "?" in user_text or any(
|
|
123
|
+
query_prefix.startswith(marker) or query_prefix.startswith(f"我{marker}")
|
|
124
|
+
for marker in _READ_ONLY_QUERY_MARKERS
|
|
125
|
+
)
|
|
126
|
+
if not asks:
|
|
127
|
+
return False
|
|
128
|
+
return not any(marker in folded for marker in _USER_ASSERTION_MARKERS)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _automatic_transient_memory(value: Any) -> bool:
|
|
132
|
+
"""Reject volatile counters and one-off execution receipts at write time."""
|
|
133
|
+
|
|
134
|
+
if not isinstance(value, str):
|
|
135
|
+
return False
|
|
136
|
+
return bool(_DERIVED_OVERDUE_RE.search(value) or _EXECUTION_RECEIPT_RE.search(value))
|
|
137
|
+
|
|
138
|
+
|
|
90
139
|
_DIAGNOSTIC_CANDIDATE_ALLOWED = _DIAGNOSTIC_CANDIDATE_REQUIRED | frozenset(
|
|
91
140
|
("reason", "duplicate_memory_id", "update_memory_id")
|
|
92
141
|
)
|
|
@@ -2268,9 +2317,14 @@ class Processor:
|
|
|
2268
2317
|
)
|
|
2269
2318
|
requests: list[dict[str, Any]] = []
|
|
2270
2319
|
observed_scopes: list[str] = []
|
|
2320
|
+
read_only_query = _automatic_read_only_query(events)
|
|
2271
2321
|
for candidate in gate["candidates"]:
|
|
2272
2322
|
candidate_id_key = str(candidate.get("candidate_id", "")).casefold()
|
|
2273
2323
|
detached_update_target_id = detached_update_target_ids.get(candidate_id_key)
|
|
2324
|
+
if candidate.get("worth") and (
|
|
2325
|
+
read_only_query or _automatic_transient_memory(candidate.get("memory"))
|
|
2326
|
+
):
|
|
2327
|
+
continue
|
|
2274
2328
|
# A combined mailbox/daily digest is not an atomic memory. If a
|
|
2275
2329
|
# concrete action was worth retaining, the gate must emit it as
|
|
2276
2330
|
# its own candidate; the aggregate shell itself is NO_CHANGE.
|
|
@@ -2566,6 +2620,10 @@ class Processor:
|
|
|
2566
2620
|
f"{summary.get('title', '')}\n{summary.get('body', '')}"
|
|
2567
2621
|
):
|
|
2568
2622
|
continue
|
|
2623
|
+
if _automatic_transient_memory(
|
|
2624
|
+
f"{summary.get('title', '')}\n{summary.get('body', '')}"
|
|
2625
|
+
):
|
|
2626
|
+
continue
|
|
2569
2627
|
if gate_update_target is not None:
|
|
2570
2628
|
summary_update_target = summary.get("update_memory_id")
|
|
2571
2629
|
if summary_update_target is None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.16
|
|
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.16。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.16 通过 PyPI 分发。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
30
30
|
## 项目定位
|
|
@@ -305,7 +305,160 @@ class AdmissionFlowTests(unittest.TestCase):
|
|
|
305
305
|
self.assertEqual(len(memories), 1)
|
|
306
306
|
self.assertIn("交付延期风险", memories[0].memory.body)
|
|
307
307
|
|
|
308
|
-
def
|
|
308
|
+
def test_pure_operational_query_cannot_reingest_assistant_snapshot(self):
|
|
309
|
+
service = self.make_service("pure-operational-query")
|
|
310
|
+
existing = service.create_memory(
|
|
311
|
+
memory_id="mem-puyin-overdue",
|
|
312
|
+
title="浦银安盛测试数据",
|
|
313
|
+
body="浦银安盛需要提供一版可靠测试数据。",
|
|
314
|
+
tags=["project"],
|
|
315
|
+
type="todo",
|
|
316
|
+
)
|
|
317
|
+
backend = QueueBackend()
|
|
318
|
+
user_key, assistant_key = self.capture_turn(
|
|
319
|
+
service,
|
|
320
|
+
session="pure-operational-query",
|
|
321
|
+
turn="turn-1",
|
|
322
|
+
user="我有没有近期比较紧急的事情要处理?",
|
|
323
|
+
assistant=(
|
|
324
|
+
"巡检汇总:浦银安盛任务逾期5天;泰信基金任务逾期33天,"
|
|
325
|
+
"状态已驳回、无负责人;中银国际历史数据和附件需要全部迁移。"
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
backend.responses.append(
|
|
329
|
+
gate(
|
|
330
|
+
[
|
|
331
|
+
candidate(
|
|
332
|
+
"repeated-task",
|
|
333
|
+
[user_key, assistant_key],
|
|
334
|
+
memory="浦银安盛需要提供一版可靠测试数据,逾期5天。",
|
|
335
|
+
type="todo",
|
|
336
|
+
),
|
|
337
|
+
candidate(
|
|
338
|
+
"daily-overdue",
|
|
339
|
+
[user_key, assistant_key],
|
|
340
|
+
memory="泰信基金任务逾期33天,状态已驳回、无负责人。",
|
|
341
|
+
type="todo",
|
|
342
|
+
),
|
|
343
|
+
candidate(
|
|
344
|
+
"assistant-only-plan",
|
|
345
|
+
[user_key, assistant_key],
|
|
346
|
+
memory="中银国际历史数据和附件需要全部迁移。",
|
|
347
|
+
type="project",
|
|
348
|
+
),
|
|
349
|
+
]
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
result = service.process(source="hermes", session_id="pure-operational-query", model=backend)
|
|
354
|
+
|
|
355
|
+
self.assertEqual(result["memory_ids"], [])
|
|
356
|
+
self.assertEqual(result["memories_written"], 0)
|
|
357
|
+
self.assertEqual(service.read(existing.memory_id).body, existing.body)
|
|
358
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate"])
|
|
359
|
+
|
|
360
|
+
def test_query_with_user_confirmed_durable_fact_remains_admissible(self):
|
|
361
|
+
service = self.make_service("query-with-confirmation")
|
|
362
|
+
backend = QueueBackend()
|
|
363
|
+
user_key, assistant_key = self.capture_turn(
|
|
364
|
+
service,
|
|
365
|
+
session="query-with-confirmation",
|
|
366
|
+
turn="turn-1",
|
|
367
|
+
user="以后处理项目邮件请保持简洁,可以吗?",
|
|
368
|
+
assistant="可以,之后项目邮件保持简洁。",
|
|
369
|
+
)
|
|
370
|
+
backend.responses.extend(
|
|
371
|
+
[
|
|
372
|
+
gate(
|
|
373
|
+
[
|
|
374
|
+
candidate(
|
|
375
|
+
"confirmed-preference",
|
|
376
|
+
[user_key, assistant_key],
|
|
377
|
+
memory="用户偏好项目邮件保持简洁。",
|
|
378
|
+
type="preference",
|
|
379
|
+
)
|
|
380
|
+
]
|
|
381
|
+
),
|
|
382
|
+
summary(
|
|
383
|
+
user_key,
|
|
384
|
+
title="项目邮件保持简洁",
|
|
385
|
+
body="用户偏好项目邮件保持简洁。",
|
|
386
|
+
type="preference",
|
|
387
|
+
),
|
|
388
|
+
]
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
result = service.process(source="hermes", session_id="query-with-confirmation", model=backend)
|
|
392
|
+
|
|
393
|
+
self.assertEqual(result["memories_written"], 1)
|
|
394
|
+
self.assertEqual(service._read_memories_unlocked("knowledge")[0].memory.type, "preference")
|
|
395
|
+
|
|
396
|
+
def test_dynamic_overdue_only_update_is_ignored(self):
|
|
397
|
+
service = self.make_service("dynamic-overdue-update")
|
|
398
|
+
old = service.create_memory(
|
|
399
|
+
memory_id="mem-taixin-overdue",
|
|
400
|
+
title="泰信基金申请日期展示问题",
|
|
401
|
+
body="泰信基金申请日期展示问题逾期26天。",
|
|
402
|
+
tags=["operational"],
|
|
403
|
+
type="todo",
|
|
404
|
+
)
|
|
405
|
+
backend = QueueBackend()
|
|
406
|
+
user_key, assistant_key = self.capture_turn(
|
|
407
|
+
service,
|
|
408
|
+
session="dynamic-overdue-update",
|
|
409
|
+
turn="turn-1",
|
|
410
|
+
user="查询泰信基金当前任务状态。",
|
|
411
|
+
assistant="泰信基金申请日期展示问题现在逾期33天。",
|
|
412
|
+
)
|
|
413
|
+
backend.responses.append(
|
|
414
|
+
gate(
|
|
415
|
+
[
|
|
416
|
+
candidate(
|
|
417
|
+
"taixin-status",
|
|
418
|
+
[user_key, assistant_key],
|
|
419
|
+
memory="泰信基金申请日期展示问题逾期33天。",
|
|
420
|
+
type="todo",
|
|
421
|
+
update_memory_id=old.memory_id,
|
|
422
|
+
)
|
|
423
|
+
]
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
result = service.process(source="hermes", session_id="dynamic-overdue-update", model=backend)
|
|
428
|
+
|
|
429
|
+
self.assertEqual(result["memory_ids"], [])
|
|
430
|
+
self.assertEqual(result["memories_written"], 0)
|
|
431
|
+
self.assertEqual(service.read(old.memory_id).body, old.body)
|
|
432
|
+
|
|
433
|
+
def test_one_time_execution_receipt_is_not_persisted(self):
|
|
434
|
+
service = self.make_service("execution-receipt")
|
|
435
|
+
backend = QueueBackend()
|
|
436
|
+
user_key, assistant_key = self.capture_turn(
|
|
437
|
+
service,
|
|
438
|
+
session="execution-receipt",
|
|
439
|
+
turn="turn-1",
|
|
440
|
+
user="把供数清单发给刘洋。",
|
|
441
|
+
assistant="邮件已发送,已归档核验,服务器已接受提交。",
|
|
442
|
+
)
|
|
443
|
+
backend.responses.append(
|
|
444
|
+
gate(
|
|
445
|
+
[
|
|
446
|
+
candidate(
|
|
447
|
+
"sent-receipt",
|
|
448
|
+
[user_key, assistant_key],
|
|
449
|
+
memory="供数清单邮件已发送并完成归档核验。",
|
|
450
|
+
type="fact",
|
|
451
|
+
)
|
|
452
|
+
]
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
result = service.process(source="hermes", session_id="execution-receipt", model=backend)
|
|
457
|
+
|
|
458
|
+
self.assertEqual(result["memory_ids"], [])
|
|
459
|
+
self.assertEqual(result["memories_written"], 0)
|
|
460
|
+
|
|
461
|
+
def test_read_only_orion_digest_does_not_reingest_atomic_assistant_action(self):
|
|
309
462
|
service = self.make_service("orion-digest")
|
|
310
463
|
backend = QueueBackend()
|
|
311
464
|
user_key, _ = self.capture_turn(
|
|
@@ -329,34 +482,14 @@ class AdmissionFlowTests(unittest.TestCase):
|
|
|
329
482
|
type="todo",
|
|
330
483
|
)
|
|
331
484
|
atomic["scopes"] = ["project:orion"]
|
|
332
|
-
|
|
333
|
-
summary(
|
|
334
|
-
user_key,
|
|
335
|
-
title="Orion提供旧版本生产取数脚本",
|
|
336
|
-
body="Orion需要提供旧版本生产取数脚本。",
|
|
337
|
-
type="todo",
|
|
338
|
-
)
|
|
339
|
-
)
|
|
340
|
-
atomic_summary.update(
|
|
341
|
-
{
|
|
342
|
-
"scopes": ["project:orion"],
|
|
343
|
-
"status": "active",
|
|
344
|
-
}
|
|
345
|
-
)
|
|
346
|
-
backend.responses.extend(
|
|
347
|
-
[gate([aggregate, atomic]), json.dumps(atomic_summary, ensure_ascii=False)]
|
|
348
|
-
)
|
|
485
|
+
backend.responses.append(gate([aggregate, atomic]))
|
|
349
486
|
|
|
350
487
|
result = service.process(source="hermes", session_id="orion-digest", model=backend)
|
|
351
488
|
|
|
352
|
-
self.assertEqual(result["memories_written"],
|
|
489
|
+
self.assertEqual(result["memories_written"], 0)
|
|
353
490
|
self.assertEqual(result["deferred_candidates"], 0)
|
|
354
|
-
|
|
355
|
-
self.assertEqual(
|
|
356
|
-
self.assertEqual(memories[0].memory.title, "Orion提供旧版本生产取数脚本")
|
|
357
|
-
self.assertNotIn("汇总", memories[0].memory.title)
|
|
358
|
-
self.assertNotIn("完成4条", memories[0].memory.body)
|
|
359
|
-
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize"])
|
|
491
|
+
self.assertEqual(service._read_memories_unlocked("knowledge"), [])
|
|
492
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate"])
|
|
360
493
|
|
|
361
494
|
def test_attachment_only_followup_is_dropped_before_summary(self):
|
|
362
495
|
service = self.make_service("attachment-followup")
|
|
@@ -412,7 +545,7 @@ class AdmissionFlowTests(unittest.TestCase):
|
|
|
412
545
|
|
|
413
546
|
self.assertEqual(result["memories_written"], 0)
|
|
414
547
|
self.assertEqual(service._read_memories_unlocked("knowledge"), [])
|
|
415
|
-
self.assertEqual([call["purpose"] for call in backend.calls], ["gate"
|
|
548
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate"])
|
|
416
549
|
|
|
417
550
|
def test_attachment_with_owner_deadline_and_remediation_remains_admissible(self):
|
|
418
551
|
service = self.make_service("attachment-action")
|
|
@@ -421,8 +554,8 @@ class AdmissionFlowTests(unittest.TestCase):
|
|
|
421
554
|
service,
|
|
422
555
|
session="attachment-action",
|
|
423
556
|
turn="turn-1",
|
|
424
|
-
user="
|
|
425
|
-
assistant="
|
|
557
|
+
user="已确认张三需在2026-09-03前逐项整改鑫元基金SIT问题清单,还要补充什么吗?",
|
|
558
|
+
assistant="该负责人、期限和整改要求已经明确。",
|
|
426
559
|
)
|
|
427
560
|
item = candidate(
|
|
428
561
|
"xinyuan-sit-remediation",
|
|
@@ -303,11 +303,11 @@ class HermesProviderTests(unittest.TestCase):
|
|
|
303
303
|
|
|
304
304
|
def test_provider_does_not_warn_when_core_versions_match(self) -> None:
|
|
305
305
|
client = FakeClient(responses=[{"stats": True}])
|
|
306
|
-
client.server_version = "0.2.
|
|
306
|
+
client.server_version = "0.2.16"
|
|
307
307
|
provider = provider_module.MemleafMemoryProvider()
|
|
308
308
|
with patch.object(provider_module, "_resolve_command", return_value="memleaf-mcp"), \
|
|
309
309
|
patch.object(provider_module, "_MCPClient", return_value=client), \
|
|
310
|
-
patch.object(provider_module, "_provider_manifest_version", return_value="0.2.
|
|
310
|
+
patch.object(provider_module, "_provider_manifest_version", return_value="0.2.16"), \
|
|
311
311
|
patch.object(provider_module.logger, "warning") as warning:
|
|
312
312
|
provider.initialize(
|
|
313
313
|
"version-match-session",
|
|
@@ -324,7 +324,7 @@ class HermesProviderTests(unittest.TestCase):
|
|
|
324
324
|
provider = provider_module.MemleafMemoryProvider()
|
|
325
325
|
with patch.object(provider_module, "_resolve_command", return_value="memleaf-mcp"), \
|
|
326
326
|
patch.object(provider_module, "_MCPClient", return_value=client), \
|
|
327
|
-
patch.object(provider_module, "_provider_manifest_version", return_value="0.2.
|
|
327
|
+
patch.object(provider_module, "_provider_manifest_version", return_value="0.2.16"), \
|
|
328
328
|
patch.object(provider_module.logger, "warning") as warning:
|
|
329
329
|
provider.initialize(
|
|
330
330
|
"version-missing-session",
|
|
@@ -1481,7 +1481,7 @@ class HermesProviderTests(unittest.TestCase):
|
|
|
1481
1481
|
)
|
|
1482
1482
|
self.assertEqual(provider_module._hermes_read_status(empty), "missing_result")
|
|
1483
1483
|
|
|
1484
|
-
def
|
|
1484
|
+
def test_read_diagnostics_keep_token_after_client_reconnect_without_relogging_history(self) -> None:
|
|
1485
1485
|
provider = self.provider(
|
|
1486
1486
|
responses=[{"stored": True}, {"stored": True}, {"processed_turns": 1}] * 2
|
|
1487
1487
|
)
|
|
@@ -1538,11 +1538,82 @@ class HermesProviderTests(unittest.TestCase):
|
|
|
1538
1538
|
|
|
1539
1539
|
output = "\n".join(call.args[0] % call.args[1:] for call in info.call_args_list)
|
|
1540
1540
|
read_logs = [line for line in output.splitlines() if "retrieval-read" in line]
|
|
1541
|
-
self.assertEqual(len(read_logs),
|
|
1541
|
+
self.assertEqual(len(read_logs), 2)
|
|
1542
1542
|
self.assertTrue(all("retrieval_present=True" in line for line in read_logs))
|
|
1543
1543
|
self.assertTrue(all("retrieval_match=True" in line for line in read_logs))
|
|
1544
1544
|
self.assertNotIn("rtv-reconnect", output)
|
|
1545
1545
|
|
|
1546
|
+
def test_read_diagnostics_skip_cumulative_history_but_keep_current_wrong_token(self) -> None:
|
|
1547
|
+
old_call = {
|
|
1548
|
+
"id": "read-old",
|
|
1549
|
+
"function": {
|
|
1550
|
+
"name": "mcp__memleaf__read",
|
|
1551
|
+
"arguments": json.dumps(
|
|
1552
|
+
{"memory_id": "MEMORY_OLD", "retrieval_id": "rtv-old"}
|
|
1553
|
+
),
|
|
1554
|
+
},
|
|
1555
|
+
}
|
|
1556
|
+
current_call = {
|
|
1557
|
+
"id": "read-current",
|
|
1558
|
+
"function": {
|
|
1559
|
+
"name": "mcp__memleaf__read",
|
|
1560
|
+
"arguments": json.dumps(
|
|
1561
|
+
{"memory_id": "MEMORY_CURRENT", "retrieval_id": "rtv-current"}
|
|
1562
|
+
),
|
|
1563
|
+
},
|
|
1564
|
+
}
|
|
1565
|
+
wrong_token_call = {
|
|
1566
|
+
"id": "read-current-wrong-token",
|
|
1567
|
+
"function": {
|
|
1568
|
+
"name": "mcp__memleaf__read",
|
|
1569
|
+
"arguments": json.dumps(
|
|
1570
|
+
{"memory_id": "MEMORY_WRONG", "retrieval_id": "rtv-other"}
|
|
1571
|
+
),
|
|
1572
|
+
},
|
|
1573
|
+
}
|
|
1574
|
+
first_messages = [
|
|
1575
|
+
{"role": "assistant", "tool_calls": [old_call]},
|
|
1576
|
+
{
|
|
1577
|
+
"role": "tool",
|
|
1578
|
+
"tool_call_id": "read-old",
|
|
1579
|
+
"content": json.dumps({"memory_id": "MEMORY_OLD", "body": "old"}),
|
|
1580
|
+
},
|
|
1581
|
+
]
|
|
1582
|
+
cumulative_messages = [
|
|
1583
|
+
*first_messages,
|
|
1584
|
+
{"role": "assistant", "tool_calls": [current_call, wrong_token_call]},
|
|
1585
|
+
{
|
|
1586
|
+
"role": "tool",
|
|
1587
|
+
"tool_call_id": "read-current",
|
|
1588
|
+
"content": json.dumps({"memory_id": "MEMORY_CURRENT", "body": "current"}),
|
|
1589
|
+
},
|
|
1590
|
+
{
|
|
1591
|
+
"role": "tool",
|
|
1592
|
+
"tool_call_id": "read-current-wrong-token",
|
|
1593
|
+
"content": json.dumps({"memory_id": "MEMORY_WRONG", "body": "wrong"}),
|
|
1594
|
+
},
|
|
1595
|
+
]
|
|
1596
|
+
seen_call_keys: set[str] = set()
|
|
1597
|
+
|
|
1598
|
+
with patch.object(provider_module.logger, "info") as info:
|
|
1599
|
+
provider_module.MemleafMemoryProvider._observe_search_messages(
|
|
1600
|
+
first_messages,
|
|
1601
|
+
"rtv-old",
|
|
1602
|
+
seen_call_keys=seen_call_keys,
|
|
1603
|
+
)
|
|
1604
|
+
provider_module.MemleafMemoryProvider._observe_search_messages(
|
|
1605
|
+
cumulative_messages,
|
|
1606
|
+
"rtv-current",
|
|
1607
|
+
seen_call_keys=seen_call_keys,
|
|
1608
|
+
)
|
|
1609
|
+
|
|
1610
|
+
output = "\n".join(call.args[0] % call.args[1:] for call in info.call_args_list)
|
|
1611
|
+
read_logs = [line for line in output.splitlines() if "retrieval-read" in line]
|
|
1612
|
+
self.assertEqual(len(read_logs), 3)
|
|
1613
|
+
self.assertIn("retrieval_match=True result=ok", read_logs[0])
|
|
1614
|
+
self.assertIn("retrieval_match=True result=ok", read_logs[1])
|
|
1615
|
+
self.assertIn("retrieval_match=False result=uncontrolled_success", read_logs[2])
|
|
1616
|
+
|
|
1546
1617
|
def test_file_tools_only_mark_paths_inside_configured_vault_as_bypass(self) -> None:
|
|
1547
1618
|
vault = self.root / "configured-vault"
|
|
1548
1619
|
vault.mkdir()
|
|
@@ -81,7 +81,7 @@ class PyPIInstallTests(unittest.TestCase):
|
|
|
81
81
|
|
|
82
82
|
self.assertTrue(target.is_dir())
|
|
83
83
|
self.assertFalse(target.is_symlink())
|
|
84
|
-
self.assertIn("version: 0.2.
|
|
84
|
+
self.assertIn("version: 0.2.16", (target / "plugin.yaml").read_text(encoding="utf-8"))
|
|
85
85
|
|
|
86
86
|
def test_windows_hermes_paths_follow_official_native_layout(self) -> None:
|
|
87
87
|
with tempfile.TemporaryDirectory(prefix="memleaf-win-paths-") as temporary:
|
|
@@ -212,16 +212,16 @@ class PyPIInstallTests(unittest.TestCase):
|
|
|
212
212
|
"status": "configured",
|
|
213
213
|
"reason": "ok",
|
|
214
214
|
"vault": "/tmp/memleaf-vault",
|
|
215
|
-
"core_version": "0.2.
|
|
216
|
-
"provider_version": "0.2.
|
|
215
|
+
"core_version": "0.2.16",
|
|
216
|
+
"provider_version": "0.2.16",
|
|
217
217
|
"model": {"status": "configured"},
|
|
218
218
|
}
|
|
219
219
|
with mock.patch("memleaf.installer.install_hermes", return_value=result), \
|
|
220
220
|
mock.patch("sys.stdout") as stdout:
|
|
221
221
|
self.assertEqual(cli.main(["install"]), 0)
|
|
222
222
|
output = "\n".join(str(call.args[0]) for call in stdout.write.call_args_list)
|
|
223
|
-
self.assertIn("core=0.2.
|
|
224
|
-
self.assertIn("Hermes provider=0.2.
|
|
223
|
+
self.assertIn("core=0.2.16", output)
|
|
224
|
+
self.assertIn("Hermes provider=0.2.16", output)
|
|
225
225
|
|
|
226
226
|
def test_readmes_document_one_line_core_and_provider_upgrade(self) -> None:
|
|
227
227
|
command = "python -m pip install -U memleaf && python -m memleaf install"
|
|
@@ -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.16"},
|
|
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.16")
|
|
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
|
|
File without changes
|