memleaf 0.2.17__tar.gz → 0.2.18__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.18}/CHANGELOG.md +7 -0
- {memleaf-0.2.17/src/memleaf.egg-info → memleaf-0.2.18}/PKG-INFO +3 -3
- {memleaf-0.2.17 → memleaf-0.2.18}/README.en.md +2 -2
- {memleaf-0.2.17 → memleaf-0.2.18}/README.md +2 -2
- {memleaf-0.2.17 → memleaf-0.2.18}/install.sh +17 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/pyproject.toml +1 -1
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/hermes_provider/__init__.py +42 -6
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/installer.py +15 -0
- memleaf-0.2.18/src/memleaf/native_registration.py +123 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/processing.py +155 -32
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/prompts.py +6 -3
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/validation.py +13 -1
- {memleaf-0.2.17 → memleaf-0.2.18/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf.egg-info/SOURCES.txt +3 -0
- memleaf-0.2.18/tests/test_cross_turn_dedupe_regressions.py +195 -0
- memleaf-0.2.18/tests/test_hermes_native_registration.py +144 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_hermes_provider.py +91 -3
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_maintenance_v2.py +4 -2
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_pypi_install.py +5 -5
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_c3_packaging.py +1 -1
- {memleaf-0.2.17 → memleaf-0.2.18}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/LICENSE +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/MANIFEST.in +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/examples/README.md +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/examples/basic_usage.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/install.ps1 +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/setup.cfg +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/config.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/index.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/models.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/service.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/__init__.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_admission_noise.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_extraction_quality_regressions.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_host_events.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_install.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b1.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b2a.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_todo_state_recovery.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_update_target_recovery.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.18 — 2026-09-02
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- 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.
|
|
9
|
+
- Preserve the existing bounded Scope Map / metadata-directory retrieval flow without adding extra scope scans during automatic deduplication.
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
5
12
|
## 0.2.17 — 2026-09-02
|
|
6
13
|
|
|
7
14
|
- 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.18
|
|
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.18。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.18 通过 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.18.**
|
|
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.18 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.18。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.18 通过 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.18"
|
|
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
|
|
@@ -1181,6 +1181,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1181
1181
|
# under the original session id after the chain is restored.
|
|
1182
1182
|
self._deferred_process_sessions: "OrderedDict[str, None]" = OrderedDict()
|
|
1183
1183
|
self._last_retrieval_observation = "unknown"
|
|
1184
|
+
self._last_retrieval_audit = "SEARCH_UNKNOWN"
|
|
1184
1185
|
|
|
1185
1186
|
@property
|
|
1186
1187
|
def name(self) -> str:
|
|
@@ -1873,6 +1874,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1873
1874
|
self._pending_lineage.clear()
|
|
1874
1875
|
self._deferred_process_sessions.clear()
|
|
1875
1876
|
self._last_retrieval_observation = "unknown"
|
|
1877
|
+
self._last_retrieval_audit = "SEARCH_UNKNOWN"
|
|
1876
1878
|
self._version_warning_emitted = False
|
|
1877
1879
|
if self._client is not None:
|
|
1878
1880
|
self._client.close()
|
|
@@ -2117,6 +2119,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2117
2119
|
turn_id: str = "",
|
|
2118
2120
|
vault_root: Optional[Path] = None,
|
|
2119
2121
|
seen_call_keys: Any = None,
|
|
2122
|
+
audit_state: Optional[dict[str, Any]] = None,
|
|
2120
2123
|
) -> str:
|
|
2121
2124
|
"""Observe explicit host MCP calls in public messages.
|
|
2122
2125
|
|
|
@@ -2152,6 +2155,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2152
2155
|
|
|
2153
2156
|
read_results_used: set[int] = set()
|
|
2154
2157
|
read_sequence = 0
|
|
2158
|
+
controlled_reads = 0
|
|
2155
2159
|
read_ordinal = 0
|
|
2156
2160
|
for call in calls:
|
|
2157
2161
|
if call.get("name") != "mcp__memleaf__read":
|
|
@@ -2175,6 +2179,8 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2175
2179
|
if not _record_tool_observation(seen_call_keys, observation_key):
|
|
2176
2180
|
continue
|
|
2177
2181
|
read_sequence += 1
|
|
2182
|
+
if result_status == "ok" and retrieval_match:
|
|
2183
|
+
controlled_reads += 1
|
|
2178
2184
|
logger.info(
|
|
2179
2185
|
"memleaf retrieval-read source=hermes session=%s turn=%s read_seq=%d retrieval_present=%s retrieval_match=%s result=%s",
|
|
2180
2186
|
_safe_component(session_id, "none") if session_id else "none",
|
|
@@ -2202,12 +2208,39 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2202
2208
|
"detected" if bypass else "not_detected",
|
|
2203
2209
|
)
|
|
2204
2210
|
if not statuses:
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
+
search_status = "unknown"
|
|
2212
|
+
audit_status = "SEARCH_UNKNOWN"
|
|
2213
|
+
elif "found" in statuses:
|
|
2214
|
+
search_status = "found"
|
|
2215
|
+
# Hermes exposes no public pre-final hook and no reliable signal
|
|
2216
|
+
# that proves whether an answer used historical memory when no
|
|
2217
|
+
# controlled read occurred. Do not fabricate FOUND_NOT_USED or
|
|
2218
|
+
# FOUND_REQUIRED_READ_MISSING; record the uncertainty explicitly.
|
|
2219
|
+
audit_status = "FOUND_READ" if controlled_reads else "FOUND_NO_READ_UNDETERMINED"
|
|
2220
|
+
elif "no_match" in statuses:
|
|
2221
|
+
search_status = "no_match"
|
|
2222
|
+
audit_status = "NO_MATCH"
|
|
2223
|
+
else:
|
|
2224
|
+
search_status = "error"
|
|
2225
|
+
audit_status = "ERROR"
|
|
2226
|
+
if isinstance(audit_state, dict):
|
|
2227
|
+
audit_state.clear()
|
|
2228
|
+
audit_state.update(
|
|
2229
|
+
{
|
|
2230
|
+
"status": audit_status,
|
|
2231
|
+
"search_status": search_status,
|
|
2232
|
+
"controlled_reads": controlled_reads,
|
|
2233
|
+
}
|
|
2234
|
+
)
|
|
2235
|
+
logger.info(
|
|
2236
|
+
"memleaf retrieval-audit source=hermes session=%s turn=%s status=%s search=%s controlled_reads=%d",
|
|
2237
|
+
_safe_component(session_id, "none") if session_id else "none",
|
|
2238
|
+
_safe_component(turn_id, "none") if turn_id else "none",
|
|
2239
|
+
audit_status,
|
|
2240
|
+
search_status,
|
|
2241
|
+
controlled_reads,
|
|
2242
|
+
)
|
|
2243
|
+
return search_status
|
|
2211
2244
|
|
|
2212
2245
|
@staticmethod
|
|
2213
2246
|
def _catalog_retrieval_id(value: Any) -> Optional[str]:
|
|
@@ -2335,6 +2368,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2335
2368
|
if retrieval_id is None and resolved_turn_number is None:
|
|
2336
2369
|
retrieval_id = self._current_gate_id(effective_session)
|
|
2337
2370
|
if self._gate_enabled:
|
|
2371
|
+
audit_state: dict[str, Any] = {}
|
|
2338
2372
|
observation = self._observe_search_messages(
|
|
2339
2373
|
messages,
|
|
2340
2374
|
retrieval_id,
|
|
@@ -2342,10 +2376,12 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2342
2376
|
turn_id=turn_id,
|
|
2343
2377
|
vault_root=_resolve_vault(self._config()),
|
|
2344
2378
|
seen_call_keys=self._observed_tool_call_keys,
|
|
2379
|
+
audit_state=audit_state,
|
|
2345
2380
|
)
|
|
2346
2381
|
while len(self._observed_tool_call_keys) > _MAX_OBSERVED_TOOL_CALL_KEYS:
|
|
2347
2382
|
self._observed_tool_call_keys.popitem(last=False)
|
|
2348
2383
|
self._last_retrieval_observation = observation
|
|
2384
|
+
self._last_retrieval_audit = str(audit_state.get("status") or "SEARCH_UNKNOWN")
|
|
2349
2385
|
lineage_ready = self._retry_pending_lineage(effective_session)
|
|
2350
2386
|
for role, content in visible_events:
|
|
2351
2387
|
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"]
|
|
@@ -230,6 +230,80 @@ def _automatic_transient_memory(value: Any) -> bool:
|
|
|
230
230
|
return bool(_DERIVED_OVERDUE_RE.search(value) or _EXECUTION_RECEIPT_RE.search(value))
|
|
231
231
|
|
|
232
232
|
|
|
233
|
+
_CANDIDATE_LOOKUP_SPLIT = re.compile(
|
|
234
|
+
r"[;;。!?!?\n]+|[,,]\s*(?=(?:后续|今后|以后|每次|所有后续|going forward|from now on))"
|
|
235
|
+
r"|(?:同时|另外|此外|其次|并且|并要求|还需|另需|in addition|separately)",
|
|
236
|
+
re.IGNORECASE,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _candidate_lookup_queries(value: Any) -> list[str]:
|
|
241
|
+
"""Return bounded candidate-level lookup variants for cross-turn dedupe."""
|
|
242
|
+
|
|
243
|
+
if not isinstance(value, str) or not value.strip():
|
|
244
|
+
return []
|
|
245
|
+
text = value.strip()
|
|
246
|
+
values = [text]
|
|
247
|
+
values.extend(part.strip() for part in _CANDIDATE_LOOKUP_SPLIT.split(text) if part.strip())
|
|
248
|
+
result: list[str] = []
|
|
249
|
+
seen: set[str] = set()
|
|
250
|
+
for item in values:
|
|
251
|
+
normalized = normalize_term(item)
|
|
252
|
+
if len(normalized) < 4 or normalized in seen:
|
|
253
|
+
continue
|
|
254
|
+
seen.add(normalized)
|
|
255
|
+
result.append(item)
|
|
256
|
+
if len(result) >= 8:
|
|
257
|
+
break
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _automatic_create_conflicts(
|
|
262
|
+
candidate: Mapping[str, Any],
|
|
263
|
+
summary: Mapping[str, Any],
|
|
264
|
+
related: Iterable[Mapping[str, Any]],
|
|
265
|
+
*,
|
|
266
|
+
ignore_memory_ids: Iterable[str] = (),
|
|
267
|
+
) -> bool:
|
|
268
|
+
"""Return true only when a finalized CREATE is already covered."""
|
|
269
|
+
|
|
270
|
+
candidate_text = str(candidate.get("memory", "")).strip()
|
|
271
|
+
queries = _candidate_lookup_queries(candidate_text)
|
|
272
|
+
summary_body = normalize_term(str(summary.get("body", "")))
|
|
273
|
+
candidate_type = candidate.get("type")
|
|
274
|
+
ignored = {
|
|
275
|
+
value.casefold() for value in ignore_memory_ids
|
|
276
|
+
if isinstance(value, str) and value
|
|
277
|
+
}
|
|
278
|
+
for raw in related:
|
|
279
|
+
if not isinstance(raw, Mapping):
|
|
280
|
+
continue
|
|
281
|
+
memory_id = raw.get("memory_id")
|
|
282
|
+
if isinstance(memory_id, str) and memory_id.casefold() in ignored:
|
|
283
|
+
# Requests already generated for this visible turn may have been
|
|
284
|
+
# partially persisted by a failed transaction. Ignore them here
|
|
285
|
+
# so replay can finish the remaining deterministic candidates.
|
|
286
|
+
continue
|
|
287
|
+
related_body = normalize_term(str(raw.get("body", "")))
|
|
288
|
+
if raw.get("native") is True:
|
|
289
|
+
related_text = normalize_term(
|
|
290
|
+
f"{raw.get('title', '')}\n{raw.get('body', '')}"
|
|
291
|
+
)
|
|
292
|
+
for query in queries:
|
|
293
|
+
normalized = normalize_term(query)
|
|
294
|
+
if len(normalized) >= 8 and related_text and (
|
|
295
|
+
normalized in related_text
|
|
296
|
+
or (len(related_text) >= 8 and related_text in normalized)
|
|
297
|
+
):
|
|
298
|
+
return True
|
|
299
|
+
continue
|
|
300
|
+
if raw.get("type") != candidate_type:
|
|
301
|
+
continue
|
|
302
|
+
if summary_body and related_body == summary_body:
|
|
303
|
+
return True
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
|
|
233
307
|
def _todo_state_recovery_candidate(
|
|
234
308
|
events: Iterable[Mapping[str, Any]],
|
|
235
309
|
related: Iterable[Mapping[str, Any]],
|
|
@@ -1450,7 +1524,7 @@ class Processor:
|
|
|
1450
1524
|
self,
|
|
1451
1525
|
turn: InboxTurn,
|
|
1452
1526
|
state: Mapping[str, Any],
|
|
1453
|
-
query: str,
|
|
1527
|
+
query: str | Iterable[str],
|
|
1454
1528
|
explicit_scope: Any = None,
|
|
1455
1529
|
*,
|
|
1456
1530
|
overlay: Iterable[Mapping[str, Any]] = (),
|
|
@@ -1464,7 +1538,15 @@ class Processor:
|
|
|
1464
1538
|
list[dict[str, str]],
|
|
1465
1539
|
Optional[tuple[list[Any], bool]],
|
|
1466
1540
|
]:
|
|
1467
|
-
|
|
1541
|
+
if isinstance(query, str):
|
|
1542
|
+
query_value: str | list[str] = query.strip()
|
|
1543
|
+
else:
|
|
1544
|
+
query_value = [
|
|
1545
|
+
str(item).strip()
|
|
1546
|
+
for item in query
|
|
1547
|
+
if isinstance(item, str) and item.strip()
|
|
1548
|
+
]
|
|
1549
|
+
visible = query_value if isinstance(query_value, str) else " ".join(query_value)
|
|
1468
1550
|
scope = _safe_scope_background(state, explicit_scope)
|
|
1469
1551
|
local: list[dict[str, Any]] = []
|
|
1470
1552
|
indexed_native: list[dict[str, Any]] = []
|
|
@@ -1495,7 +1577,7 @@ class Processor:
|
|
|
1495
1577
|
records = priority_records
|
|
1496
1578
|
else:
|
|
1497
1579
|
records = self.service._search_unlocked(
|
|
1498
|
-
|
|
1580
|
+
query_value,
|
|
1499
1581
|
scope=scope if scope else None,
|
|
1500
1582
|
include_history=False,
|
|
1501
1583
|
todo_status="all",
|
|
@@ -1512,7 +1594,7 @@ class Processor:
|
|
|
1512
1594
|
records = [
|
|
1513
1595
|
record
|
|
1514
1596
|
for record in records
|
|
1515
|
-
if candidate_matches_query(record.memory,
|
|
1597
|
+
if candidate_matches_query(record.memory, query_value)
|
|
1516
1598
|
]
|
|
1517
1599
|
if visible and not records and not priority_records and self._has_specific_scope(scope):
|
|
1518
1600
|
scoped_records, ambiguous = self._scope_records_unlocked(scope)
|
|
@@ -1532,7 +1614,7 @@ class Processor:
|
|
|
1532
1614
|
if visible:
|
|
1533
1615
|
if not priority_only:
|
|
1534
1616
|
indexed_native = NativeIndexer(self.service.vault).search_unlocked(
|
|
1535
|
-
|
|
1617
|
+
query_value,
|
|
1536
1618
|
target_agent=turn.source,
|
|
1537
1619
|
for_context=False,
|
|
1538
1620
|
limit=None,
|
|
@@ -1996,14 +2078,11 @@ class Processor:
|
|
|
1996
2078
|
candidate: Mapping[str, Any],
|
|
1997
2079
|
related: Iterable[Mapping[str, Any]],
|
|
1998
2080
|
) -> dict[str, Any]:
|
|
1999
|
-
"""Reuse one
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
same scoped plan is already active. The active target's type is the
|
|
2005
|
-
canonical type; it is copied before summary validation so the target
|
|
2006
|
-
type invariant remains enforced.
|
|
2081
|
+
"""Reuse one unambiguous same-scope active memory before CREATE.
|
|
2082
|
+
|
|
2083
|
+
General same-type matching prevents a wrong gate target from escaping
|
|
2084
|
+
into a sibling CREATE. Project-plan candidates retain the specialized
|
|
2085
|
+
project-over-fact preference used by earlier maintenance behavior.
|
|
2007
2086
|
"""
|
|
2008
2087
|
|
|
2009
2088
|
result = dict(candidate)
|
|
@@ -2014,14 +2093,52 @@ class Processor:
|
|
|
2014
2093
|
or not isinstance(result.get("memory"), str)
|
|
2015
2094
|
):
|
|
2016
2095
|
return result
|
|
2017
|
-
if result.get("type") not in {"fact", "project"}:
|
|
2018
|
-
return result
|
|
2019
|
-
if not self._is_project_plan_title(result["memory"]):
|
|
2020
|
-
return result
|
|
2021
2096
|
project_keys = self._project_scope_keys(result.get("scopes"))
|
|
2022
2097
|
if len(project_keys) != 1:
|
|
2023
2098
|
return result
|
|
2024
2099
|
|
|
2100
|
+
candidate_type = result.get("type")
|
|
2101
|
+
candidate_text = result["memory"]
|
|
2102
|
+
same_type_matches: list[Memory] = []
|
|
2103
|
+
seen_same_type: set[str] = set()
|
|
2104
|
+
for item in related:
|
|
2105
|
+
if not isinstance(item, Mapping) or item.get("native") is True:
|
|
2106
|
+
continue
|
|
2107
|
+
memory_id = item.get("memory_id")
|
|
2108
|
+
if (
|
|
2109
|
+
not isinstance(memory_id, str)
|
|
2110
|
+
or item.get("type") != candidate_type
|
|
2111
|
+
or self._project_scope_keys(item.get("scopes")) != project_keys
|
|
2112
|
+
or memory_id.casefold() in seen_same_type
|
|
2113
|
+
):
|
|
2114
|
+
continue
|
|
2115
|
+
try:
|
|
2116
|
+
target = Memory.from_mapping(item)
|
|
2117
|
+
except (TypeError, ValueError):
|
|
2118
|
+
continue
|
|
2119
|
+
target_title = normalize_term(target.title)
|
|
2120
|
+
candidate_normalized = normalize_term(candidate_text)
|
|
2121
|
+
if (
|
|
2122
|
+
len(target_title) < 4
|
|
2123
|
+
or target_title not in candidate_normalized
|
|
2124
|
+
or not candidate_matches_query(target, candidate_text)
|
|
2125
|
+
):
|
|
2126
|
+
continue
|
|
2127
|
+
seen_same_type.add(memory_id.casefold())
|
|
2128
|
+
same_type_matches.append(target)
|
|
2129
|
+
|
|
2130
|
+
specialized_plan = (
|
|
2131
|
+
candidate_type in {"fact", "project"}
|
|
2132
|
+
and self._is_project_plan_title(candidate_text)
|
|
2133
|
+
)
|
|
2134
|
+
if not specialized_plan:
|
|
2135
|
+
if len(same_type_matches) > 1:
|
|
2136
|
+
result["_defer_reason"] = "ambiguous_update_target"
|
|
2137
|
+
return result
|
|
2138
|
+
if len(same_type_matches) == 1:
|
|
2139
|
+
result["update_memory_id"] = same_type_matches[0].memory_id
|
|
2140
|
+
return result
|
|
2141
|
+
|
|
2025
2142
|
project_matches: list[Memory] = []
|
|
2026
2143
|
fact_matches: list[Memory] = []
|
|
2027
2144
|
seen: set[str] = set()
|
|
@@ -2051,10 +2168,6 @@ class Processor:
|
|
|
2051
2168
|
else:
|
|
2052
2169
|
fact_matches.append(target)
|
|
2053
2170
|
|
|
2054
|
-
# Prefer a project target over a same-topic fact. If there are
|
|
2055
|
-
# multiple project targets, defer rather than creating a sibling by
|
|
2056
|
-
# guessing; the same conservative rule applies when only multiple
|
|
2057
|
-
# durable fact targets remain.
|
|
2058
2171
|
if len(project_matches) > 1 or (not project_matches and len(fact_matches) > 1):
|
|
2059
2172
|
result["_defer_reason"] = "ambiguous_update_target"
|
|
2060
2173
|
return result
|
|
@@ -2065,8 +2178,6 @@ class Processor:
|
|
|
2065
2178
|
else:
|
|
2066
2179
|
return result
|
|
2067
2180
|
result["update_memory_id"] = target.memory_id
|
|
2068
|
-
# The update target's type is immutable. Correct a model's fact-vs-
|
|
2069
|
-
# project label only after the same-use target was identified.
|
|
2070
2181
|
result["type"] = target.type
|
|
2071
2182
|
return result
|
|
2072
2183
|
|
|
@@ -2530,6 +2641,7 @@ class Processor:
|
|
|
2530
2641
|
)
|
|
2531
2642
|
requests: list[dict[str, Any]] = []
|
|
2532
2643
|
observed_scopes: list[str] = []
|
|
2644
|
+
current_turn_request_ids: set[str] = set()
|
|
2533
2645
|
read_only_query = _automatic_read_only_query(events)
|
|
2534
2646
|
for candidate in gate["candidates"]:
|
|
2535
2647
|
candidate_id_key = str(candidate.get("candidate_id", "")).casefold()
|
|
@@ -2614,7 +2726,7 @@ class Processor:
|
|
|
2614
2726
|
candidate_related, candidate_scope_background, candidate_native_refs, _ = self._related_query(
|
|
2615
2727
|
turn,
|
|
2616
2728
|
state,
|
|
2617
|
-
|
|
2729
|
+
_candidate_lookup_queries(candidate.get("memory")),
|
|
2618
2730
|
candidate_scopes,
|
|
2619
2731
|
overlay=self._planned_related,
|
|
2620
2732
|
priority_memory_ids=[
|
|
@@ -2895,15 +3007,26 @@ class Processor:
|
|
|
2895
3007
|
scope_source=summary.get("scope_source"),
|
|
2896
3008
|
)
|
|
2897
3009
|
continue
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
native_refs=candidate_native_refs,
|
|
2905
|
-
)
|
|
3010
|
+
pending_request = self._request(
|
|
3011
|
+
summary,
|
|
3012
|
+
turn,
|
|
3013
|
+
candidate_id=str(candidate["candidate_id"]),
|
|
3014
|
+
conversation_title=title,
|
|
3015
|
+
native_refs=candidate_native_refs,
|
|
2906
3016
|
)
|
|
3017
|
+
current_turn_request_ids.add(pending_request["memory_id"].casefold())
|
|
3018
|
+
summary_update_target = summary.get("update_memory_id")
|
|
3019
|
+
final_is_create = not (
|
|
3020
|
+
isinstance(summary_update_target, str) and summary_update_target
|
|
3021
|
+
)
|
|
3022
|
+
if final_is_create and _automatic_create_conflicts(
|
|
3023
|
+
candidate,
|
|
3024
|
+
summary,
|
|
3025
|
+
candidate_related,
|
|
3026
|
+
ignore_memory_ids=current_turn_request_ids,
|
|
3027
|
+
):
|
|
3028
|
+
continue
|
|
3029
|
+
requests.append(pending_request)
|
|
2907
3030
|
for observed_scope in summary["scopes"]:
|
|
2908
3031
|
if (
|
|
2909
3032
|
isinstance(observed_scope, str)
|