memleaf 0.2.18__tar.gz → 0.2.19__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. {memleaf-0.2.18 → memleaf-0.2.19}/CHANGELOG.md +9 -0
  2. {memleaf-0.2.18/src/memleaf.egg-info → memleaf-0.2.19}/PKG-INFO +3 -3
  3. {memleaf-0.2.18 → memleaf-0.2.19}/README.en.md +2 -2
  4. {memleaf-0.2.18 → memleaf-0.2.19}/README.md +2 -2
  5. {memleaf-0.2.18 → memleaf-0.2.19}/pyproject.toml +1 -1
  6. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/hermes_provider/__init__.py +59 -40
  8. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  9. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/processing.py +24 -0
  10. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/validation.py +8 -1
  11. {memleaf-0.2.18 → memleaf-0.2.19/src/memleaf.egg-info}/PKG-INFO +3 -3
  12. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_extraction_quality_regressions.py +142 -1
  13. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_hermes_provider.py +89 -2
  14. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_pypi_install.py +5 -5
  15. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_c1_mcp.py +1 -1
  16. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_c3_packaging.py +1 -1
  17. {memleaf-0.2.18 → memleaf-0.2.19}/IMPLEMENTATION_PLAN.md +0 -0
  18. {memleaf-0.2.18 → memleaf-0.2.19}/LICENSE +0 -0
  19. {memleaf-0.2.18 → memleaf-0.2.19}/MANIFEST.in +0 -0
  20. {memleaf-0.2.18 → memleaf-0.2.19}/RELEASE_CHECKLIST.md +0 -0
  21. {memleaf-0.2.18 → memleaf-0.2.19}/examples/README.md +0 -0
  22. {memleaf-0.2.18 → memleaf-0.2.19}/examples/basic_usage.py +0 -0
  23. {memleaf-0.2.18 → memleaf-0.2.19}/examples/mcp_stdio.ndjson +0 -0
  24. {memleaf-0.2.18 → memleaf-0.2.19}/install.ps1 +0 -0
  25. {memleaf-0.2.18 → memleaf-0.2.19}/install.sh +0 -0
  26. {memleaf-0.2.18 → memleaf-0.2.19}/setup.cfg +0 -0
  27. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/__main__.py +0 -0
  28. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/adapters/__init__.py +0 -0
  29. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/adapters/antigravity.py +0 -0
  30. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/adapters/base.py +0 -0
  31. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/adapters/codex.py +0 -0
  32. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/adapters/hermes.py +0 -0
  33. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/budget.py +0 -0
  34. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/capture.py +0 -0
  35. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/cli.py +0 -0
  36. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/compaction.py +0 -0
  37. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/config.py +0 -0
  38. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/credentials.py +0 -0
  39. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/frontmatter.py +0 -0
  40. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/hermes_provider/README.md +0 -0
  41. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/host_events.py +0 -0
  42. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/host_runtime.py +0 -0
  43. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/inbox.py +0 -0
  44. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/index.py +0 -0
  45. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/installer.py +0 -0
  46. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/llm/__init__.py +0 -0
  47. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/llm/base.py +0 -0
  48. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/llm/claude_compatible.py +0 -0
  49. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/llm/gemini.py +0 -0
  50. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/llm/openai_compatible.py +0 -0
  51. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/llm/router.py +0 -0
  52. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/locking.py +0 -0
  53. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/mcp_server.py +0 -0
  54. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/memory_writer.py +0 -0
  55. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/model_discovery.py +0 -0
  56. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/models.py +0 -0
  57. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/native_index.py +0 -0
  58. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/native_registration.py +0 -0
  59. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/prompts.py +0 -0
  60. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/redaction.py +0 -0
  61. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/retrieval.py +0 -0
  62. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/retrieval_gate.py +0 -0
  63. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/scope_maintenance.py +0 -0
  64. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/scope_state.py +0 -0
  65. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/service.py +0 -0
  66. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf/vault.py +0 -0
  67. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf.egg-info/SOURCES.txt +0 -0
  68. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf.egg-info/dependency_links.txt +0 -0
  69. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf.egg-info/entry_points.txt +0 -0
  70. {memleaf-0.2.18 → memleaf-0.2.19}/src/memleaf.egg-info/top_level.txt +0 -0
  71. {memleaf-0.2.18 → memleaf-0.2.19}/tests/__init__.py +0 -0
  72. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_admission_noise.py +0 -0
  73. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_codex_install.py +0 -0
  74. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_codex_native_cli.py +0 -0
  75. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_context_budget.py +0 -0
  76. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_credential_safety.py +0 -0
  77. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_cross_host_acceptance.py +0 -0
  78. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_cross_turn_dedupe_regressions.py +0 -0
  79. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_hermes_native_registration.py +0 -0
  80. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_hermes_stdio_transport.py +0 -0
  81. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_host_events.py +0 -0
  82. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_host_runtime_contract.py +0 -0
  83. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_install.py +0 -0
  84. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_maintenance_v2.py +0 -0
  85. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_model_discovery.py +0 -0
  86. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_retrieval_gate.py +0 -0
  87. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_retrieval_v2.py +0 -0
  88. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_session_lineage.py +0 -0
  89. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_a.py +0 -0
  90. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_b1.py +0 -0
  91. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_b2a.py +0 -0
  92. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_b2b.py +0 -0
  93. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_b3a_commit.py +0 -0
  94. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_b3a_contract.py +0 -0
  95. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_b3b_native_context.py +0 -0
  96. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_b3b_native_index.py +0 -0
  97. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_b3b_scope.py +0 -0
  98. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_b3c_retrieval.py +0 -0
  99. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  100. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_stage_c2_init.py +0 -0
  101. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_todo_state_recovery.py +0 -0
  102. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_update_target_recovery.py +0 -0
  103. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_upgrade_preserves_vault.py +0 -0
  104. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_v2_gate_limits.py +0 -0
  105. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_v2_host_flow.py +0 -0
  106. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_v2_mcp_flow.py +0 -0
  107. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_v2_nomatch_semantics.py +0 -0
  108. {memleaf-0.2.18 → memleaf-0.2.19}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.19 — 2026-09-03
6
+
7
+ - Keep Hermes `<untrusted_tool_result>` search audit status synchronized with
8
+ Core for `FOUND` and `NO_MATCH` results using bounded, strict decoding;
9
+ malformed and error envelopes remain audit errors.
10
+ - After three `mixed_future_use` gate responses, defer only the affected
11
+ candidate so valid candidates from the same turn can continue processing;
12
+ mixed content is never persisted and unrelated schema violations still fail.
13
+
5
14
  ## 0.2.18 — 2026-09-02
6
15
 
7
16
  - Register Hermes native `MEMORY.md` and `USER.md` as private, read-only native sources during supported install/upgrade flows while preserving custom native source configuration.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.18
3
+ Version: 0.2.19
4
4
  Summary: A local-first Markdown memory core for AI agents
5
5
  Author: memleaf contributors
6
6
  License-Expression: MIT
@@ -23,8 +23,8 @@ Dynamic: license-file
23
23
 
24
24
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
25
25
 
26
- > **当前版本:0.2.18。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.18 通过 PyPI 分发。
26
+ > **当前版本:0.2.19。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.19 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -4,8 +4,8 @@
4
4
 
5
5
  [中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **Version: 0.2.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.
7
+ > **Version: 0.2.19.**
8
+ > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. memleaf 0.2.19 is distributed through PyPI.
9
9
  > **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
10
10
 
11
11
  ## Project scope
@@ -4,8 +4,8 @@
4
4
 
5
5
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **当前版本:0.2.18。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.18 通过 PyPI 分发。
7
+ > **当前版本:0.2.19。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.19 通过 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.18"
7
+ version = "0.2.19"
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.18"
3
+ __version__ = "0.2.19"
4
4
 
5
5
  from .config import DEFAULT_CONFIG, default_config, load_config, save_config
6
6
  from .frontmatter import FrontmatterError, dump_frontmatter, dump_yaml, load_yaml, parse_frontmatter
@@ -387,47 +387,66 @@ def _scope_context(
387
387
  def _hermes_search_status(value: Any) -> str:
388
388
  """Classify an observed MCP search result without retaining its content."""
389
389
 
390
- decoded: Any = value
391
- if isinstance(decoded, list):
392
- for item in decoded:
393
- if isinstance(item, Mapping) and item.get("type") == "text":
394
- decoded = item.get("text")
395
- break
396
- if isinstance(decoded, str):
397
- try:
398
- decoded = json.loads(decoded)
399
- except (TypeError, ValueError):
400
- return "error"
401
- if not isinstance(decoded, Mapping):
402
- return "error"
403
- if decoded.get("isError") is True:
404
- return "error"
405
- nested = decoded.get("structuredContent")
406
- if isinstance(nested, Mapping):
407
- decoded = nested.get("result") if isinstance(nested.get("result"), Mapping) else nested
408
- elif isinstance(decoded.get("content"), list):
409
- return _hermes_search_status(decoded["content"])
410
- if not isinstance(decoded, Mapping) or "error" in decoded:
411
- return "error"
412
- status = decoded.get("status")
413
- results = decoded.get("results")
414
- if status not in {"found", "no_match"} or not isinstance(results, list):
415
- return "error"
416
- valid_results = all(
417
- isinstance(item, Mapping)
418
- and set(item) == {"memory_id", "title"}
419
- and isinstance(item.get("memory_id"), str)
420
- and bool(item.get("memory_id"))
421
- and isinstance(item.get("title"), str)
422
- and bool(item.get("title"))
423
- for item in results
424
- )
425
- if not valid_results:
390
+ if value is _CALL_FAILED or value is None:
426
391
  return "error"
427
- if status == "found" and results:
428
- return "found"
429
- if status == "no_match" and not results:
430
- return "no_match"
392
+ decoded: Any = _decode_hermes_tool_result(value)
393
+ for _ in range(_MAX_TOOL_RESULT_LAYERS):
394
+ if decoded is _MISSING_TOOL_RESULT:
395
+ return "error"
396
+ if isinstance(decoded, str):
397
+ decoded = _decode_hermes_tool_result(decoded)
398
+ continue
399
+ if isinstance(decoded, list):
400
+ text_item = next(
401
+ (
402
+ item
403
+ for item in decoded
404
+ if isinstance(item, Mapping) and item.get("type") == "text"
405
+ ),
406
+ None,
407
+ )
408
+ if text_item is None:
409
+ return "error"
410
+ decoded = _decode_hermes_tool_result(text_item.get("text"))
411
+ continue
412
+ if not isinstance(decoded, Mapping):
413
+ return "error"
414
+ if decoded.get("isError") is True or decoded.get("error") is not None:
415
+ return "error"
416
+
417
+ status = decoded.get("status")
418
+ results = decoded.get("results")
419
+ if status is not None or results is not None:
420
+ if status not in {"found", "no_match"} or not isinstance(results, list):
421
+ return "error"
422
+ valid_results = all(
423
+ isinstance(item, Mapping)
424
+ and set(item) == {"memory_id", "title"}
425
+ and isinstance(item.get("memory_id"), str)
426
+ and bool(item.get("memory_id"))
427
+ and isinstance(item.get("title"), str)
428
+ and bool(item.get("title"))
429
+ for item in results
430
+ )
431
+ if not valid_results:
432
+ return "error"
433
+ if status == "found" and results:
434
+ return "found"
435
+ if status == "no_match" and not results:
436
+ return "no_match"
437
+ return "error"
438
+
439
+ nested = next(
440
+ (
441
+ decoded[key]
442
+ for key in ("structuredContent", "result", "content")
443
+ if isinstance(decoded.get(key), (Mapping, list, str))
444
+ ),
445
+ _MISSING_TOOL_RESULT,
446
+ )
447
+ if nested is _MISSING_TOOL_RESULT:
448
+ return "error"
449
+ decoded = _decode_hermes_tool_result(nested)
431
450
  return "error"
432
451
 
433
452
 
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.18
2
+ version: 0.2.19
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -67,6 +67,7 @@ from .validation import (
67
67
  normalize_relative_calendar_text,
68
68
  is_aggregate_operational_text,
69
69
  is_attachment_followup_only_text,
70
+ is_mixed_future_use_text,
70
71
  is_project_plan_text,
71
72
  )
72
73
  from .vault import safe_component
@@ -2414,6 +2415,7 @@ class Processor:
2414
2415
  related_memory_ids=gate_related_memory_ids,
2415
2416
  scope_registry=validation_scope_registry,
2416
2417
  enforce_model_scope_grounding=gate_attempt_count < 3,
2418
+ allow_mixed_future_use=gate_attempt_count >= 3,
2417
2419
  )
2418
2420
  if gate_attempt_count >= 3:
2419
2421
  candidates = []
@@ -2564,6 +2566,19 @@ class Processor:
2564
2566
  candidates.append(independent)
2565
2567
  parsed = dict(parsed)
2566
2568
  parsed["candidates"] = candidates
2569
+ if gate_attempt_count >= 3:
2570
+ # Mixed future-use is still a hard safety boundary. After
2571
+ # the bounded correction attempts, retain only the affected
2572
+ # candidate for deferred retry so valid siblings can commit;
2573
+ # no mixed body can reach summarize or persistence.
2574
+ marked_candidates: list[dict[str, Any]] = []
2575
+ for candidate in parsed["candidates"]:
2576
+ item = dict(candidate)
2577
+ if item.get("worth") and is_mixed_future_use_text(item.get("memory")):
2578
+ item["_defer_reason"] = "mixed_future_use"
2579
+ marked_candidates.append(item)
2580
+ parsed = dict(parsed)
2581
+ parsed["candidates"] = marked_candidates
2567
2582
  return parsed
2568
2583
 
2569
2584
  gate = self._complete_json_stage(
@@ -2647,6 +2662,15 @@ class Processor:
2647
2662
  candidate_id_key = str(candidate.get("candidate_id", "")).casefold()
2648
2663
  recovery = recovery_by_candidate.get(candidate_id_key)
2649
2664
  detached_update_target_id = detached_update_target_ids.get(candidate_id_key)
2665
+ defer_reason = candidate.get("_defer_reason")
2666
+ if isinstance(defer_reason, str) and defer_reason == "mixed_future_use":
2667
+ self._defer_candidate(
2668
+ turn_ref,
2669
+ candidate,
2670
+ defer_reason,
2671
+ scopes=candidate.get("scopes"),
2672
+ )
2673
+ continue
2650
2674
  if candidate.get("worth") and (
2651
2675
  (read_only_query and recovery is None)
2652
2676
  or _automatic_transient_memory(candidate.get("memory"))
@@ -1086,6 +1086,7 @@ def validate_gate_output(
1086
1086
  related_memory_types: Mapping[str, Any] | None = None,
1087
1087
  scope_registry: Mapping[str, Any] | None = None,
1088
1088
  enforce_model_scope_grounding: bool = True,
1089
+ allow_mixed_future_use: bool = False,
1089
1090
  ) -> dict[str, Any]:
1090
1091
  """Validate and return a normalized gate object without writing anything."""
1091
1092
 
@@ -1146,7 +1147,11 @@ def validate_gate_output(
1146
1147
  raise ModelOutputError("duplicate candidate cannot be worth remembering", validation_detail="invalid_flags")
1147
1148
  if item["worth"] and candidate_type is None:
1148
1149
  raise ModelOutputError("worth candidate must have a type", validation_detail="invalid_type")
1149
- if item["worth"] and is_mixed_future_use_text(item["memory"]):
1150
+ if (
1151
+ item["worth"]
1152
+ and is_mixed_future_use_text(item["memory"])
1153
+ and not allow_mixed_future_use
1154
+ ):
1150
1155
  raise ModelOutputError(
1151
1156
  "one candidate combines independent future uses",
1152
1157
  validation_detail="mixed_future_use",
@@ -1263,6 +1268,7 @@ def parse_gate_output(
1263
1268
  related_memory_types: Mapping[str, Any] | None = None,
1264
1269
  scope_registry: Mapping[str, Any] | None = None,
1265
1270
  enforce_model_scope_grounding: bool = True,
1271
+ allow_mixed_future_use: bool = False,
1266
1272
  ) -> dict[str, Any]:
1267
1273
  try:
1268
1274
  parsed = parse_strict_json(raw)
@@ -1279,6 +1285,7 @@ def parse_gate_output(
1279
1285
  related_memory_types=related_memory_types,
1280
1286
  scope_registry=scope_registry,
1281
1287
  enforce_model_scope_grounding=enforce_model_scope_grounding,
1288
+ allow_mixed_future_use=allow_mixed_future_use,
1282
1289
  )
1283
1290
  except ModelOutputError as error:
1284
1291
  if error.validation_reason is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.18
3
+ Version: 0.2.19
4
4
  Summary: A local-first Markdown memory core for AI agents
5
5
  Author: memleaf contributors
6
6
  License-Expression: MIT
@@ -23,8 +23,8 @@ Dynamic: license-file
23
23
 
24
24
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
25
25
 
26
- > **当前版本:0.2.18。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.18 通过 PyPI 分发。
26
+ > **当前版本:0.2.19。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.19 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
 
15
15
  from memleaf import Memleaf
16
16
  from memleaf.index import event_key
17
- from memleaf.validation import parse_gate_output
17
+ from memleaf.validation import ModelOutputError, parse_gate_output
18
18
 
19
19
 
20
20
  class QueueBackend:
@@ -199,6 +199,147 @@ class ExtractionQualityRegressionTests(unittest.TestCase):
199
199
  "project constraint and deadline todo were merged into one memory",
200
200
  )
201
201
 
202
+ def test_repeated_mixed_future_use_defers_only_bad_candidate(self):
203
+ """A third mixed-use failure must not discard a valid sibling candidate."""
204
+
205
+ user_key, assistant_key = self.capture_turn(
206
+ "repeated-mixed-future-use",
207
+ user="请记录本轮确认的项目要求。",
208
+ assistant="系统采用达梦数据库;部署测试环境的任务必须在2026-09-10前完成。",
209
+ )
210
+ project_scope = ["project:金元顺安"]
211
+ mixed = candidate(
212
+ "mixed-repeated",
213
+ [user_key, assistant_key],
214
+ memory=(
215
+ "金元顺安实施计划采用达梦和东方通,并要求部署测试环境在"
216
+ "2026-09-10前完成。"
217
+ ),
218
+ type="project",
219
+ scopes=project_scope,
220
+ )
221
+ valid = candidate(
222
+ "valid-sibling",
223
+ [user_key, assistant_key],
224
+ memory="本轮确认系统采用达梦数据库。",
225
+ type="fact",
226
+ scopes=["global"],
227
+ )
228
+ backend = QueueBackend(
229
+ [
230
+ gate([mixed, valid]),
231
+ gate([mixed, valid]),
232
+ gate([mixed, valid]),
233
+ summary(
234
+ user_key,
235
+ title="系统数据库选型",
236
+ body="本轮确认系统采用达梦数据库。",
237
+ type="fact",
238
+ scopes=["global"],
239
+ ),
240
+ ]
241
+ )
242
+
243
+ result = self.service.process(
244
+ source="hermes",
245
+ session_id="repeated-mixed-future-use",
246
+ model=backend,
247
+ )
248
+
249
+ self.assertEqual(result["processed_turns"], 1)
250
+ self.assertEqual(result["memories_written"], 1)
251
+ self.assertEqual(len(self.active_memories()), 1)
252
+ self.assertEqual(self.active_memories()[0].body, "本轮确认系统采用达梦数据库。")
253
+ self.assertEqual(
254
+ [call["purpose"] for call in backend.calls],
255
+ ["gate", "gate", "gate", "summarize"],
256
+ )
257
+ processed = json.loads(
258
+ self.service.vault.processed_index_path.read_text(encoding="utf-8")
259
+ )
260
+ state = processed["sessions"]["hermes/repeated-mixed-future-use"]
261
+ self.assertEqual(state["processing"]["status"], "idle")
262
+ self.assertEqual(result["deferred_candidates"], 1)
263
+ self.assertEqual(
264
+ state["processed_turns"][0]["deferred_candidates"][0]["candidate_id"],
265
+ "mixed-repeated",
266
+ )
267
+ self.assertEqual(
268
+ state["processed_turns"][0]["deferred_candidates"][0]["reason"],
269
+ "mixed_future_use",
270
+ )
271
+ self.assertTrue(
272
+ (
273
+ self.service.vault.inbox_path
274
+ / "hermes"
275
+ / "repeated-mixed-future-use.md"
276
+ ).is_file()
277
+ )
278
+
279
+ retry_backend = QueueBackend(
280
+ [
281
+ gate([mixed, valid]),
282
+ gate([mixed, valid]),
283
+ gate([mixed, valid]),
284
+ summary(
285
+ user_key,
286
+ title="系统数据库选型",
287
+ body="本轮确认系统采用达梦数据库。",
288
+ type="fact",
289
+ scopes=["global"],
290
+ ),
291
+ ]
292
+ )
293
+ retry = self.service.process(
294
+ source="hermes",
295
+ session_id="repeated-mixed-future-use",
296
+ scope="global",
297
+ model=retry_backend,
298
+ )
299
+ self.assertEqual(retry["processed_turns"], 1)
300
+ self.assertEqual(retry["memory_ids"], [])
301
+ self.assertEqual(retry["memories_written"], 0)
302
+ self.assertEqual(len(self.active_memories()), 1)
303
+ self.assertEqual(retry["deferred_candidates"], 1)
304
+
305
+ def test_repeated_mixed_future_use_does_not_swallow_other_schema_errors(self):
306
+ """The final mixed-use fallback must preserve unrelated validation failures."""
307
+
308
+ user_key, assistant_key = self.capture_turn(
309
+ "repeated-mixed-with-invalid-type",
310
+ user="请记录本轮确认的项目要求。",
311
+ assistant="系统采用达梦数据库;部署测试环境的任务必须在2026-09-10前完成。",
312
+ )
313
+ mixed = candidate(
314
+ "mixed-repeated",
315
+ [user_key, assistant_key],
316
+ memory=(
317
+ "金元顺安实施计划采用达梦和东方通,并要求部署测试环境在"
318
+ "2026-09-10前完成。"
319
+ ),
320
+ type="project",
321
+ scopes=["project:金元顺安"],
322
+ )
323
+ invalid = candidate(
324
+ "invalid-sibling",
325
+ [user_key, assistant_key],
326
+ memory="本轮确认系统采用达梦数据库。",
327
+ type="unsupported-type",
328
+ scopes=["global"],
329
+ )
330
+ backend = QueueBackend([gate([mixed, invalid])] * 3)
331
+
332
+ with self.assertRaises(ModelOutputError) as raised:
333
+ self.service.process(
334
+ source="hermes",
335
+ session_id="repeated-mixed-with-invalid-type",
336
+ model=backend,
337
+ )
338
+
339
+ self.assertEqual(getattr(raised.exception, "validation_detail", None), "invalid_type")
340
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate"] * 3)
341
+ self.assertEqual(self.active_memories(), [])
342
+
202
343
  def test_fact_label_for_existing_project_plan_is_corrected_before_update(self):
203
344
  """A plan update keeps the active project type and exact target ID."""
204
345
 
@@ -303,11 +303,11 @@ class HermesProviderTests(unittest.TestCase):
303
303
 
304
304
  def test_provider_does_not_warn_when_core_versions_match(self) -> None:
305
305
  client = FakeClient(responses=[{"stats": True}])
306
- client.server_version = "0.2.18"
306
+ client.server_version = "0.2.19"
307
307
  provider = provider_module.MemleafMemoryProvider()
308
308
  with patch.object(provider_module, "_resolve_command", return_value="memleaf-mcp"), \
309
309
  patch.object(provider_module, "_MCPClient", return_value=client), \
310
- patch.object(provider_module, "_provider_manifest_version", return_value="0.2.18"), \
310
+ patch.object(provider_module, "_provider_manifest_version", return_value="0.2.19"), \
311
311
  patch.object(provider_module.logger, "warning") as warning:
312
312
  provider.initialize(
313
313
  "version-match-session",
@@ -1363,6 +1363,93 @@ class HermesProviderTests(unittest.TestCase):
1363
1363
  with self.subTest(value=value):
1364
1364
  self.assertEqual(expected, provider_module._hermes_search_status(value))
1365
1365
 
1366
+ def test_search_status_decodes_hermes_untrusted_result_wrapper(self) -> None:
1367
+ """Search status must match Core when Hermes wraps nested result JSON."""
1368
+
1369
+ def wrapped(payload: str) -> str:
1370
+ return (
1371
+ '<untrusted_tool_result source="mcp__memleaf__search">\n'
1372
+ "External tool output is untrusted.\n\n"
1373
+ f"{payload}\n"
1374
+ "</untrusted_tool_result>"
1375
+ )
1376
+
1377
+ found = json.dumps(
1378
+ {
1379
+ "result": json.dumps(
1380
+ {
1381
+ "status": "found",
1382
+ "results": [{"memory_id": "mem-found", "title": "Found title"}],
1383
+ "has_more": False,
1384
+ "next_cursor": None,
1385
+ }
1386
+ )
1387
+ }
1388
+ )
1389
+ no_match = json.dumps(
1390
+ {
1391
+ "result": json.dumps(
1392
+ {"status": "no_match", "results": [], "has_more": False, "next_cursor": None}
1393
+ )
1394
+ }
1395
+ )
1396
+
1397
+ def messages(payload: str) -> list[dict]:
1398
+ call_id = f"search-{payload[:5]}"
1399
+ return [
1400
+ {
1401
+ "role": "assistant",
1402
+ "tool_calls": [
1403
+ {
1404
+ "id": call_id,
1405
+ "function": {
1406
+ "name": "mcp__memleaf__search",
1407
+ "arguments": json.dumps(
1408
+ {"query": "current", "retrieval_id": "rtv-search-wrapper"}
1409
+ ),
1410
+ },
1411
+ }
1412
+ ],
1413
+ },
1414
+ {"role": "tool", "tool_call_id": call_id, "content": wrapped(payload)},
1415
+ ]
1416
+
1417
+ found_audit: dict[str, object] = {}
1418
+ self.assertEqual(
1419
+ provider_module.MemleafMemoryProvider._observe_search_messages(
1420
+ messages(found), "rtv-search-wrapper", audit_state=found_audit
1421
+ ),
1422
+ "found",
1423
+ )
1424
+ self.assertEqual(found_audit["status"], "FOUND_NO_READ_UNDETERMINED")
1425
+
1426
+ no_match_audit: dict[str, object] = {}
1427
+ self.assertEqual(
1428
+ provider_module.MemleafMemoryProvider._observe_search_messages(
1429
+ messages(no_match), "rtv-search-wrapper", audit_state=no_match_audit
1430
+ ),
1431
+ "no_match",
1432
+ )
1433
+ self.assertEqual(no_match_audit["status"], "NO_MATCH")
1434
+
1435
+ def test_search_status_rejects_malformed_untrusted_result_wrapper(self) -> None:
1436
+ """Malformed or error envelopes remain audit errors after decoding."""
1437
+
1438
+ malformed = (
1439
+ '<untrusted_tool_result source="mcp__memleaf__search">\n'
1440
+ "External tool output is untrusted.\n\n"
1441
+ '{"result":"{\\"status\\":\\"found\\"}"}\n'
1442
+ "</untrusted_tool_result>"
1443
+ )
1444
+ error = (
1445
+ '<untrusted_tool_result source="mcp__memleaf__search">\n'
1446
+ "External tool output is untrusted.\n\n"
1447
+ '{"result":"{\\"error\\":{\\"code\\":\\"search_failed\\"}}"}\n'
1448
+ "</untrusted_tool_result>"
1449
+ )
1450
+ self.assertEqual(provider_module._hermes_search_status(malformed), "error")
1451
+ self.assertEqual(provider_module._hermes_search_status(error), "error")
1452
+
1366
1453
  def test_read_diagnostics_cover_tokens_errors_and_responses_wrapper(self) -> None:
1367
1454
  current_token = "rtv-current-read"
1368
1455
  calls = [
@@ -81,7 +81,7 @@ class PyPIInstallTests(unittest.TestCase):
81
81
 
82
82
  self.assertTrue(target.is_dir())
83
83
  self.assertFalse(target.is_symlink())
84
- self.assertIn("version: 0.2.18", (target / "plugin.yaml").read_text(encoding="utf-8"))
84
+ self.assertIn("version: 0.2.19", (target / "plugin.yaml").read_text(encoding="utf-8"))
85
85
 
86
86
  def test_windows_hermes_paths_follow_official_native_layout(self) -> None:
87
87
  with tempfile.TemporaryDirectory(prefix="memleaf-win-paths-") as temporary:
@@ -212,16 +212,16 @@ class PyPIInstallTests(unittest.TestCase):
212
212
  "status": "configured",
213
213
  "reason": "ok",
214
214
  "vault": "/tmp/memleaf-vault",
215
- "core_version": "0.2.18",
216
- "provider_version": "0.2.18",
215
+ "core_version": "0.2.19",
216
+ "provider_version": "0.2.19",
217
217
  "model": {"status": "configured"},
218
218
  }
219
219
  with mock.patch("memleaf.installer.install_hermes", return_value=result), \
220
220
  mock.patch("sys.stdout") as stdout:
221
221
  self.assertEqual(cli.main(["install"]), 0)
222
222
  output = "\n".join(str(call.args[0]) for call in stdout.write.call_args_list)
223
- self.assertIn("core=0.2.18", output)
224
- self.assertIn("Hermes provider=0.2.18", output)
223
+ self.assertIn("core=0.2.19", output)
224
+ self.assertIn("Hermes provider=0.2.19", output)
225
225
 
226
226
  def test_readmes_document_one_line_core_and_provider_upgrade(self) -> None:
227
227
  command = "python -m pip install -U memleaf && python -m memleaf install"
@@ -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.18"},
107
+ {"name": "memleaf", "version": "0.2.19"},
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.18")
27
+ self.assertEqual(self.project["version"], "0.2.19")
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")
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