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.
Files changed (102) hide show
  1. {memleaf-0.2.0 → memleaf-0.2.1}/CHANGELOG.md +11 -0
  2. {memleaf-0.2.0 → memleaf-0.2.1}/PKG-INFO +3 -3
  3. {memleaf-0.2.0 → memleaf-0.2.1}/README.en.md +2 -2
  4. {memleaf-0.2.0 → memleaf-0.2.1}/README.md +2 -2
  5. {memleaf-0.2.0 → memleaf-0.2.1}/pyproject.toml +1 -1
  6. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/hermes_provider/__init__.py +1 -3
  8. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  9. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/host_events.py +1 -3
  10. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/installer.py +22 -4
  11. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/mcp_server.py +12 -5
  12. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/model_discovery.py +10 -96
  13. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/retrieval_gate.py +3 -2
  14. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/service.py +24 -6
  15. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf.egg-info/PKG-INFO +3 -3
  16. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_codex_install.py +20 -0
  17. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_hermes_provider.py +9 -3
  18. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_model_discovery.py +27 -42
  19. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_pypi_install.py +11 -2
  20. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_a.py +54 -0
  21. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_c1_mcp.py +1 -1
  22. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_c3_packaging.py +1 -1
  23. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_v2_host_flow.py +53 -0
  24. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_v2_mcp_flow.py +37 -0
  25. {memleaf-0.2.0 → memleaf-0.2.1}/IMPLEMENTATION_PLAN.md +0 -0
  26. {memleaf-0.2.0 → memleaf-0.2.1}/LICENSE +0 -0
  27. {memleaf-0.2.0 → memleaf-0.2.1}/MANIFEST.in +0 -0
  28. {memleaf-0.2.0 → memleaf-0.2.1}/RELEASE_CHECKLIST.md +0 -0
  29. {memleaf-0.2.0 → memleaf-0.2.1}/examples/README.md +0 -0
  30. {memleaf-0.2.0 → memleaf-0.2.1}/examples/basic_usage.py +0 -0
  31. {memleaf-0.2.0 → memleaf-0.2.1}/examples/mcp_stdio.ndjson +0 -0
  32. {memleaf-0.2.0 → memleaf-0.2.1}/install.ps1 +0 -0
  33. {memleaf-0.2.0 → memleaf-0.2.1}/install.sh +0 -0
  34. {memleaf-0.2.0 → memleaf-0.2.1}/setup.cfg +0 -0
  35. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/__main__.py +0 -0
  36. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/adapters/__init__.py +0 -0
  37. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/adapters/antigravity.py +0 -0
  38. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/adapters/base.py +0 -0
  39. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/adapters/codex.py +0 -0
  40. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/adapters/hermes.py +0 -0
  41. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/budget.py +0 -0
  42. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/capture.py +0 -0
  43. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/cli.py +0 -0
  44. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/compaction.py +0 -0
  45. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/config.py +0 -0
  46. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/credentials.py +0 -0
  47. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/frontmatter.py +0 -0
  48. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/hermes_provider/README.md +0 -0
  49. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/host_runtime.py +0 -0
  50. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/inbox.py +0 -0
  51. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/index.py +0 -0
  52. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/llm/__init__.py +0 -0
  53. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/llm/base.py +0 -0
  54. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/llm/claude_compatible.py +0 -0
  55. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/llm/gemini.py +0 -0
  56. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/llm/openai_compatible.py +0 -0
  57. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/llm/router.py +0 -0
  58. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/locking.py +0 -0
  59. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/memory_writer.py +0 -0
  60. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/models.py +0 -0
  61. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/native_index.py +0 -0
  62. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/processing.py +0 -0
  63. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/prompts.py +0 -0
  64. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/redaction.py +0 -0
  65. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/retrieval.py +0 -0
  66. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/scope_maintenance.py +0 -0
  67. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/scope_state.py +0 -0
  68. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/validation.py +0 -0
  69. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf/vault.py +0 -0
  70. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf.egg-info/SOURCES.txt +0 -0
  71. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf.egg-info/dependency_links.txt +0 -0
  72. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf.egg-info/entry_points.txt +0 -0
  73. {memleaf-0.2.0 → memleaf-0.2.1}/src/memleaf.egg-info/top_level.txt +0 -0
  74. {memleaf-0.2.0 → memleaf-0.2.1}/tests/__init__.py +0 -0
  75. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_admission_noise.py +0 -0
  76. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_codex_native_cli.py +0 -0
  77. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_context_budget.py +0 -0
  78. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_credential_safety.py +0 -0
  79. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_cross_host_acceptance.py +0 -0
  80. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_hermes_stdio_transport.py +0 -0
  81. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_host_events.py +0 -0
  82. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_host_runtime_contract.py +0 -0
  83. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_install.py +0 -0
  84. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_maintenance_v2.py +0 -0
  85. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_retrieval_gate.py +0 -0
  86. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_retrieval_v2.py +0 -0
  87. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_session_lineage.py +0 -0
  88. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b1.py +0 -0
  89. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b2a.py +0 -0
  90. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b2b.py +0 -0
  91. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3a_commit.py +0 -0
  92. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3a_contract.py +0 -0
  93. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3b_native_context.py +0 -0
  94. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3b_native_index.py +0 -0
  95. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3b_scope.py +0 -0
  96. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3c_retrieval.py +0 -0
  97. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  98. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_stage_c2_init.py +0 -0
  99. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_upgrade_preserves_vault.py +0 -0
  100. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_v2_gate_limits.py +0 -0
  101. {memleaf-0.2.0 → memleaf-0.2.1}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {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.0
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.0。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.0 通过 PyPI 分发。
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.0.**
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.0 is distributed through PyPI.
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.0。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.0 通过 PyPI 分发。
7
+ > **当前版本:0.2.1。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.1 通过 PyPI 分发。
9
9
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
10
10
 
11
11
  ## 项目定位
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memleaf"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "A local-first Markdown memory core for AI agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  """Local-first Markdown memory core for AI agents."""
2
2
 
3
- __version__ = "0.2.0"
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:
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.0
2
+ version: 0.2.1
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -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 existing[0][1], sources
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
- """Return validated Hermes state; Codex observation remains in hooks."""
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
- if state.get("source") != "hermes":
677
- return None
678
- return validate_current_turn(service.vault, retrieval_id, "hermes")
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 = "hermes" if state.get("source") == "hermes" else None
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
- """Discover only Codex providers with a chat-completions route.
564
+ """Keep the legacy API fail-closed without reading Codex model settings.
586
565
 
587
- Codex's documented ``responses`` wire API is not silently treated as
588
- OpenAI chat completions. Until memleaf has a tested Responses backend,
589
- those routes are reported as unsupported and skipped.
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
- effective_home = adapter_home(home)
593
- environment = adapter_environment(env)
594
- if config_path is not None:
595
- path = Path(config_path).expanduser()
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
- found, messages = discover_codex(home=home, env=env)
686
- candidates.extend(found)
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
- This is used only for Hermes' managed MCP path. Ordinary ``validate_turn``
392
- remains available for Codex Stop continuation and historical inspection.
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
- deleted: list[str] = []
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
- try:
1360
- record.path.unlink()
1361
- except FileNotFoundError:
1362
- continue
1363
- deleted.append(record.memory.memory_id)
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.0
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.0。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.0 通过 PyPI 分发。
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", "scopes": ["project: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", "scopes": ["global"]}
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
- ({"status": "found", "results": [dict(valid, scopes=[])]}, "error"),
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 test_codex_responses_route_is_skipped_and_chat_route_is_callable(self):
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
- config = home / ".codex" / "config.toml"
204
- config.parent.mkdir()
203
+ codex_home = home / ".codex"
204
+ codex_home.mkdir()
205
+ config = codex_home / "config.toml"
205
206
  config.write_text(
206
- 'model = "codex-mini"\n'
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
- candidates, diagnostics = discover_codex(home=home, env={"CODEX_CHAT_KEY": "chat-secret"})
221
- self.assertEqual(len(candidates), 1)
222
- self.assertEqual(candidates[0].model, "codex-mini")
223
- self.assertTrue(any("responses" in item for item in diagnostics))
224
- self.assertNotIn("chat-secret", " ".join(diagnostics))
225
- self.assertNotIn("responses-secret", " ".join(diagnostics))
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 test_codex_home_is_respected(self):
228
- with tempfile.TemporaryDirectory() as temporary:
229
- home = Path(temporary)
230
- codex_home = home / "custom-codex"
231
- codex_home.mkdir()
232
- (codex_home / "config.toml").write_text(
233
- 'model = "custom-mini"\n'
234
- 'model_provider = "custom"\n'
235
- '[model_providers.custom]\n'
236
- 'base_url = "https://chat.example.test/v1"\n'
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
- self.assertEqual([item.model for item in candidates], ["custom-mini"])
247
- self.assertNotIn("custom-secret", " ".join(diagnostics))
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.0", (target / "plugin.yaml").read_text(encoding="utf-8"))
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.0"},
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.0")
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