memleaf 0.2.0__tar.gz → 0.2.1__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.0 → memleaf-0.2.1}/CHANGELOG.md +11 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/PKG-INFO +3 -3
- {memleaf-0.2.0 → memleaf-0.2.1}/README.en.md +2 -2
- {memleaf-0.2.0 → memleaf-0.2.1}/README.md +2 -2
- {memleaf-0.2.0 → memleaf-0.2.1}/pyproject.toml +1 -1
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/hermes_provider/__init__.py +1 -3
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/host_events.py +1 -3
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/installer.py +22 -4
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/mcp_server.py +12 -5
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/model_discovery.py +10 -96
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/retrieval_gate.py +3 -2
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/service.py +24 -6
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf.egg-info/PKG-INFO +3 -3
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_codex_install.py +20 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_hermes_provider.py +9 -3
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_model_discovery.py +27 -42
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_pypi_install.py +11 -2
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_a.py +54 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_c3_packaging.py +1 -1
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_v2_host_flow.py +53 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_v2_mcp_flow.py +37 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/LICENSE +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/MANIFEST.in +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/examples/README.md +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/examples/basic_usage.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/install.ps1 +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/install.sh +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/setup.cfg +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/config.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/index.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/models.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/processing.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/prompts.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/validation.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf.egg-info/SOURCES.txt +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/__init__.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_admission_noise.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_host_events.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_install.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_maintenance_v2.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b1.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b2a.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.1 — 2026-08-31
|
|
6
|
+
|
|
7
|
+
- Fixed the finalized `search → read` contract in both supported hosts: Codex `PostToolUse` and Hermes diagnostics now accept only `memory_id + title` search candidates, so a real `found` result no longer degrades to an error before `read`.
|
|
8
|
+
- Enforced current-turn `retrieval_id` validation at the MCP boundary for both Codex and Hermes, preventing a still-present historical token from being reused for managed search/read after a newer turn begins.
|
|
9
|
+
- Made Codex model discovery fail closed. Codex remains a host only; memleaf no longer reads or reuses Codex provider/model/auth configuration even if legacy discovery APIs are called explicitly.
|
|
10
|
+
- Made multi-host Vault reuse compare physical paths rather than raw path strings, reducing false conflicts from equivalent host paths on Windows/macOS while still rejecting genuinely different Vaults.
|
|
11
|
+
- Hardened forget failure recovery: linked history is deleted before active knowledge, partial filesystem failures rebuild derived indexes, and the active memory remains a retry anchor until linked history deletion succeeds.
|
|
12
|
+
- Standardized Hermes installer subprocess decoding on UTF-8, matching the provider/Codex host boundaries and preventing non-ASCII CLI output from being corrupted by a Windows default code page.
|
|
13
|
+
- Removed dead Codex-discovery helpers after the host/model boundary was made fail closed.
|
|
14
|
+
- Re-ran the full Linux 3.11/3.12/3.13, Windows 3.11/3.12/3.13, macOS 3.11/3.13, native Codex Windows/macOS, package-build, wheel-install, and sdist-test release gates.
|
|
15
|
+
|
|
5
16
|
## 0.2.0 — 2026-08-31
|
|
6
17
|
|
|
7
18
|
- Hardened the supported Codex integration against the official Codex CLI 0.151.0 on Windows and macOS, including `CODEX_HOME`, npm `codex.cmd` discovery, UTF-8 CLI output, idempotent `mcp add/get`, and quote-free Windows lifecycle commands carried through a UTF-16LE PowerShell `EncodedCommand` payload.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
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.1。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.1 通过 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.1.**
|
|
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.1 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.1。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.1 通过 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.1"
|
|
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
|
|
@@ -404,13 +404,11 @@ def _hermes_search_status(value: Any) -> str:
|
|
|
404
404
|
return "error"
|
|
405
405
|
valid_results = all(
|
|
406
406
|
isinstance(item, Mapping)
|
|
407
|
+
and set(item) == {"memory_id", "title"}
|
|
407
408
|
and isinstance(item.get("memory_id"), str)
|
|
408
409
|
and bool(item.get("memory_id"))
|
|
409
410
|
and isinstance(item.get("title"), str)
|
|
410
411
|
and bool(item.get("title"))
|
|
411
|
-
and isinstance(item.get("scopes"), list)
|
|
412
|
-
and bool(item.get("scopes"))
|
|
413
|
-
and all(isinstance(scope, str) and bool(scope) for scope in item["scopes"])
|
|
414
412
|
for item in results
|
|
415
413
|
)
|
|
416
414
|
if not valid_results:
|
|
@@ -370,13 +370,11 @@ def _codex_search_status(value: Any) -> str:
|
|
|
370
370
|
return "error"
|
|
371
371
|
if not all(
|
|
372
372
|
isinstance(item, Mapping)
|
|
373
|
+
and set(item) == {"memory_id", "title"}
|
|
373
374
|
and isinstance(item.get("memory_id"), str)
|
|
374
375
|
and bool(item.get("memory_id"))
|
|
375
376
|
and isinstance(item.get("title"), str)
|
|
376
377
|
and bool(item.get("title"))
|
|
377
|
-
and isinstance(item.get("scopes"), list)
|
|
378
|
-
and bool(item.get("scopes"))
|
|
379
|
-
and all(isinstance(scope, str) and bool(scope) for scope in item["scopes"])
|
|
380
378
|
for item in results
|
|
381
379
|
):
|
|
382
380
|
return "error"
|
|
@@ -106,6 +106,22 @@ def _select_vault_path(
|
|
|
106
106
|
return (home / ".memleaf").resolve(), "default"
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
def _vault_paths_equivalent(left: Path, right: Path) -> bool:
|
|
110
|
+
"""Return whether two host Vault paths identify the same location."""
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
if left.exists() and right.exists():
|
|
114
|
+
return os.path.samefile(left, right)
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
try:
|
|
118
|
+
left_value = os.path.normcase(str(left.expanduser().resolve()))
|
|
119
|
+
right_value = os.path.normcase(str(right.expanduser().resolve()))
|
|
120
|
+
except (OSError, RuntimeError):
|
|
121
|
+
return False
|
|
122
|
+
return left_value == right_value
|
|
123
|
+
|
|
124
|
+
|
|
109
125
|
def _select_codex_vault_path(
|
|
110
126
|
*,
|
|
111
127
|
home: Path,
|
|
@@ -130,12 +146,12 @@ def _select_codex_vault_path(
|
|
|
130
146
|
codex_vault = adapter.configured_vault(detection)
|
|
131
147
|
if codex_vault is not None:
|
|
132
148
|
existing.append(("codex_config", codex_vault))
|
|
133
|
-
unique = {str(path): path for _, path in existing}
|
|
134
|
-
if len(unique) > 1:
|
|
135
|
-
raise RuntimeError("vault_conflict: existing memleaf hosts use different Vaults")
|
|
136
149
|
if existing:
|
|
150
|
+
reference = existing[0][1]
|
|
151
|
+
if any(not _vault_paths_equivalent(reference, path) for _, path in existing[1:]):
|
|
152
|
+
raise RuntimeError("vault_conflict: existing memleaf hosts use different Vaults")
|
|
137
153
|
sources = "+".join(source for source, _ in existing)
|
|
138
|
-
return
|
|
154
|
+
return reference, sources
|
|
139
155
|
|
|
140
156
|
environment = os.environ if env is None else env
|
|
141
157
|
configured = environment.get("MEMLEAF_VAULT")
|
|
@@ -252,6 +268,8 @@ def _run(command: list[str]) -> subprocess.CompletedProcess[str]:
|
|
|
252
268
|
env=os.environ.copy(),
|
|
253
269
|
capture_output=True,
|
|
254
270
|
text=True,
|
|
271
|
+
encoding="utf-8",
|
|
272
|
+
errors="strict",
|
|
255
273
|
check=False,
|
|
256
274
|
)
|
|
257
275
|
|
|
@@ -668,14 +668,19 @@ def _managed_search_state(
|
|
|
668
668
|
service: Memleaf,
|
|
669
669
|
retrieval_id: str | None,
|
|
670
670
|
) -> dict[str, Any] | None:
|
|
671
|
-
"""
|
|
671
|
+
"""Validate the current host turn; Hermes additionally records MCP results."""
|
|
672
672
|
|
|
673
673
|
if retrieval_id is None:
|
|
674
674
|
return None
|
|
675
675
|
state = validate_turn(service.vault, retrieval_id)
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
676
|
+
source = state.get("source")
|
|
677
|
+
if not isinstance(source, str) or not source:
|
|
678
|
+
raise RetrievalGateError("retrieval_identity_invalid")
|
|
679
|
+
current = validate_current_turn(service.vault, retrieval_id, source)
|
|
680
|
+
# Codex records real search outcomes from PostToolUse so the stdio server
|
|
681
|
+
# must not double-count them. Current-turn validation is authoritative for
|
|
682
|
+
# every host token before the search itself is allowed to run.
|
|
683
|
+
return current if source == "hermes" else None
|
|
679
684
|
|
|
680
685
|
|
|
681
686
|
def _observe_mcp_search(
|
|
@@ -785,7 +790,9 @@ def _invoke_tool(
|
|
|
785
790
|
return _read_page_result(service.read_page(**page_args))
|
|
786
791
|
|
|
787
792
|
state = validate_turn(service.vault, retrieval_id)
|
|
788
|
-
current_source =
|
|
793
|
+
current_source = state.get("source")
|
|
794
|
+
if not isinstance(current_source, str) or not current_source:
|
|
795
|
+
raise RetrievalGateError("retrieval_identity_invalid")
|
|
789
796
|
value = guarded_read(
|
|
790
797
|
service.vault,
|
|
791
798
|
retrieval_id,
|
|
@@ -19,7 +19,6 @@ import os
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
import re
|
|
21
21
|
import shutil
|
|
22
|
-
import tomllib
|
|
23
22
|
from typing import Any, Callable, Iterable, Mapping, Sequence
|
|
24
23
|
from urllib.parse import urlsplit
|
|
25
24
|
|
|
@@ -160,18 +159,6 @@ def _safe_text(value: Any) -> str | None:
|
|
|
160
159
|
return None
|
|
161
160
|
|
|
162
161
|
|
|
163
|
-
def _configured_directory(home: Path, value: Any, default_name: str) -> Path:
|
|
164
|
-
configured = _safe_text(value)
|
|
165
|
-
if configured is None:
|
|
166
|
-
return (home / default_name).resolve()
|
|
167
|
-
if configured == "~":
|
|
168
|
-
return home.resolve()
|
|
169
|
-
if configured.startswith("~/"):
|
|
170
|
-
return (home / configured[2:]).resolve()
|
|
171
|
-
path = Path(configured)
|
|
172
|
-
return (path if path.is_absolute() else home / path).resolve()
|
|
173
|
-
|
|
174
|
-
|
|
175
162
|
def _valid_base_url(value: Any) -> str | None:
|
|
176
163
|
text = _safe_text(value)
|
|
177
164
|
if text is None:
|
|
@@ -568,96 +555,23 @@ def discover_hermes(
|
|
|
568
555
|
return candidates, diagnostics
|
|
569
556
|
|
|
570
557
|
|
|
571
|
-
def _codex_env_values(document: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
572
|
-
policy = document.get("shell_environment_policy")
|
|
573
|
-
if not isinstance(policy, Mapping):
|
|
574
|
-
return {}
|
|
575
|
-
values = policy.get("set")
|
|
576
|
-
return values if isinstance(values, Mapping) else {}
|
|
577
|
-
|
|
578
|
-
|
|
579
558
|
def discover_codex(
|
|
580
559
|
*,
|
|
581
560
|
home: Path | str | None = None,
|
|
582
561
|
env: Mapping[str, str] | None = None,
|
|
583
562
|
config_path: Path | str | None = None,
|
|
584
563
|
) -> tuple[list[ModelCandidate], list[str]]:
|
|
585
|
-
"""
|
|
564
|
+
"""Keep the legacy API fail-closed without reading Codex model settings.
|
|
586
565
|
|
|
587
|
-
Codex
|
|
588
|
-
|
|
589
|
-
|
|
566
|
+
Codex is a memleaf host, never an extraction-model source. Retaining this
|
|
567
|
+
callable avoids breaking imports from early 0.x builds while ensuring an
|
|
568
|
+
accidental caller cannot read or reuse Codex provider/auth configuration.
|
|
590
569
|
"""
|
|
591
570
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
else:
|
|
597
|
-
codex_home = _configured_directory(effective_home, environment.get("CODEX_HOME"), ".codex")
|
|
598
|
-
path = codex_home / "config.toml"
|
|
599
|
-
if path.is_symlink() or not path.is_file():
|
|
600
|
-
return [], ["codex: user config.toml was not found"]
|
|
601
|
-
try:
|
|
602
|
-
document = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
603
|
-
except (OSError, UnicodeError, tomllib.TOMLDecodeError):
|
|
604
|
-
return [], ["codex: config.toml could not be parsed"]
|
|
605
|
-
providers = document.get("model_providers")
|
|
606
|
-
if not isinstance(providers, Mapping):
|
|
607
|
-
return [], ["codex: no reusable model provider configuration found"]
|
|
608
|
-
selected_name = _safe_text(document.get("model_provider"))
|
|
609
|
-
top_model = _safe_text(document.get("model"))
|
|
610
|
-
shell_values = _codex_env_values(document)
|
|
611
|
-
candidates: list[ModelCandidate] = []
|
|
612
|
-
diagnostics: list[str] = []
|
|
613
|
-
for name, raw_item in providers.items():
|
|
614
|
-
if not isinstance(name, str) or not isinstance(raw_item, Mapping):
|
|
615
|
-
continue
|
|
616
|
-
wire_api = (_safe_text(raw_item.get("wire_api")) or "").casefold().replace("-", "_")
|
|
617
|
-
if wire_api != "chat_completions":
|
|
618
|
-
if wire_api == "responses":
|
|
619
|
-
diagnostics.append(f"codex: provider {name} uses unsupported responses wire API")
|
|
620
|
-
else:
|
|
621
|
-
diagnostics.append(f"codex: provider {name} has no supported chat wire API")
|
|
622
|
-
continue
|
|
623
|
-
provider = _safe_text(raw_item.get("name")) or name
|
|
624
|
-
model_ids: list[str] = []
|
|
625
|
-
for key in ("model", "default_model"):
|
|
626
|
-
value = _safe_text(raw_item.get(key))
|
|
627
|
-
if value:
|
|
628
|
-
model_ids.append(value)
|
|
629
|
-
if selected_name == name and top_model:
|
|
630
|
-
model_ids.append(top_model)
|
|
631
|
-
if isinstance(raw_item.get("models"), list):
|
|
632
|
-
model_ids.extend(item for item in raw_item["models"] if isinstance(item, str) and item.strip())
|
|
633
|
-
if not model_ids:
|
|
634
|
-
diagnostics.append(f"codex: provider {name} has no model name")
|
|
635
|
-
continue
|
|
636
|
-
seen: set[str] = set()
|
|
637
|
-
for model_id in model_ids:
|
|
638
|
-
if model_id.casefold() in seen:
|
|
639
|
-
continue
|
|
640
|
-
seen.add(model_id.casefold())
|
|
641
|
-
item = dict(raw_item)
|
|
642
|
-
found, reason = _candidate(
|
|
643
|
-
source="codex",
|
|
644
|
-
provider=provider,
|
|
645
|
-
protocol="openai",
|
|
646
|
-
base_url=raw_item.get("base_url"),
|
|
647
|
-
model=model_id,
|
|
648
|
-
item=item,
|
|
649
|
-
env=environment,
|
|
650
|
-
extra_env=shell_values,
|
|
651
|
-
source_detail="selected provider" if selected_name == name else "model provider",
|
|
652
|
-
)
|
|
653
|
-
if found:
|
|
654
|
-
candidates.append(found)
|
|
655
|
-
elif reason != "non-chat model":
|
|
656
|
-
diagnostics.append(f"codex: provider {name} unavailable ({reason})")
|
|
657
|
-
if top_model and not selected_name:
|
|
658
|
-
diagnostics.append("codex: top-level model has no selected reusable provider")
|
|
659
|
-
return candidates, diagnostics
|
|
660
|
-
|
|
571
|
+
del home, env, config_path
|
|
572
|
+
return [], [
|
|
573
|
+
"codex: model discovery is disabled; configure an independent memleaf Model Route"
|
|
574
|
+
]
|
|
661
575
|
|
|
662
576
|
def discover_models(
|
|
663
577
|
*,
|
|
@@ -682,8 +596,8 @@ def discover_models(
|
|
|
682
596
|
candidates.extend(found)
|
|
683
597
|
diagnostics.extend(messages)
|
|
684
598
|
if include_codex:
|
|
685
|
-
|
|
686
|
-
|
|
599
|
+
# Compatibility flag only. Codex is a host, not a model source.
|
|
600
|
+
_, messages = discover_codex(home=home, env=env)
|
|
687
601
|
diagnostics.extend(messages)
|
|
688
602
|
selected = select_lightest(candidates)
|
|
689
603
|
if selected is None:
|
|
@@ -388,8 +388,9 @@ def validate_current_turn(
|
|
|
388
388
|
) -> dict[str, Any]:
|
|
389
389
|
"""Validate a token belongs to the newest live turn for its host session.
|
|
390
390
|
|
|
391
|
-
|
|
392
|
-
remains available for
|
|
391
|
+
MCP uses this for every host token before search/read. Ordinary
|
|
392
|
+
``validate_turn`` remains available for Stop continuation and historical
|
|
393
|
+
inspection where "current turn" is not the question.
|
|
393
394
|
"""
|
|
394
395
|
|
|
395
396
|
retrieval_id = _retrieval_id(retrieval_id)
|
|
@@ -1348,7 +1348,9 @@ class Memleaf:
|
|
|
1348
1348
|
return fit_directory_items(entries, limit=bounded_limit)
|
|
1349
1349
|
|
|
1350
1350
|
def _delete_records_unlocked(self, records: Iterable[_Record]) -> list[str]:
|
|
1351
|
-
|
|
1351
|
+
# Preflight every target before mutating anything, then keep active
|
|
1352
|
+
# knowledge as the retry anchor until all linked history is gone.
|
|
1353
|
+
unique: list[_Record] = []
|
|
1352
1354
|
seen_paths: set[Path] = set()
|
|
1353
1355
|
for record in records:
|
|
1354
1356
|
if record.path in seen_paths:
|
|
@@ -1356,11 +1358,27 @@ class Memleaf:
|
|
|
1356
1358
|
seen_paths.add(record.path)
|
|
1357
1359
|
if record.path.is_symlink():
|
|
1358
1360
|
raise ValueError("unsafe memory path")
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1361
|
+
unique.append(record)
|
|
1362
|
+
unique.sort(key=lambda record: (record.area == "knowledge", str(record.path)))
|
|
1363
|
+
|
|
1364
|
+
deleted: list[str] = []
|
|
1365
|
+
try:
|
|
1366
|
+
for record in unique:
|
|
1367
|
+
try:
|
|
1368
|
+
record.path.unlink()
|
|
1369
|
+
except FileNotFoundError:
|
|
1370
|
+
continue
|
|
1371
|
+
deleted.append(record.memory.memory_id)
|
|
1372
|
+
except Exception:
|
|
1373
|
+
if deleted:
|
|
1374
|
+
# The Markdown files are source of truth. Keep derived indexes
|
|
1375
|
+
# synchronized after a partial filesystem mutation before the
|
|
1376
|
+
# original deletion error is surfaced to the caller.
|
|
1377
|
+
try:
|
|
1378
|
+
self._rebuild_index_unlocked()
|
|
1379
|
+
except Exception:
|
|
1380
|
+
pass
|
|
1381
|
+
raise
|
|
1364
1382
|
if deleted:
|
|
1365
1383
|
self._rebuild_index_unlocked()
|
|
1366
1384
|
return sorted(set(deleted))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
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.1。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.1 通过 PyPI 分发。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
30
30
|
## 项目定位
|
|
@@ -7,6 +7,7 @@ import stat
|
|
|
7
7
|
import subprocess
|
|
8
8
|
import tempfile
|
|
9
9
|
import unittest
|
|
10
|
+
from unittest import mock
|
|
10
11
|
|
|
11
12
|
from memleaf.adapters.base import CommandResult, host_event_command, mcp_command, merge_hook_config
|
|
12
13
|
from memleaf.adapters.codex import CodexAdapter, _codex_hook_definition, _entry_matches
|
|
@@ -305,6 +306,25 @@ class CodexInstallTests(unittest.TestCase):
|
|
|
305
306
|
self.assertEqual(hermes_vault.resolve(), selected)
|
|
306
307
|
self.assertEqual("hermes_config+codex_config", source)
|
|
307
308
|
|
|
309
|
+
alias = self.root / "same-physical-alias"
|
|
310
|
+
with mock.patch(
|
|
311
|
+
"memleaf.installer._existing_hermes_vault",
|
|
312
|
+
return_value=hermes_vault.resolve(),
|
|
313
|
+
), mock.patch(
|
|
314
|
+
"memleaf.installer._vault_paths_equivalent",
|
|
315
|
+
return_value=True,
|
|
316
|
+
) as equivalent:
|
|
317
|
+
selected, source = _select_codex_vault_path(
|
|
318
|
+
home=self.home,
|
|
319
|
+
hermes_home=hermes_home,
|
|
320
|
+
adapter=ExistingVaultAdapter(alias),
|
|
321
|
+
detection=object(),
|
|
322
|
+
env={},
|
|
323
|
+
)
|
|
324
|
+
self.assertEqual(hermes_vault.resolve(), selected)
|
|
325
|
+
self.assertEqual("hermes_config+codex_config", source)
|
|
326
|
+
equivalent.assert_called_once()
|
|
327
|
+
|
|
308
328
|
with self.assertRaisesRegex(RuntimeError, "vault_conflict"):
|
|
309
329
|
_select_codex_vault_path(
|
|
310
330
|
home=self.home,
|
|
@@ -510,7 +510,7 @@ class HermesProviderTests(unittest.TestCase):
|
|
|
510
510
|
{
|
|
511
511
|
"status": "found",
|
|
512
512
|
"results": [
|
|
513
|
-
{"memory_id": "mem-alpha", "title": "Alpha"
|
|
513
|
+
{"memory_id": "mem-alpha", "title": "Alpha"}
|
|
514
514
|
],
|
|
515
515
|
}
|
|
516
516
|
),
|
|
@@ -1201,11 +1201,17 @@ class HermesProviderTests(unittest.TestCase):
|
|
|
1201
1201
|
)
|
|
1202
1202
|
|
|
1203
1203
|
def test_search_status_rejects_malformed_v2_envelopes(self) -> None:
|
|
1204
|
-
valid = {"memory_id": "mem-1", "title": "Memory"
|
|
1204
|
+
valid = {"memory_id": "mem-1", "title": "Memory"}
|
|
1205
1205
|
cases = [
|
|
1206
1206
|
({"status": "found", "results": []}, "error"),
|
|
1207
1207
|
({"status": "no_match", "results": [valid]}, "error"),
|
|
1208
|
-
(
|
|
1208
|
+
(
|
|
1209
|
+
{
|
|
1210
|
+
"status": "found",
|
|
1211
|
+
"results": [dict(valid, scopes=["global"])],
|
|
1212
|
+
},
|
|
1213
|
+
"error",
|
|
1214
|
+
),
|
|
1209
1215
|
({"status": "found", "results": [valid], "error": {"code": "failed"}}, "error"),
|
|
1210
1216
|
({"status": "found", "results": [valid]}, "found"),
|
|
1211
1217
|
({"status": "no_match", "results": []}, "no_match"),
|
|
@@ -197,54 +197,39 @@ class ModelDiscoveryTests(unittest.TestCase):
|
|
|
197
197
|
router = ModelRouter.from_config(load_config(path, vault=root))
|
|
198
198
|
self.assertIsNone(router.api)
|
|
199
199
|
|
|
200
|
-
def
|
|
201
|
-
with tempfile.TemporaryDirectory() as temporary:
|
|
200
|
+
def test_codex_model_discovery_is_fail_closed_and_does_not_read_config(self):
|
|
201
|
+
with tempfile.TemporaryDirectory(prefix="memleaf-codex-model-disabled-") as temporary:
|
|
202
202
|
home = Path(temporary)
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
codex_home = home / ".codex"
|
|
204
|
+
codex_home.mkdir()
|
|
205
|
+
config = codex_home / "config.toml"
|
|
205
206
|
config.write_text(
|
|
206
|
-
'model = "
|
|
207
|
-
'model_provider = "chat"\n'
|
|
208
|
-
'[model_providers.responses]\n'
|
|
209
|
-
'base_url = "https://responses.example.test"\n'
|
|
210
|
-
'wire_api = "responses"\n'
|
|
211
|
-
'model = "codex-mini"\n'
|
|
212
|
-
'experimental_bearer_token = "responses-secret"\n'
|
|
213
|
-
'[model_providers.chat]\n'
|
|
214
|
-
'base_url = "https://chat.example.test/v1"\n'
|
|
215
|
-
'wire_api = "chat_completions"\n'
|
|
216
|
-
'model = "codex-mini"\n'
|
|
217
|
-
'env_key = "CODEX_CHAT_KEY"\n',
|
|
207
|
+
'model = "sensitive-model"\nmodel_provider = "private"\n',
|
|
218
208
|
encoding="utf-8",
|
|
219
209
|
)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
210
|
+
with patch.object(Path, "read_text", side_effect=AssertionError("must not read Codex config")):
|
|
211
|
+
candidates, diagnostics = discover_codex(
|
|
212
|
+
home=home,
|
|
213
|
+
env={"CODEX_HOME": str(codex_home), "PRIVATE_KEY": "SECRET"},
|
|
214
|
+
)
|
|
215
|
+
self.assertEqual([], candidates)
|
|
216
|
+
self.assertTrue(any("disabled" in item for item in diagnostics))
|
|
217
|
+
self.assertNotIn("SECRET", repr(diagnostics))
|
|
226
218
|
|
|
227
|
-
def
|
|
228
|
-
with
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
'wire_api = "chat_completions"\n'
|
|
238
|
-
'model = "custom-mini"\n'
|
|
239
|
-
'experimental_bearer_token = "custom-secret"\n',
|
|
240
|
-
encoding="utf-8",
|
|
241
|
-
)
|
|
242
|
-
candidates, diagnostics = discover_codex(
|
|
243
|
-
home=home,
|
|
244
|
-
env={"CODEX_HOME": str(codex_home)},
|
|
219
|
+
def test_include_codex_flag_is_compatibility_noop(self):
|
|
220
|
+
with patch(
|
|
221
|
+
"memleaf.model_discovery.discover_codex",
|
|
222
|
+
return_value=([], ["codex: model discovery is disabled"]),
|
|
223
|
+
) as codex:
|
|
224
|
+
result = discover_models(
|
|
225
|
+
home=Path("/tmp/isolated-home"),
|
|
226
|
+
env={},
|
|
227
|
+
include_hermes=False,
|
|
228
|
+
include_codex=True,
|
|
245
229
|
)
|
|
246
|
-
|
|
247
|
-
|
|
230
|
+
codex.assert_called_once()
|
|
231
|
+
self.assertIsNone(result.selected)
|
|
232
|
+
self.assertTrue(any("disabled" in item for item in result.diagnostics))
|
|
248
233
|
|
|
249
234
|
def test_direct_key_is_written_with_restricted_permissions_and_router_uses_it(self):
|
|
250
235
|
with tempfile.TemporaryDirectory() as temporary:
|
|
@@ -9,7 +9,7 @@ from unittest import mock
|
|
|
9
9
|
|
|
10
10
|
from memleaf import cli
|
|
11
11
|
from memleaf.adapters.hermes import HermesAdapter
|
|
12
|
-
from memleaf.installer import _copy_provider, _hermes_home, _write_provider_config, install_codex
|
|
12
|
+
from memleaf.installer import _copy_provider, _hermes_home, _run, _write_provider_config, install_codex
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class PyPIInstallTests(unittest.TestCase):
|
|
@@ -39,7 +39,7 @@ class PyPIInstallTests(unittest.TestCase):
|
|
|
39
39
|
|
|
40
40
|
self.assertTrue(target.is_dir())
|
|
41
41
|
self.assertFalse(target.is_symlink())
|
|
42
|
-
self.assertIn("version: 0.2.
|
|
42
|
+
self.assertIn("version: 0.2.1", (target / "plugin.yaml").read_text(encoding="utf-8"))
|
|
43
43
|
|
|
44
44
|
def test_windows_hermes_paths_follow_official_native_layout(self) -> None:
|
|
45
45
|
with tempfile.TemporaryDirectory(prefix="memleaf-win-paths-") as temporary:
|
|
@@ -83,6 +83,15 @@ class PyPIInstallTests(unittest.TestCase):
|
|
|
83
83
|
self.assertEqual("high", detection.confidence)
|
|
84
84
|
self.assertEqual(str(launcher.resolve()), detection.executable)
|
|
85
85
|
|
|
86
|
+
def test_installer_host_cli_output_is_decoded_as_utf8(self) -> None:
|
|
87
|
+
completed = SimpleNamespace(returncode=0, stdout="中文", stderr="")
|
|
88
|
+
with mock.patch("memleaf.installer.subprocess.run", return_value=completed) as run:
|
|
89
|
+
result = _run(["hermes", "memory", "status"])
|
|
90
|
+
self.assertIs(result, completed)
|
|
91
|
+
self.assertEqual(run.call_args.kwargs["encoding"], "utf-8")
|
|
92
|
+
self.assertEqual(run.call_args.kwargs["errors"], "strict")
|
|
93
|
+
self.assertTrue(run.call_args.kwargs["text"])
|
|
94
|
+
|
|
86
95
|
def test_provider_config_uses_absolute_vault_on_all_platforms(self) -> None:
|
|
87
96
|
with tempfile.TemporaryDirectory(prefix="memleaf-provider-config-") as temporary:
|
|
88
97
|
root = Path(temporary)
|
|
@@ -3,6 +3,7 @@ import hashlib
|
|
|
3
3
|
import tempfile
|
|
4
4
|
import threading
|
|
5
5
|
import unittest
|
|
6
|
+
from unittest import mock
|
|
6
7
|
from concurrent.futures import ThreadPoolExecutor
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
@@ -280,6 +281,59 @@ class StageATest(unittest.TestCase):
|
|
|
280
281
|
self.assertFalse((self.vault_path / "knowledge" / "m-two-versions.md").exists())
|
|
281
282
|
self.assertFalse((self.vault_path / "history" / "m-two-versions.md").exists())
|
|
282
283
|
|
|
284
|
+
def test_forget_partial_failure_keeps_active_retry_anchor_and_rebuilds_index(self):
|
|
285
|
+
active = self.service.create_memory(
|
|
286
|
+
memory_id="m-retry-anchor",
|
|
287
|
+
title="Retry anchor",
|
|
288
|
+
body="current",
|
|
289
|
+
tags=["retry-delete"],
|
|
290
|
+
)
|
|
291
|
+
first_history = self.service.create_memory(
|
|
292
|
+
memory_id="hist-retry-a",
|
|
293
|
+
title="Old A",
|
|
294
|
+
body="old-a",
|
|
295
|
+
tags=["retry-delete"],
|
|
296
|
+
area="history",
|
|
297
|
+
active_memory_id=active.memory_id,
|
|
298
|
+
)
|
|
299
|
+
second_history = self.service.create_memory(
|
|
300
|
+
memory_id="hist-retry-b",
|
|
301
|
+
title="Old B",
|
|
302
|
+
body="old-b",
|
|
303
|
+
tags=["retry-delete"],
|
|
304
|
+
area="history",
|
|
305
|
+
active_memory_id=active.memory_id,
|
|
306
|
+
)
|
|
307
|
+
active_path = self.vault_path / "knowledge" / f"{active.memory_id}.md"
|
|
308
|
+
first_path = self.vault_path / "history" / f"{first_history.memory_id}.md"
|
|
309
|
+
second_path = self.vault_path / "history" / f"{second_history.memory_id}.md"
|
|
310
|
+
original_unlink = Path.unlink
|
|
311
|
+
attempted: list[str] = []
|
|
312
|
+
|
|
313
|
+
def flaky_unlink(path, *args, **kwargs):
|
|
314
|
+
attempted.append(path.name)
|
|
315
|
+
if path == second_path:
|
|
316
|
+
raise PermissionError("simulated history delete failure")
|
|
317
|
+
return original_unlink(path, *args, **kwargs)
|
|
318
|
+
|
|
319
|
+
with mock.patch.object(
|
|
320
|
+
self.service,
|
|
321
|
+
"_rebuild_index_unlocked",
|
|
322
|
+
wraps=self.service._rebuild_index_unlocked,
|
|
323
|
+
) as rebuild, mock.patch.object(Path, "unlink", new=flaky_unlink):
|
|
324
|
+
with self.assertRaises(PermissionError):
|
|
325
|
+
self.service.forget_memory(active.memory_id)
|
|
326
|
+
self.assertGreaterEqual(rebuild.call_count, 1)
|
|
327
|
+
|
|
328
|
+
self.assertFalse(first_path.exists())
|
|
329
|
+
self.assertTrue(second_path.exists())
|
|
330
|
+
self.assertTrue(active_path.exists())
|
|
331
|
+
self.assertNotIn(active_path.name, attempted)
|
|
332
|
+
|
|
333
|
+
self.assertTrue(self.service.forget_memory(active.memory_id))
|
|
334
|
+
self.assertFalse(second_path.exists())
|
|
335
|
+
self.assertFalse(active_path.exists())
|
|
336
|
+
|
|
283
337
|
def test_sensitive_event_id_uses_stable_digest_after_rebuild(self):
|
|
284
338
|
raw_event_id = "session/password=top-secret-token"
|
|
285
339
|
result = self.service.capture(
|
|
@@ -104,7 +104,7 @@ class StageC1MCPTest(unittest.TestCase):
|
|
|
104
104
|
testcase.assertEqual(result["resultType"], "complete")
|
|
105
105
|
testcase.assertEqual(
|
|
106
106
|
result["_meta"]["io.modelcontextprotocol/serverInfo"],
|
|
107
|
-
{"name": "memleaf", "version": "0.2.
|
|
107
|
+
{"name": "memleaf", "version": "0.2.1"},
|
|
108
108
|
)
|
|
109
109
|
return result
|
|
110
110
|
|
|
@@ -24,7 +24,7 @@ class StageC3PackagingTests(unittest.TestCase):
|
|
|
24
24
|
|
|
25
25
|
def test_project_metadata_and_version_export(self) -> None:
|
|
26
26
|
self.assertEqual(self.project["name"], "memleaf")
|
|
27
|
-
self.assertEqual(self.project["version"], "0.2.
|
|
27
|
+
self.assertEqual(self.project["version"], "0.2.1")
|
|
28
28
|
self.assertEqual(self.project["requires-python"], ">=3.11")
|
|
29
29
|
self.assertEqual(self.project["dependencies"], [])
|
|
30
30
|
self.assertEqual(self.project["license"], "MIT")
|
|
@@ -144,6 +144,59 @@ class V2HostFlowTest(unittest.TestCase):
|
|
|
144
144
|
self.assertEqual(state["status"], "NO_MATCH")
|
|
145
145
|
self.assertEqual(state["search_attempts"], 4)
|
|
146
146
|
|
|
147
|
+
def test_found_search_uses_finalized_directory_shape_and_allows_read(self):
|
|
148
|
+
memory = self.service.create_memory(
|
|
149
|
+
memory_id="found-contract",
|
|
150
|
+
title="Found contract",
|
|
151
|
+
body="FOUND_PRIVATE_BODY",
|
|
152
|
+
)
|
|
153
|
+
self.event("UserPromptSubmit", prompt="Found contract")
|
|
154
|
+
retrieval_id = find_turn(
|
|
155
|
+
self.service.vault, "codex", "isolated-session", "user-turn"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
search_pre = self.event(
|
|
159
|
+
"PreToolUse",
|
|
160
|
+
tool_name="mcp__memleaf__search",
|
|
161
|
+
tool_input={"query": "Found contract"},
|
|
162
|
+
)
|
|
163
|
+
search_args = search_pre["hookSpecificOutput"]["updatedInput"]
|
|
164
|
+
actual = self.process.send({
|
|
165
|
+
"jsonrpc": "2.0",
|
|
166
|
+
"id": 91,
|
|
167
|
+
"method": "tools/call",
|
|
168
|
+
"params": {"name": "search", "arguments": search_args},
|
|
169
|
+
})["result"]
|
|
170
|
+
self.assertFalse(actual["isError"], actual)
|
|
171
|
+
results = actual["structuredContent"]["results"]
|
|
172
|
+
self.assertTrue(results)
|
|
173
|
+
self.assertEqual(set(results[0]), {"memory_id", "title"})
|
|
174
|
+
self.assertNotIn("FOUND_PRIVATE_BODY", json.dumps(actual))
|
|
175
|
+
|
|
176
|
+
self.event(
|
|
177
|
+
"PostToolUse",
|
|
178
|
+
tool_name="mcp__memleaf__search",
|
|
179
|
+
tool_use_id="found-call",
|
|
180
|
+
tool_input=search_args,
|
|
181
|
+
tool_response=actual,
|
|
182
|
+
)
|
|
183
|
+
self.assertEqual(validate_turn(self.service.vault, retrieval_id)["status"], "FOUND")
|
|
184
|
+
|
|
185
|
+
read_pre = self.event(
|
|
186
|
+
"PreToolUse",
|
|
187
|
+
tool_name="mcp__memleaf__read",
|
|
188
|
+
tool_input={"memory_id": memory.memory_id},
|
|
189
|
+
)
|
|
190
|
+
read_args = read_pre["hookSpecificOutput"]["updatedInput"]
|
|
191
|
+
read = self.process.send({
|
|
192
|
+
"jsonrpc": "2.0",
|
|
193
|
+
"id": 92,
|
|
194
|
+
"method": "tools/call",
|
|
195
|
+
"params": {"name": "read", "arguments": read_args},
|
|
196
|
+
})["result"]
|
|
197
|
+
self.assertFalse(read["isError"], read)
|
|
198
|
+
self.assertEqual(read["structuredContent"]["body"], "FOUND_PRIVATE_BODY")
|
|
199
|
+
|
|
147
200
|
def test_scope_deferred_processing_is_reported_as_incomplete(self):
|
|
148
201
|
self.event("UserPromptSubmit", prompt="你好")
|
|
149
202
|
retrieval_id = find_turn(self.service.vault, "codex", "isolated-session", "user-turn")
|
|
@@ -284,6 +284,43 @@ class V2MCPFlowTest(unittest.TestCase):
|
|
|
284
284
|
)
|
|
285
285
|
self.assertEqual(ledger["entries"][retrieval_id]["seen_call_hashes"], [])
|
|
286
286
|
|
|
287
|
+
def test_old_codex_token_is_rejected_for_search_and_read(self):
|
|
288
|
+
memory = self.service.create_memory(
|
|
289
|
+
memory_id="old-codex-memory",
|
|
290
|
+
title="Old Codex",
|
|
291
|
+
body="OLD_CODEX_BODY",
|
|
292
|
+
)
|
|
293
|
+
old_id = begin_turn(self.service.vault, "codex", "same-codex-session", "turn-1")
|
|
294
|
+
observe_search(self.service.vault, old_id, "found", "old-codex-search")
|
|
295
|
+
current_id = begin_turn(
|
|
296
|
+
self.service.vault, "codex", "same-codex-session", "turn-2"
|
|
297
|
+
)
|
|
298
|
+
self.assertNotEqual(old_id, current_id)
|
|
299
|
+
|
|
300
|
+
stale_search = self.call(
|
|
301
|
+
"search",
|
|
302
|
+
query="Old Codex",
|
|
303
|
+
retrieval_id=old_id,
|
|
304
|
+
)
|
|
305
|
+
self.assertTrue(stale_search["isError"], stale_search)
|
|
306
|
+
self.assertEqual(
|
|
307
|
+
stale_search["structuredContent"]["error"]["code"],
|
|
308
|
+
"retrieval_turn_mismatch",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
stale_read = self.call(
|
|
312
|
+
"read",
|
|
313
|
+
memory_id=memory.memory_id,
|
|
314
|
+
retrieval_id=old_id,
|
|
315
|
+
)
|
|
316
|
+
self.assertTrue(stale_read["isError"], stale_read)
|
|
317
|
+
self.assertEqual(
|
|
318
|
+
stale_read["structuredContent"]["error"]["code"],
|
|
319
|
+
"retrieval_turn_mismatch",
|
|
320
|
+
)
|
|
321
|
+
self.assertEqual(validate_turn(self.service.vault, old_id)["read_count"], 0)
|
|
322
|
+
self.assertEqual(self.service.read(memory.memory_id).hit_count, 0)
|
|
323
|
+
|
|
287
324
|
def test_hermes_search_observation_failure_cannot_return_success(self):
|
|
288
325
|
retrieval_id = begin_turn(self.service.vault, "hermes", "session", "turn")
|
|
289
326
|
with patch(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|