memleaf 0.2.17__tar.gz → 0.2.19__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.17 → memleaf-0.2.19}/CHANGELOG.md +16 -0
- {memleaf-0.2.17/src/memleaf.egg-info → memleaf-0.2.19}/PKG-INFO +3 -3
- {memleaf-0.2.17 → memleaf-0.2.19}/README.en.md +2 -2
- {memleaf-0.2.17 → memleaf-0.2.19}/README.md +2 -2
- {memleaf-0.2.17 → memleaf-0.2.19}/install.sh +17 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/pyproject.toml +1 -1
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/hermes_provider/__init__.py +101 -46
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/installer.py +15 -0
- memleaf-0.2.19/src/memleaf/native_registration.py +123 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/processing.py +179 -32
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/prompts.py +6 -3
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/validation.py +21 -2
- {memleaf-0.2.17 → memleaf-0.2.19/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf.egg-info/SOURCES.txt +3 -0
- memleaf-0.2.19/tests/test_cross_turn_dedupe_regressions.py +195 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_extraction_quality_regressions.py +142 -1
- memleaf-0.2.19/tests/test_hermes_native_registration.py +144 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_hermes_provider.py +178 -3
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_maintenance_v2.py +4 -2
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_pypi_install.py +5 -5
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_c3_packaging.py +1 -1
- {memleaf-0.2.17 → memleaf-0.2.19}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/LICENSE +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/MANIFEST.in +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/examples/README.md +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/examples/basic_usage.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/install.ps1 +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/setup.cfg +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/config.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/index.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/models.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/service.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/__init__.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_admission_noise.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_host_events.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_install.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_b1.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_b2a.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_todo_state_recovery.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_update_target_recovery.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.19}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.19 — 2026-09-03
|
|
6
|
+
|
|
7
|
+
- Keep Hermes `<untrusted_tool_result>` search audit status synchronized with
|
|
8
|
+
Core for `FOUND` and `NO_MATCH` results using bounded, strict decoding;
|
|
9
|
+
malformed and error envelopes remain audit errors.
|
|
10
|
+
- After three `mixed_future_use` gate responses, defer only the affected
|
|
11
|
+
candidate so valid candidates from the same turn can continue processing;
|
|
12
|
+
mixed content is never persisted and unrelated schema violations still fail.
|
|
13
|
+
|
|
14
|
+
## 0.2.18 — 2026-09-02
|
|
15
|
+
|
|
16
|
+
- Register Hermes native `MEMORY.md` and `USER.md` as private, read-only native sources during supported install/upgrade flows while preserving custom native source configuration.
|
|
17
|
+
- Harden cross-turn deduplication and UPDATE/NO_CHANGE decisions: split independent future-use clauses, reuse one unambiguous same-scope active target, treat exact active duplicates as deterministic no-ops, and keep crash replay idempotent.
|
|
18
|
+
- Preserve the existing bounded Scope Map / metadata-directory retrieval flow without adding extra scope scans during automatic deduplication.
|
|
19
|
+
- Distinguish Hermes controlled read audit states: record `FOUND_READ` only after a current-token successful read, and use `FOUND_NO_READ_UNDETERMINED` when a FOUND result has no provable controlled read instead of overstating Soft Gate guarantees.
|
|
20
|
+
|
|
5
21
|
## 0.2.17 — 2026-09-02
|
|
6
22
|
|
|
7
23
|
- Preserve explicit user-confirmed completion or cancellation of one uniquely
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.19
|
|
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.19。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.19 通过 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.19.**
|
|
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.19 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.19。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.19 通过 PyPI 分发。
|
|
9
9
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
10
10
|
|
|
11
11
|
## 项目定位
|
|
@@ -347,6 +347,23 @@ PY
|
|
|
347
347
|
die "Hermes memleaf MCP configuration/test failed"
|
|
348
348
|
fi
|
|
349
349
|
|
|
350
|
+
# Register Hermes' own MEMORY.md and USER.md as private, read-only native
|
|
351
|
+
# sources only after both provider and MCP checks have succeeded. Missing
|
|
352
|
+
# files remain a valid indexed state and are never created by memleaf.
|
|
353
|
+
"$venv_path/bin/python" - "$vault_path" "$hermes_home" <<'PY'
|
|
354
|
+
from __future__ import annotations
|
|
355
|
+
|
|
356
|
+
import sys
|
|
357
|
+
from pathlib import Path
|
|
358
|
+
|
|
359
|
+
from memleaf.native_registration import ensure_hermes_native_sources
|
|
360
|
+
from memleaf.vault import Vault
|
|
361
|
+
|
|
362
|
+
vault = Vault(Path(sys.argv[1]).expanduser().resolve())
|
|
363
|
+
hermes_home = Path(sys.argv[2]).expanduser().resolve()
|
|
364
|
+
ensure_hermes_native_sources(vault, hermes_home)
|
|
365
|
+
PY
|
|
366
|
+
|
|
350
367
|
# The MCP status is written only after its own official test succeeds. This
|
|
351
368
|
# merge changes no Provider or other host fields.
|
|
352
369
|
"$venv_path/bin/python" - "$vault_path" <<'PY'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Local-first Markdown memory core for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
3
|
+
__version__ = "0.2.19"
|
|
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
|
|
@@ -387,47 +387,66 @@ def _scope_context(
|
|
|
387
387
|
def _hermes_search_status(value: Any) -> str:
|
|
388
388
|
"""Classify an observed MCP search result without retaining its content."""
|
|
389
389
|
|
|
390
|
-
|
|
391
|
-
if isinstance(decoded, list):
|
|
392
|
-
for item in decoded:
|
|
393
|
-
if isinstance(item, Mapping) and item.get("type") == "text":
|
|
394
|
-
decoded = item.get("text")
|
|
395
|
-
break
|
|
396
|
-
if isinstance(decoded, str):
|
|
397
|
-
try:
|
|
398
|
-
decoded = json.loads(decoded)
|
|
399
|
-
except (TypeError, ValueError):
|
|
400
|
-
return "error"
|
|
401
|
-
if not isinstance(decoded, Mapping):
|
|
402
|
-
return "error"
|
|
403
|
-
if decoded.get("isError") is True:
|
|
404
|
-
return "error"
|
|
405
|
-
nested = decoded.get("structuredContent")
|
|
406
|
-
if isinstance(nested, Mapping):
|
|
407
|
-
decoded = nested.get("result") if isinstance(nested.get("result"), Mapping) else nested
|
|
408
|
-
elif isinstance(decoded.get("content"), list):
|
|
409
|
-
return _hermes_search_status(decoded["content"])
|
|
410
|
-
if not isinstance(decoded, Mapping) or "error" in decoded:
|
|
411
|
-
return "error"
|
|
412
|
-
status = decoded.get("status")
|
|
413
|
-
results = decoded.get("results")
|
|
414
|
-
if status not in {"found", "no_match"} or not isinstance(results, list):
|
|
415
|
-
return "error"
|
|
416
|
-
valid_results = all(
|
|
417
|
-
isinstance(item, Mapping)
|
|
418
|
-
and set(item) == {"memory_id", "title"}
|
|
419
|
-
and isinstance(item.get("memory_id"), str)
|
|
420
|
-
and bool(item.get("memory_id"))
|
|
421
|
-
and isinstance(item.get("title"), str)
|
|
422
|
-
and bool(item.get("title"))
|
|
423
|
-
for item in results
|
|
424
|
-
)
|
|
425
|
-
if not valid_results:
|
|
390
|
+
if value is _CALL_FAILED or value is None:
|
|
426
391
|
return "error"
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
392
|
+
decoded: Any = _decode_hermes_tool_result(value)
|
|
393
|
+
for _ in range(_MAX_TOOL_RESULT_LAYERS):
|
|
394
|
+
if decoded is _MISSING_TOOL_RESULT:
|
|
395
|
+
return "error"
|
|
396
|
+
if isinstance(decoded, str):
|
|
397
|
+
decoded = _decode_hermes_tool_result(decoded)
|
|
398
|
+
continue
|
|
399
|
+
if isinstance(decoded, list):
|
|
400
|
+
text_item = next(
|
|
401
|
+
(
|
|
402
|
+
item
|
|
403
|
+
for item in decoded
|
|
404
|
+
if isinstance(item, Mapping) and item.get("type") == "text"
|
|
405
|
+
),
|
|
406
|
+
None,
|
|
407
|
+
)
|
|
408
|
+
if text_item is None:
|
|
409
|
+
return "error"
|
|
410
|
+
decoded = _decode_hermes_tool_result(text_item.get("text"))
|
|
411
|
+
continue
|
|
412
|
+
if not isinstance(decoded, Mapping):
|
|
413
|
+
return "error"
|
|
414
|
+
if decoded.get("isError") is True or decoded.get("error") is not None:
|
|
415
|
+
return "error"
|
|
416
|
+
|
|
417
|
+
status = decoded.get("status")
|
|
418
|
+
results = decoded.get("results")
|
|
419
|
+
if status is not None or results is not None:
|
|
420
|
+
if status not in {"found", "no_match"} or not isinstance(results, list):
|
|
421
|
+
return "error"
|
|
422
|
+
valid_results = all(
|
|
423
|
+
isinstance(item, Mapping)
|
|
424
|
+
and set(item) == {"memory_id", "title"}
|
|
425
|
+
and isinstance(item.get("memory_id"), str)
|
|
426
|
+
and bool(item.get("memory_id"))
|
|
427
|
+
and isinstance(item.get("title"), str)
|
|
428
|
+
and bool(item.get("title"))
|
|
429
|
+
for item in results
|
|
430
|
+
)
|
|
431
|
+
if not valid_results:
|
|
432
|
+
return "error"
|
|
433
|
+
if status == "found" and results:
|
|
434
|
+
return "found"
|
|
435
|
+
if status == "no_match" and not results:
|
|
436
|
+
return "no_match"
|
|
437
|
+
return "error"
|
|
438
|
+
|
|
439
|
+
nested = next(
|
|
440
|
+
(
|
|
441
|
+
decoded[key]
|
|
442
|
+
for key in ("structuredContent", "result", "content")
|
|
443
|
+
if isinstance(decoded.get(key), (Mapping, list, str))
|
|
444
|
+
),
|
|
445
|
+
_MISSING_TOOL_RESULT,
|
|
446
|
+
)
|
|
447
|
+
if nested is _MISSING_TOOL_RESULT:
|
|
448
|
+
return "error"
|
|
449
|
+
decoded = _decode_hermes_tool_result(nested)
|
|
431
450
|
return "error"
|
|
432
451
|
|
|
433
452
|
|
|
@@ -1181,6 +1200,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1181
1200
|
# under the original session id after the chain is restored.
|
|
1182
1201
|
self._deferred_process_sessions: "OrderedDict[str, None]" = OrderedDict()
|
|
1183
1202
|
self._last_retrieval_observation = "unknown"
|
|
1203
|
+
self._last_retrieval_audit = "SEARCH_UNKNOWN"
|
|
1184
1204
|
|
|
1185
1205
|
@property
|
|
1186
1206
|
def name(self) -> str:
|
|
@@ -1873,6 +1893,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1873
1893
|
self._pending_lineage.clear()
|
|
1874
1894
|
self._deferred_process_sessions.clear()
|
|
1875
1895
|
self._last_retrieval_observation = "unknown"
|
|
1896
|
+
self._last_retrieval_audit = "SEARCH_UNKNOWN"
|
|
1876
1897
|
self._version_warning_emitted = False
|
|
1877
1898
|
if self._client is not None:
|
|
1878
1899
|
self._client.close()
|
|
@@ -2117,6 +2138,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2117
2138
|
turn_id: str = "",
|
|
2118
2139
|
vault_root: Optional[Path] = None,
|
|
2119
2140
|
seen_call_keys: Any = None,
|
|
2141
|
+
audit_state: Optional[dict[str, Any]] = None,
|
|
2120
2142
|
) -> str:
|
|
2121
2143
|
"""Observe explicit host MCP calls in public messages.
|
|
2122
2144
|
|
|
@@ -2152,6 +2174,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2152
2174
|
|
|
2153
2175
|
read_results_used: set[int] = set()
|
|
2154
2176
|
read_sequence = 0
|
|
2177
|
+
controlled_reads = 0
|
|
2155
2178
|
read_ordinal = 0
|
|
2156
2179
|
for call in calls:
|
|
2157
2180
|
if call.get("name") != "mcp__memleaf__read":
|
|
@@ -2175,6 +2198,8 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2175
2198
|
if not _record_tool_observation(seen_call_keys, observation_key):
|
|
2176
2199
|
continue
|
|
2177
2200
|
read_sequence += 1
|
|
2201
|
+
if result_status == "ok" and retrieval_match:
|
|
2202
|
+
controlled_reads += 1
|
|
2178
2203
|
logger.info(
|
|
2179
2204
|
"memleaf retrieval-read source=hermes session=%s turn=%s read_seq=%d retrieval_present=%s retrieval_match=%s result=%s",
|
|
2180
2205
|
_safe_component(session_id, "none") if session_id else "none",
|
|
@@ -2202,12 +2227,39 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2202
2227
|
"detected" if bypass else "not_detected",
|
|
2203
2228
|
)
|
|
2204
2229
|
if not statuses:
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2230
|
+
search_status = "unknown"
|
|
2231
|
+
audit_status = "SEARCH_UNKNOWN"
|
|
2232
|
+
elif "found" in statuses:
|
|
2233
|
+
search_status = "found"
|
|
2234
|
+
# Hermes exposes no public pre-final hook and no reliable signal
|
|
2235
|
+
# that proves whether an answer used historical memory when no
|
|
2236
|
+
# controlled read occurred. Do not fabricate FOUND_NOT_USED or
|
|
2237
|
+
# FOUND_REQUIRED_READ_MISSING; record the uncertainty explicitly.
|
|
2238
|
+
audit_status = "FOUND_READ" if controlled_reads else "FOUND_NO_READ_UNDETERMINED"
|
|
2239
|
+
elif "no_match" in statuses:
|
|
2240
|
+
search_status = "no_match"
|
|
2241
|
+
audit_status = "NO_MATCH"
|
|
2242
|
+
else:
|
|
2243
|
+
search_status = "error"
|
|
2244
|
+
audit_status = "ERROR"
|
|
2245
|
+
if isinstance(audit_state, dict):
|
|
2246
|
+
audit_state.clear()
|
|
2247
|
+
audit_state.update(
|
|
2248
|
+
{
|
|
2249
|
+
"status": audit_status,
|
|
2250
|
+
"search_status": search_status,
|
|
2251
|
+
"controlled_reads": controlled_reads,
|
|
2252
|
+
}
|
|
2253
|
+
)
|
|
2254
|
+
logger.info(
|
|
2255
|
+
"memleaf retrieval-audit source=hermes session=%s turn=%s status=%s search=%s controlled_reads=%d",
|
|
2256
|
+
_safe_component(session_id, "none") if session_id else "none",
|
|
2257
|
+
_safe_component(turn_id, "none") if turn_id else "none",
|
|
2258
|
+
audit_status,
|
|
2259
|
+
search_status,
|
|
2260
|
+
controlled_reads,
|
|
2261
|
+
)
|
|
2262
|
+
return search_status
|
|
2211
2263
|
|
|
2212
2264
|
@staticmethod
|
|
2213
2265
|
def _catalog_retrieval_id(value: Any) -> Optional[str]:
|
|
@@ -2335,6 +2387,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2335
2387
|
if retrieval_id is None and resolved_turn_number is None:
|
|
2336
2388
|
retrieval_id = self._current_gate_id(effective_session)
|
|
2337
2389
|
if self._gate_enabled:
|
|
2390
|
+
audit_state: dict[str, Any] = {}
|
|
2338
2391
|
observation = self._observe_search_messages(
|
|
2339
2392
|
messages,
|
|
2340
2393
|
retrieval_id,
|
|
@@ -2342,10 +2395,12 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2342
2395
|
turn_id=turn_id,
|
|
2343
2396
|
vault_root=_resolve_vault(self._config()),
|
|
2344
2397
|
seen_call_keys=self._observed_tool_call_keys,
|
|
2398
|
+
audit_state=audit_state,
|
|
2345
2399
|
)
|
|
2346
2400
|
while len(self._observed_tool_call_keys) > _MAX_OBSERVED_TOOL_CALL_KEYS:
|
|
2347
2401
|
self._observed_tool_call_keys.popitem(last=False)
|
|
2348
2402
|
self._last_retrieval_observation = observation
|
|
2403
|
+
self._last_retrieval_audit = str(audit_state.get("status") or "SEARCH_UNKNOWN")
|
|
2349
2404
|
lineage_ready = self._retry_pending_lineage(effective_session)
|
|
2350
2405
|
for role, content in visible_events:
|
|
2351
2406
|
if not self._capture_visible(
|
|
@@ -22,6 +22,7 @@ from .adapters.codex import CodexAdapter
|
|
|
22
22
|
from .adapters.hermes import HermesAdapter, hermes_home_for_platform
|
|
23
23
|
from .cli import _home_from_environment, _prepare_model_route
|
|
24
24
|
from .locking import atomic_write_json
|
|
25
|
+
from .native_registration import ensure_hermes_native_sources
|
|
25
26
|
from .vault import Vault
|
|
26
27
|
|
|
27
28
|
|
|
@@ -415,6 +416,19 @@ def install_hermes(*, vault_path: Path | None = None) -> dict[str, Any]:
|
|
|
415
416
|
"model": model,
|
|
416
417
|
}
|
|
417
418
|
|
|
419
|
+
try:
|
|
420
|
+
native_registration = ensure_hermes_native_sources(vault, hermes_home)
|
|
421
|
+
except Exception:
|
|
422
|
+
return {
|
|
423
|
+
"status": "failure",
|
|
424
|
+
"reason": "Hermes native memory sources could not be registered safely",
|
|
425
|
+
"core_version": core_version,
|
|
426
|
+
"provider_version": provider_version,
|
|
427
|
+
"vault": str(vault.root),
|
|
428
|
+
"provider": str(provider_path),
|
|
429
|
+
"model": model,
|
|
430
|
+
}
|
|
431
|
+
|
|
418
432
|
update_agents_index(
|
|
419
433
|
vault.agents_index_path,
|
|
420
434
|
{
|
|
@@ -443,6 +457,7 @@ def install_hermes(*, vault_path: Path | None = None) -> dict[str, Any]:
|
|
|
443
457
|
"provider": str(provider_path),
|
|
444
458
|
"mcp_command": str(command),
|
|
445
459
|
"model": model,
|
|
460
|
+
"native_sources": native_registration,
|
|
446
461
|
}
|
|
447
462
|
|
|
448
463
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Host-native source registration helpers.
|
|
2
|
+
|
|
3
|
+
This module owns only configuration of read-only native sources. It never
|
|
4
|
+
creates, writes, renames, or removes a host's native memory files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
from .config import save_config
|
|
14
|
+
from .native_index import NativeIndexer, validate_native_sources
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_HERMES_SOURCES = (
|
|
18
|
+
("hermes_memory", "hermes_memory_builtin", "MEMORY.md"),
|
|
19
|
+
("hermes_user", "hermes_user_builtin", "USER.md"),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _same_path(left: str | Path, right: str | Path) -> bool:
|
|
24
|
+
try:
|
|
25
|
+
return Path(left).expanduser().resolve(strict=False) == Path(right).expanduser().resolve(strict=False)
|
|
26
|
+
except (OSError, RuntimeError, ValueError):
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _desired(path: Path) -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"agent": "hermes",
|
|
33
|
+
"path": str(path.expanduser().resolve(strict=False)),
|
|
34
|
+
"share": False,
|
|
35
|
+
"format": "markdown",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _safe_existing_for_hermes(source: Mapping[str, Any], path: Path) -> bool:
|
|
40
|
+
return (
|
|
41
|
+
source.get("agent") == "hermes"
|
|
42
|
+
and source.get("share") is False
|
|
43
|
+
and source.get("format", "markdown") == "markdown"
|
|
44
|
+
and _same_path(str(source.get("path", "")), path)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ensure_hermes_native_sources(vault: Any, hermes_home: Path | str) -> dict[str, Any]:
|
|
49
|
+
"""Idempotently register Hermes MEMORY.md and USER.md as private sources.
|
|
50
|
+
|
|
51
|
+
Existing user-defined native sources are preserved. A semantic conflict
|
|
52
|
+
fails closed instead of silently changing the user's source or exposing a
|
|
53
|
+
private Hermes file to another agent.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
home = Path(hermes_home).expanduser().resolve(strict=False)
|
|
57
|
+
targets = [(canonical, fallback, home / "memories" / filename) for canonical, fallback, filename in _HERMES_SOURCES]
|
|
58
|
+
|
|
59
|
+
with vault.lock():
|
|
60
|
+
config = vault.config()
|
|
61
|
+
existing_value = config.get("native_sources", {})
|
|
62
|
+
if not isinstance(existing_value, Mapping):
|
|
63
|
+
raise RuntimeError("invalid native_sources configuration")
|
|
64
|
+
sources: dict[str, dict[str, Any]] = {
|
|
65
|
+
str(source_id): deepcopy(dict(source))
|
|
66
|
+
for source_id, source in existing_value.items()
|
|
67
|
+
if isinstance(source_id, str) and isinstance(source, Mapping)
|
|
68
|
+
}
|
|
69
|
+
# Validate before touching anything. This also catches duplicate paths
|
|
70
|
+
# already present in a manually edited configuration.
|
|
71
|
+
validate_native_sources(sources, base_dir=vault.root)
|
|
72
|
+
changed = False
|
|
73
|
+
registered: dict[str, str] = {}
|
|
74
|
+
|
|
75
|
+
for canonical, fallback, path in targets:
|
|
76
|
+
desired = _desired(path)
|
|
77
|
+
path_owner = next(
|
|
78
|
+
(
|
|
79
|
+
source_id
|
|
80
|
+
for source_id, source in sources.items()
|
|
81
|
+
if _same_path(str(source.get("path", "")), path)
|
|
82
|
+
),
|
|
83
|
+
None,
|
|
84
|
+
)
|
|
85
|
+
if path_owner is not None:
|
|
86
|
+
current = sources[path_owner]
|
|
87
|
+
if not _safe_existing_for_hermes(current, path):
|
|
88
|
+
raise RuntimeError(f"unsafe Hermes native source conflict: {path_owner}")
|
|
89
|
+
registered[canonical] = path_owner
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
target_id = canonical
|
|
93
|
+
if target_id in sources:
|
|
94
|
+
if _safe_existing_for_hermes(sources[target_id], path):
|
|
95
|
+
registered[canonical] = target_id
|
|
96
|
+
continue
|
|
97
|
+
target_id = fallback
|
|
98
|
+
if target_id in sources and not _safe_existing_for_hermes(sources[target_id], path):
|
|
99
|
+
raise RuntimeError(f"Hermes native source id conflict: {canonical}")
|
|
100
|
+
|
|
101
|
+
if target_id not in sources:
|
|
102
|
+
sources[target_id] = desired
|
|
103
|
+
changed = True
|
|
104
|
+
registered[canonical] = target_id
|
|
105
|
+
|
|
106
|
+
if changed:
|
|
107
|
+
updated = deepcopy(dict(config))
|
|
108
|
+
updated["native_sources"] = sources
|
|
109
|
+
save_config(vault.config_path, updated)
|
|
110
|
+
|
|
111
|
+
# Refresh the derived index under the same vault lock. Missing native
|
|
112
|
+
# files are a valid state and are represented as unavailable entries;
|
|
113
|
+
# this function never creates them.
|
|
114
|
+
index_summary = NativeIndexer(vault).refresh_unlocked()
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"changed": changed,
|
|
118
|
+
"sources": registered,
|
|
119
|
+
"index": index_summary,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
__all__ = ["ensure_hermes_native_sources"]
|