memleaf 0.2.22__tar.gz → 0.2.23__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.22 → memleaf-0.2.23}/CHANGELOG.md +8 -0
- {memleaf-0.2.22/src/memleaf.egg-info → memleaf-0.2.23}/PKG-INFO +3 -3
- {memleaf-0.2.22 → memleaf-0.2.23}/README.en.md +2 -2
- {memleaf-0.2.22 → memleaf-0.2.23}/README.md +2 -2
- {memleaf-0.2.22 → memleaf-0.2.23}/pyproject.toml +1 -1
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/capture.py +46 -1
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/hermes_provider/__init__.py +94 -1
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/inbox.py +28 -1
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/mcp_server.py +11 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/memory_writer.py +64 -2
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/processing.py +325 -21
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/scope_state.py +52 -1
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/service.py +2 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/validation.py +41 -0
- {memleaf-0.2.22 → memleaf-0.2.23/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf.egg-info/SOURCES.txt +1 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_extraction_quality_regressions.py +2 -2
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_pypi_install.py +1 -1
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_c1_mcp.py +2 -2
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_c3_packaging.py +0 -1
- memleaf-0.2.23/tests/test_v023_scope_correction.py +171 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/LICENSE +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/MANIFEST.in +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/examples/README.md +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/examples/basic_usage.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/install.ps1 +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/install.sh +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/setup.cfg +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/config.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/index.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/models.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/native_registration.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/prompts.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/__init__.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_admission_noise.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_cross_turn_dedupe_regressions.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_global_todo_acceptance.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_global_todo_retrieval.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_hermes_native_registration.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_hermes_provider.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_host_events.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_install.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_maintenance_v2.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_b1.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_b2a.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_todo_state_recovery.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_update_target_recovery.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.22 → memleaf-0.2.23}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.23 — 2026-09-03
|
|
6
|
+
|
|
7
|
+
- Add a strict `scope_correction` transaction for explicit cross-project corrections without weakening ordinary UPDATE scope isolation: uniquely recover the wrong active target, reuse its `memory_id` when appropriate, or retire it to history with `invalidated_reason: scope_correction` and `superseded_by` when a correct active survivor already exists.
|
|
8
|
+
- Deterministically split same-project `mixed_future_use` candidates after bounded model retries only when every clause is safely classifiable as durable project/fact state or an unfinished todo; ambiguous fragments remain deferred and valid siblings continue independently.
|
|
9
|
+
- Add bounded mail-tool evidence capture limited to message ID, subject, sender and sender domain, plus private per-scope domain identifiers; a unique domain/scope conflict defers extraction instead of writing a wrongly attributed project memory, while full tool output remains excluded.
|
|
10
|
+
- Preserve global `list_todos` pagination and unlimited aggregate managed reads from 0.2.20; no source/session ownership filter or aggregate read quota is reintroduced.
|
|
11
|
+
- Hermes still uses its existing Soft Gate in this package release. Generic fail-closed pre-final retrieval enforcement requires a Hermes host lifecycle hook and is tracked upstream in NousResearch/hermes-agent#101973.
|
|
12
|
+
|
|
5
13
|
## 0.2.22 — 2026-09-03
|
|
6
14
|
|
|
7
15
|
- Safely split valid multi-project `unscoped` aggregate candidates using only
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.23
|
|
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.23。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.23 通过 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.23.**
|
|
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.23 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.23。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.23 通过 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.23"
|
|
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
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
import re
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Optional
|
|
9
|
+
from typing import Any, Mapping, Optional
|
|
10
10
|
|
|
11
11
|
from .index import (
|
|
12
12
|
EVENT_CONTENT,
|
|
@@ -28,6 +28,45 @@ def _timestamp() -> str:
|
|
|
28
28
|
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
|
|
32
|
+
_MAIL_EVIDENCE_FIELDS = frozenset({"message_id", "subject", "sender", "domain"})
|
|
33
|
+
_MAIL_DOMAIN_RE = re.compile(r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$", re.IGNORECASE)
|
|
34
|
+
_MAX_MAIL_EVIDENCE_ITEMS = 8
|
|
35
|
+
_MAX_MAIL_EVIDENCE_TEXT = 320
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize_tool_evidence(value: Any) -> list[dict[str, str]]:
|
|
39
|
+
if value is None:
|
|
40
|
+
return []
|
|
41
|
+
if not isinstance(value, (list, tuple)):
|
|
42
|
+
raise ValueError("tool evidence must be a list")
|
|
43
|
+
result: list[dict[str, str]] = []
|
|
44
|
+
seen: set[tuple[tuple[str, str], ...]] = set()
|
|
45
|
+
for raw in list(value)[:_MAX_MAIL_EVIDENCE_ITEMS]:
|
|
46
|
+
if not isinstance(raw, Mapping) or set(raw) - _MAIL_EVIDENCE_FIELDS:
|
|
47
|
+
raise ValueError("invalid tool evidence record")
|
|
48
|
+
item: dict[str, str] = {}
|
|
49
|
+
for key in _MAIL_EVIDENCE_FIELDS:
|
|
50
|
+
field = raw.get(key)
|
|
51
|
+
if field is None:
|
|
52
|
+
continue
|
|
53
|
+
if not isinstance(field, str) or not field.strip() or any(ch in field for ch in "\x00\r\n"):
|
|
54
|
+
raise ValueError("invalid tool evidence field")
|
|
55
|
+
text = redact_text(field.strip())[:_MAX_MAIL_EVIDENCE_TEXT]
|
|
56
|
+
if key == "domain":
|
|
57
|
+
text = text.casefold().lstrip("@")
|
|
58
|
+
if not _MAIL_DOMAIN_RE.fullmatch(text):
|
|
59
|
+
raise ValueError("invalid tool evidence domain")
|
|
60
|
+
item[key] = text
|
|
61
|
+
if not item:
|
|
62
|
+
continue
|
|
63
|
+
fingerprint = tuple(sorted(item.items()))
|
|
64
|
+
if fingerprint in seen:
|
|
65
|
+
continue
|
|
66
|
+
seen.add(fingerprint)
|
|
67
|
+
result.append(item)
|
|
68
|
+
return result
|
|
69
|
+
|
|
31
70
|
def _event_id(source: str, session_id: str, turn_id: str, role: str, event_id: Optional[str]) -> str:
|
|
32
71
|
if event_id is not None:
|
|
33
72
|
if not isinstance(event_id, str) or not event_id or "\x00" in event_id or "\n" in event_id or "\r" in event_id:
|
|
@@ -149,6 +188,7 @@ def _append_event(
|
|
|
149
188
|
event_digest: str,
|
|
150
189
|
event_turn_key: str,
|
|
151
190
|
turn_index: int,
|
|
191
|
+
tool_evidence: list[dict[str, str]] | None = None,
|
|
152
192
|
) -> str:
|
|
153
193
|
timestamp = _timestamp()
|
|
154
194
|
if not existing:
|
|
@@ -168,6 +208,8 @@ def _append_event(
|
|
|
168
208
|
"turn_index": turn_index,
|
|
169
209
|
"timestamp": timestamp,
|
|
170
210
|
}
|
|
211
|
+
if tool_evidence:
|
|
212
|
+
metadata["tool_evidence"] = [dict(item) for item in tool_evidence]
|
|
171
213
|
metadata_line = json.dumps(metadata, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
172
214
|
return (
|
|
173
215
|
f"{updated}{EVENT_START}\n"
|
|
@@ -207,6 +249,7 @@ def capture_event(
|
|
|
207
249
|
event_id: Optional[str] = None,
|
|
208
250
|
record: bool = True,
|
|
209
251
|
visible: bool = True,
|
|
252
|
+
tool_evidence: Any = None,
|
|
210
253
|
) -> CaptureResult:
|
|
211
254
|
"""Capture one visible event; all persisted text is redacted first."""
|
|
212
255
|
|
|
@@ -222,6 +265,7 @@ def capture_event(
|
|
|
222
265
|
resolved_turn_key = turn_key(turn_id)
|
|
223
266
|
persisted_turn_id = _safe_turn_id(turn_id)
|
|
224
267
|
safe_content = escape_event_markers(redact_text(content))
|
|
268
|
+
safe_tool_evidence = _normalize_tool_evidence(tool_evidence)
|
|
225
269
|
if not record or not visible or role not in ("user", "assistant"):
|
|
226
270
|
return CaptureResult(resolved_event_id, stored=False, duplicate=False, content=safe_content)
|
|
227
271
|
|
|
@@ -266,6 +310,7 @@ def capture_event(
|
|
|
266
310
|
resolved_event_key,
|
|
267
311
|
resolved_turn_key,
|
|
268
312
|
turn_index,
|
|
313
|
+
safe_tool_evidence,
|
|
269
314
|
)
|
|
270
315
|
atomic_write_text(path, updated)
|
|
271
316
|
|
|
@@ -108,6 +108,11 @@ _UNTRUSTED_TOOL_RESULT_TAG = "<untrusted_tool_result"
|
|
|
108
108
|
_UNTRUSTED_TOOL_RESULT_END = "</untrusted_tool_result>"
|
|
109
109
|
|
|
110
110
|
|
|
111
|
+
_MAX_MAIL_EVIDENCE_ITEMS = 8
|
|
112
|
+
_MAIL_ADDRESS_RE = re.compile(r"(?i)([A-Z0-9._%+-]+@([A-Z0-9.-]+\.[A-Z]{2,63}))")
|
|
113
|
+
_MAIL_TOOL_RE = re.compile(r"(?:gmail|outlook|mail|email)", re.IGNORECASE)
|
|
114
|
+
|
|
115
|
+
|
|
111
116
|
class _MCPToolError(RuntimeError):
|
|
112
117
|
"""Safe structured model error returned by the local MCP boundary."""
|
|
113
118
|
|
|
@@ -1152,6 +1157,83 @@ def _resolve_command(config: Mapping[str, Any]) -> Optional[str]:
|
|
|
1152
1157
|
)
|
|
1153
1158
|
|
|
1154
1159
|
|
|
1160
|
+
def _bounded_mail_evidence(messages: Optional[List[Dict[str, Any]]]) -> list[dict[str, str]]:
|
|
1161
|
+
"""Extract only message id/subject/sender/domain from visible mail tool results."""
|
|
1162
|
+
|
|
1163
|
+
calls = _visible_tool_calls(messages)
|
|
1164
|
+
results = _visible_tool_results(messages)
|
|
1165
|
+
used: set[int] = set()
|
|
1166
|
+
output: list[dict[str, str]] = []
|
|
1167
|
+
seen: set[tuple[tuple[str, str], ...]] = set()
|
|
1168
|
+
|
|
1169
|
+
def add_record(mapping: Mapping[str, Any]) -> None:
|
|
1170
|
+
if len(output) >= _MAX_MAIL_EVIDENCE_ITEMS:
|
|
1171
|
+
return
|
|
1172
|
+
lowered = {str(key).casefold(): value for key, value in mapping.items()}
|
|
1173
|
+
message_id = next((lowered.get(key) for key in ("message_id", "messageid", "id") if isinstance(lowered.get(key), (str, int))), None)
|
|
1174
|
+
subject = next((lowered.get(key) for key in ("subject", "title") if isinstance(lowered.get(key), str)), None)
|
|
1175
|
+
sender = next((lowered.get(key) for key in ("sender", "from", "from_address", "from_email", "email") if isinstance(lowered.get(key), str)), None)
|
|
1176
|
+
domain = next((lowered.get(key) for key in ("domain", "sender_domain") if isinstance(lowered.get(key), str)), None)
|
|
1177
|
+
if isinstance(sender, str):
|
|
1178
|
+
match = _MAIL_ADDRESS_RE.search(sender)
|
|
1179
|
+
if match and not domain:
|
|
1180
|
+
domain = match.group(2)
|
|
1181
|
+
item: dict[str, str] = {}
|
|
1182
|
+
for key, value, limit in (
|
|
1183
|
+
("message_id", message_id, 160),
|
|
1184
|
+
("subject", subject, 320),
|
|
1185
|
+
("sender", sender, 320),
|
|
1186
|
+
("domain", domain, 253),
|
|
1187
|
+
):
|
|
1188
|
+
if value is None:
|
|
1189
|
+
continue
|
|
1190
|
+
text = str(value).strip()
|
|
1191
|
+
if not text or any(ch in text for ch in "\x00\r\n"):
|
|
1192
|
+
continue
|
|
1193
|
+
if key == "domain":
|
|
1194
|
+
text = text.casefold().lstrip("@")
|
|
1195
|
+
if not re.fullmatch(r"(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}", text):
|
|
1196
|
+
continue
|
|
1197
|
+
item[key] = text[:limit]
|
|
1198
|
+
if "domain" not in item:
|
|
1199
|
+
return
|
|
1200
|
+
fingerprint = tuple(sorted(item.items()))
|
|
1201
|
+
if fingerprint not in seen:
|
|
1202
|
+
seen.add(fingerprint)
|
|
1203
|
+
output.append(item)
|
|
1204
|
+
|
|
1205
|
+
def visit(value: Any, depth: int = 0) -> None:
|
|
1206
|
+
if depth > 4 or len(output) >= _MAX_MAIL_EVIDENCE_ITEMS:
|
|
1207
|
+
return
|
|
1208
|
+
decoded = _decode_hermes_tool_result(value)
|
|
1209
|
+
if decoded is _MISSING_TOOL_RESULT:
|
|
1210
|
+
return
|
|
1211
|
+
if decoded is not value:
|
|
1212
|
+
visit(decoded, depth + 1)
|
|
1213
|
+
return
|
|
1214
|
+
if isinstance(value, Mapping):
|
|
1215
|
+
add_record(value)
|
|
1216
|
+
for child in value.values():
|
|
1217
|
+
if isinstance(child, (Mapping, list, tuple)):
|
|
1218
|
+
visit(child, depth + 1)
|
|
1219
|
+
elif isinstance(value, (list, tuple)):
|
|
1220
|
+
for child in value:
|
|
1221
|
+
visit(child, depth + 1)
|
|
1222
|
+
|
|
1223
|
+
ordinal = 0
|
|
1224
|
+
for call in calls:
|
|
1225
|
+
name = call.get("name")
|
|
1226
|
+
if not isinstance(name, str) or not _MAIL_TOOL_RE.search(name):
|
|
1227
|
+
continue
|
|
1228
|
+
ordinal += 1
|
|
1229
|
+
payload = _tool_result_for_call(call, calls, results, used)
|
|
1230
|
+
if payload is not _CALL_FAILED:
|
|
1231
|
+
visit(payload)
|
|
1232
|
+
if len(output) >= _MAX_MAIL_EVIDENCE_ITEMS:
|
|
1233
|
+
break
|
|
1234
|
+
return output
|
|
1235
|
+
|
|
1236
|
+
|
|
1155
1237
|
class MemleafMemoryProvider(MemoryProvider):
|
|
1156
1238
|
"""Use the local memleaf vault as Hermes' single external provider."""
|
|
1157
1239
|
|
|
@@ -2115,7 +2197,15 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2115
2197
|
)
|
|
2116
2198
|
return result
|
|
2117
2199
|
|
|
2118
|
-
def _capture_visible(
|
|
2200
|
+
def _capture_visible(
|
|
2201
|
+
self,
|
|
2202
|
+
*,
|
|
2203
|
+
session_id: str,
|
|
2204
|
+
turn_id: str,
|
|
2205
|
+
role: str,
|
|
2206
|
+
content: str,
|
|
2207
|
+
tool_evidence: Optional[List[Dict[str, str]]] = None,
|
|
2208
|
+
) -> bool:
|
|
2119
2209
|
result = self._call(
|
|
2120
2210
|
"capture",
|
|
2121
2211
|
{
|
|
@@ -2126,6 +2216,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2126
2216
|
"content": content,
|
|
2127
2217
|
"record": True,
|
|
2128
2218
|
"visible": True,
|
|
2219
|
+
**({"tool_evidence": tool_evidence} if tool_evidence else {}),
|
|
2129
2220
|
},
|
|
2130
2221
|
stage=f"capture_{role}",
|
|
2131
2222
|
session_id=session_id,
|
|
@@ -2414,12 +2505,14 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2414
2505
|
self._last_retrieval_observation = observation
|
|
2415
2506
|
self._last_retrieval_audit = str(audit_state.get("status") or "SEARCH_UNKNOWN")
|
|
2416
2507
|
lineage_ready = self._retry_pending_lineage(effective_session)
|
|
2508
|
+
mail_evidence = _bounded_mail_evidence(messages)
|
|
2417
2509
|
for role, content in visible_events:
|
|
2418
2510
|
if not self._capture_visible(
|
|
2419
2511
|
session_id=effective_session,
|
|
2420
2512
|
turn_id=turn_id,
|
|
2421
2513
|
role=role,
|
|
2422
2514
|
content=content,
|
|
2515
|
+
tool_evidence=mail_evidence if role == "assistant" else None,
|
|
2423
2516
|
):
|
|
2424
2517
|
return
|
|
2425
2518
|
if not self._auto_process:
|
|
@@ -13,7 +13,7 @@ import json
|
|
|
13
13
|
import re
|
|
14
14
|
from dataclasses import dataclass
|
|
15
15
|
from pathlib import Path
|
|
16
|
-
from typing import Any, Iterable, Optional
|
|
16
|
+
from typing import Any, Iterable, Mapping, Optional
|
|
17
17
|
|
|
18
18
|
from .index import EVENT_V2_BLOCK, extract_event_metadata
|
|
19
19
|
|
|
@@ -32,6 +32,7 @@ class InboxEvent:
|
|
|
32
32
|
content: str
|
|
33
33
|
turn_id: Optional[str] = None
|
|
34
34
|
timestamp: Optional[str] = None
|
|
35
|
+
tool_evidence: tuple[dict[str, str], ...] = ()
|
|
35
36
|
legacy: bool = False
|
|
36
37
|
|
|
37
38
|
@property
|
|
@@ -107,6 +108,30 @@ def _event_metadata(text: str) -> list[dict[str, Any]]:
|
|
|
107
108
|
return result
|
|
108
109
|
|
|
109
110
|
|
|
111
|
+
|
|
112
|
+
def _bounded_tool_evidence(value: Any) -> tuple[dict[str, str], ...]:
|
|
113
|
+
if not isinstance(value, list):
|
|
114
|
+
return ()
|
|
115
|
+
allowed = {"message_id", "subject", "sender", "domain"}
|
|
116
|
+
result: list[dict[str, str]] = []
|
|
117
|
+
for raw in value[:8]:
|
|
118
|
+
if not isinstance(raw, Mapping) or set(raw) - allowed:
|
|
119
|
+
continue
|
|
120
|
+
item = {
|
|
121
|
+
str(key): str(field)
|
|
122
|
+
for key, field in raw.items()
|
|
123
|
+
if key in allowed
|
|
124
|
+
and isinstance(field, str)
|
|
125
|
+
and field
|
|
126
|
+
and "\x00" not in field
|
|
127
|
+
and "\r" not in field
|
|
128
|
+
and "\n" not in field
|
|
129
|
+
and len(field) <= 320
|
|
130
|
+
}
|
|
131
|
+
if item:
|
|
132
|
+
result.append(item)
|
|
133
|
+
return tuple(result)
|
|
134
|
+
|
|
110
135
|
def parse_inbox_text(
|
|
111
136
|
text: str,
|
|
112
137
|
*,
|
|
@@ -180,6 +205,7 @@ def parse_inbox_text(
|
|
|
180
205
|
content=str(metadata.get("content", "")),
|
|
181
206
|
turn_id=display_turn_id if isinstance(display_turn_id, str) else None,
|
|
182
207
|
timestamp=metadata.get("timestamp") if isinstance(metadata.get("timestamp"), str) else None,
|
|
208
|
+
tool_evidence=_bounded_tool_evidence(metadata.get("tool_evidence")),
|
|
183
209
|
legacy=not groupable,
|
|
184
210
|
)
|
|
185
211
|
)
|
|
@@ -195,6 +221,7 @@ def parse_inbox_text(
|
|
|
195
221
|
content=str(metadata.get("content", "")),
|
|
196
222
|
turn_id=display_turn_id,
|
|
197
223
|
timestamp=metadata.get("timestamp") if isinstance(metadata.get("timestamp"), str) else None,
|
|
224
|
+
tool_evidence=_bounded_tool_evidence(metadata.get("tool_evidence")),
|
|
198
225
|
)
|
|
199
226
|
)
|
|
200
227
|
|
|
@@ -138,6 +138,17 @@ _TOOLS: tuple[dict[str, Any], ...] = (
|
|
|
138
138
|
"event_id": {"type": "string"},
|
|
139
139
|
"record": {"type": "boolean"},
|
|
140
140
|
"visible": {"type": "boolean"},
|
|
141
|
+
"tool_evidence": {
|
|
142
|
+
"type": "array",
|
|
143
|
+
"items": _object_schema(
|
|
144
|
+
{
|
|
145
|
+
"message_id": {"type": "string"},
|
|
146
|
+
"subject": {"type": "string"},
|
|
147
|
+
"sender": {"type": "string"},
|
|
148
|
+
"domain": {"type": "string"},
|
|
149
|
+
}
|
|
150
|
+
),
|
|
151
|
+
},
|
|
141
152
|
},
|
|
142
153
|
required=["source", "session_id", "turn_id", "role", "content"],
|
|
143
154
|
),
|
|
@@ -160,6 +160,26 @@ class MemoryWriter:
|
|
|
160
160
|
duplicate_ids: set[str] = set()
|
|
161
161
|
memory_ids: set[str] = set()
|
|
162
162
|
for request in requests:
|
|
163
|
+
correction = request.get("scope_correction")
|
|
164
|
+
if isinstance(correction, Mapping) and correction.get("survivor_memory_id"):
|
|
165
|
+
target_id = correction.get("target_memory_id")
|
|
166
|
+
survivor_id = correction.get("survivor_memory_id")
|
|
167
|
+
if not isinstance(target_id, str) or not isinstance(survivor_id, str) or target_id == survivor_id:
|
|
168
|
+
raise self._preflight_error("invalid scope correction targets")
|
|
169
|
+
target_record = active.get(target_id)
|
|
170
|
+
survivor_record = active.get(survivor_id)
|
|
171
|
+
if target_record is None or survivor_record is None:
|
|
172
|
+
raise self._preflight_error("scope correction target is not active")
|
|
173
|
+
target_memory = getattr(target_record, "memory", target_record)
|
|
174
|
+
survivor_memory = getattr(survivor_record, "memory", survivor_record)
|
|
175
|
+
if not isinstance(target_memory, Memory) or not isinstance(survivor_memory, Memory):
|
|
176
|
+
raise self._preflight_error("scope correction memory is invalid")
|
|
177
|
+
if target_memory.type != survivor_memory.type or survivor_memory.type != request["summary"].get("type"):
|
|
178
|
+
raise self._preflight_error("scope correction type mismatch")
|
|
179
|
+
if request.get("memory_id") != survivor_id:
|
|
180
|
+
raise self._preflight_error("scope correction survivor id mismatch")
|
|
181
|
+
active.pop(target_id, None)
|
|
182
|
+
continue
|
|
163
183
|
summary = request["summary"]
|
|
164
184
|
duplicate_id = request.get("duplicate_memory_id")
|
|
165
185
|
if duplicate_id is not None:
|
|
@@ -338,7 +358,14 @@ class MemoryWriter:
|
|
|
338
358
|
values = [scope for scope in values if scope != "unscoped"]
|
|
339
359
|
return values or ["global"]
|
|
340
360
|
|
|
341
|
-
def _write_history(
|
|
361
|
+
def _write_history(
|
|
362
|
+
self,
|
|
363
|
+
old: Memory,
|
|
364
|
+
*,
|
|
365
|
+
superseded_by: str,
|
|
366
|
+
archived_at: str,
|
|
367
|
+
invalidated_reason: str | None = None,
|
|
368
|
+
) -> str:
|
|
342
369
|
history_id = self._history_id(old)
|
|
343
370
|
extra = dict(old.extra)
|
|
344
371
|
extra.update(
|
|
@@ -348,6 +375,8 @@ class MemoryWriter:
|
|
|
348
375
|
"archived_at": archived_at,
|
|
349
376
|
}
|
|
350
377
|
)
|
|
378
|
+
if invalidated_reason is not None:
|
|
379
|
+
extra["invalidated_reason"] = invalidated_reason
|
|
351
380
|
historical = Memory(
|
|
352
381
|
memory_id=history_id,
|
|
353
382
|
title=old.title,
|
|
@@ -390,6 +419,32 @@ class MemoryWriter:
|
|
|
390
419
|
active_records: Mapping[str, Any] | None = None,
|
|
391
420
|
) -> Memory:
|
|
392
421
|
active = active_records if active_records is not None else self._active_records()
|
|
422
|
+
correction = request.get("scope_correction")
|
|
423
|
+
if isinstance(correction, Mapping) and correction.get("survivor_memory_id"):
|
|
424
|
+
target_id = correction.get("target_memory_id")
|
|
425
|
+
survivor_id = correction.get("survivor_memory_id")
|
|
426
|
+
target_record = active.get(target_id)
|
|
427
|
+
survivor_record = active.get(survivor_id)
|
|
428
|
+
if target_record is None or survivor_record is None:
|
|
429
|
+
raise ModelOutputError("scope correction target is not active")
|
|
430
|
+
target = getattr(target_record, "memory", target_record)
|
|
431
|
+
survivor = getattr(survivor_record, "memory", survivor_record)
|
|
432
|
+
if not isinstance(target, Memory) or not isinstance(survivor, Memory):
|
|
433
|
+
raise ModelOutputError("scope correction memory is invalid")
|
|
434
|
+
self._write_history(
|
|
435
|
+
target,
|
|
436
|
+
superseded_by=survivor.memory_id,
|
|
437
|
+
archived_at=now,
|
|
438
|
+
invalidated_reason="scope_correction",
|
|
439
|
+
)
|
|
440
|
+
path = self.service.vault.memory_path(target.memory_id, "knowledge")
|
|
441
|
+
if path.is_symlink():
|
|
442
|
+
raise ModelOutputError("unsafe knowledge path")
|
|
443
|
+
if path.exists():
|
|
444
|
+
path.unlink()
|
|
445
|
+
if isinstance(active, dict):
|
|
446
|
+
active.pop(target.memory_id, None)
|
|
447
|
+
return survivor
|
|
393
448
|
duplicate_id = request.get("duplicate_memory_id")
|
|
394
449
|
if duplicate_id is not None:
|
|
395
450
|
existing_record = active.get(duplicate_id)
|
|
@@ -448,7 +503,14 @@ class MemoryWriter:
|
|
|
448
503
|
self.last_noop_memory_ids.add(request["memory_id"])
|
|
449
504
|
return existing
|
|
450
505
|
if existing is not None:
|
|
451
|
-
|
|
506
|
+
correction = request.get("scope_correction")
|
|
507
|
+
invalidated_reason = "scope_correction" if isinstance(correction, Mapping) else None
|
|
508
|
+
self._write_history(
|
|
509
|
+
existing,
|
|
510
|
+
superseded_by=desired.memory_id,
|
|
511
|
+
archived_at=now,
|
|
512
|
+
invalidated_reason=invalidated_reason,
|
|
513
|
+
)
|
|
452
514
|
path = self.service.vault.memory_path(desired.memory_id, "knowledge")
|
|
453
515
|
if path.is_symlink():
|
|
454
516
|
raise ModelOutputError("unsafe knowledge path")
|