memleaf 0.2.4__tar.gz → 0.2.5__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.4 → memleaf-0.2.5}/CHANGELOG.md +11 -0
- {memleaf-0.2.4/src/memleaf.egg-info → memleaf-0.2.5}/PKG-INFO +3 -3
- {memleaf-0.2.4 → memleaf-0.2.5}/README.md +2 -2
- {memleaf-0.2.4 → memleaf-0.2.5}/pyproject.toml +1 -1
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/hermes_provider/__init__.py +1 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/memory_writer.py +41 -13
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/processing.py +56 -1
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/prompts.py +35 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/validation.py +60 -0
- {memleaf-0.2.4 → memleaf-0.2.5/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_hermes_provider.py +17 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_pypi_install.py +1 -1
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b1.py +94 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b2a.py +260 -1
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_c3_packaging.py +1 -1
- {memleaf-0.2.4 → memleaf-0.2.5}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/LICENSE +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/MANIFEST.in +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/README.en.md +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/examples/README.md +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/examples/basic_usage.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/install.ps1 +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/install.sh +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/setup.cfg +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/config.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/index.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/models.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/service.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf.egg-info/SOURCES.txt +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/__init__.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_admission_noise.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_host_events.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_install.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_maintenance_v2.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.4 → memleaf-0.2.5}/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.5 — 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Detect multiple gate candidates that target the same active memory before
|
|
8
|
+
batch writing, then retry with an explicit instruction to merge the
|
|
9
|
+
candidates while preserving all supporting evidence.
|
|
10
|
+
- Keep gate-selected update targets and active-memory types stable through
|
|
11
|
+
summarization, with bounded corrective retries for target or type drift.
|
|
12
|
+
- Preserve `duplicate_update_target` and aggregate preflight diagnostics across
|
|
13
|
+
Core, MCP, and Hermes while retaining the inbox and zero-write transaction
|
|
14
|
+
boundary after repeated failure.
|
|
15
|
+
|
|
5
16
|
## 0.2.4 — 2026-09-01
|
|
6
17
|
|
|
7
18
|
- Give `relative_time` retries explicit, actionable correction instructions so
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
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.5。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.5 通过 PyPI 分发。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
30
30
|
## 项目定位
|
|
@@ -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.5。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.5 通过 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.5"
|
|
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
|
|
@@ -133,6 +133,25 @@ class MemoryWriter:
|
|
|
133
133
|
}
|
|
134
134
|
return request_keys.issubset(applied_keys)
|
|
135
135
|
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _preflight_error(
|
|
138
|
+
message: str,
|
|
139
|
+
*,
|
|
140
|
+
validation_detail: str = "other_schema_violation",
|
|
141
|
+
) -> ModelOutputError:
|
|
142
|
+
"""Return a safe, stage-labelled batch-consistency failure."""
|
|
143
|
+
|
|
144
|
+
error = ModelOutputError(
|
|
145
|
+
message,
|
|
146
|
+
validation_reason="schema_violation",
|
|
147
|
+
validation_detail=validation_detail,
|
|
148
|
+
)
|
|
149
|
+
# The model outputs that form this batch have already passed their
|
|
150
|
+
# individual summaries. Keep the public stage vocabulary bounded
|
|
151
|
+
# while making a defensive aggregate failure diagnosable.
|
|
152
|
+
error.stage = "summarize"
|
|
153
|
+
return error
|
|
154
|
+
|
|
136
155
|
def _preflight(self, requests: list[Mapping[str, Any]]) -> None:
|
|
137
156
|
active = self._active_records()
|
|
138
157
|
target_ids: set[str] = set()
|
|
@@ -144,11 +163,14 @@ class MemoryWriter:
|
|
|
144
163
|
duplicate_id = request.get("duplicate_memory_id")
|
|
145
164
|
if duplicate_id is not None:
|
|
146
165
|
if not isinstance(duplicate_id, str) or duplicate_id not in active:
|
|
147
|
-
raise
|
|
166
|
+
raise self._preflight_error("duplicate target is not an active memory")
|
|
148
167
|
if duplicate_id in duplicate_ids:
|
|
149
|
-
raise
|
|
168
|
+
raise self._preflight_error(
|
|
169
|
+
"batch contains duplicate metadata merge target",
|
|
170
|
+
validation_detail="duplicate_update_target",
|
|
171
|
+
)
|
|
150
172
|
if summary.get("update_memory_id") is not None:
|
|
151
|
-
raise
|
|
173
|
+
raise self._preflight_error("duplicate target conflicts with update target")
|
|
152
174
|
duplicate_ids.add(duplicate_id)
|
|
153
175
|
continue
|
|
154
176
|
target_id = summary.get("update_memory_id")
|
|
@@ -161,12 +183,15 @@ class MemoryWriter:
|
|
|
161
183
|
)
|
|
162
184
|
seen_turns = target_turns.setdefault(target_id, set())
|
|
163
185
|
if turn_identity in seen_turns:
|
|
164
|
-
raise
|
|
186
|
+
raise self._preflight_error(
|
|
187
|
+
"batch contains duplicate update target",
|
|
188
|
+
validation_detail="duplicate_update_target",
|
|
189
|
+
)
|
|
165
190
|
seen_turns.add(turn_identity)
|
|
166
191
|
target_ids.add(target_id)
|
|
167
192
|
target = active.get(target_id)
|
|
168
193
|
if target is None:
|
|
169
|
-
raise
|
|
194
|
+
raise self._preflight_error("update target is not an active memory")
|
|
170
195
|
target_memory = getattr(target, "memory", target)
|
|
171
196
|
target_type = (
|
|
172
197
|
target_memory.type
|
|
@@ -176,19 +201,22 @@ class MemoryWriter:
|
|
|
176
201
|
else None
|
|
177
202
|
)
|
|
178
203
|
if target_type != summary.get("type"):
|
|
179
|
-
raise
|
|
204
|
+
raise self._preflight_error(
|
|
205
|
+
"update target type does not match summary",
|
|
206
|
+
validation_detail="invalid_type",
|
|
207
|
+
)
|
|
180
208
|
deterministic_id = request["memory_id"]
|
|
181
209
|
if deterministic_id in memory_ids:
|
|
182
|
-
raise
|
|
210
|
+
raise self._preflight_error("batch contains duplicate deterministic memory id")
|
|
183
211
|
if deterministic_id in duplicate_ids:
|
|
184
|
-
raise
|
|
212
|
+
raise self._preflight_error("batch memory id collides with metadata merge target")
|
|
185
213
|
if deterministic_id in target_ids:
|
|
186
|
-
raise
|
|
214
|
+
raise self._preflight_error("batch memory id collides with update target")
|
|
187
215
|
if target_id is not None and target_id.casefold() == deterministic_id.casefold():
|
|
188
|
-
raise
|
|
216
|
+
raise self._preflight_error("batch memory id collides with update target")
|
|
189
217
|
memory_ids.add(deterministic_id)
|
|
190
218
|
if "/" in deterministic_id or "\\" in deterministic_id or deterministic_id in (".", ".."):
|
|
191
|
-
raise
|
|
219
|
+
raise self._preflight_error("invalid deterministic memory id")
|
|
192
220
|
# Make the request visible to the rest of this same commit batch.
|
|
193
221
|
# This is needed when a later pending turn updates a memory that
|
|
194
222
|
# an earlier pending turn has just created; the real filesystem
|
|
@@ -198,9 +226,9 @@ class MemoryWriter:
|
|
|
198
226
|
else:
|
|
199
227
|
active[deterministic_id] = summary
|
|
200
228
|
if target_ids.intersection(duplicate_ids):
|
|
201
|
-
raise
|
|
229
|
+
raise self._preflight_error("batch update target collides with metadata merge target")
|
|
202
230
|
if memory_ids.intersection(duplicate_ids):
|
|
203
|
-
raise
|
|
231
|
+
raise self._preflight_error("batch memory id collides with metadata merge target")
|
|
204
232
|
|
|
205
233
|
def _core_sources(self, request: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
206
234
|
turn = request["turn"]
|
|
@@ -31,9 +31,12 @@ from .memory_writer import MemoryWriter
|
|
|
31
31
|
from .models import Memory, utc_now
|
|
32
32
|
from .native_index import NativeIndexer
|
|
33
33
|
from .prompts import (
|
|
34
|
+
DUPLICATE_TARGET_CORRECTION,
|
|
34
35
|
JSON_CORRECTION,
|
|
35
36
|
RELATIVE_TIME_CORRECTION,
|
|
36
37
|
GATE_SYSTEM,
|
|
38
|
+
SUMMARY_TARGET_CORRECTION,
|
|
39
|
+
SUMMARY_TYPE_CORRECTION,
|
|
37
40
|
SUMMARIZE_SYSTEM,
|
|
38
41
|
gate_prompt,
|
|
39
42
|
summarize_prompt,
|
|
@@ -473,8 +476,15 @@ class Processor:
|
|
|
473
476
|
@staticmethod
|
|
474
477
|
def _correction_instruction(error: BaseException) -> Optional[str]:
|
|
475
478
|
hint = Processor._safe_correction_hint(error)
|
|
479
|
+
stage = getattr(error, "stage", None)
|
|
480
|
+
if hint == "duplicate_update_target":
|
|
481
|
+
return DUPLICATE_TARGET_CORRECTION
|
|
476
482
|
if hint == "relative_time":
|
|
477
483
|
return RELATIVE_TIME_CORRECTION
|
|
484
|
+
if stage == "summarize" and hint == "invalid_update_target":
|
|
485
|
+
return SUMMARY_TARGET_CORRECTION
|
|
486
|
+
if stage == "summarize" and hint == "invalid_type":
|
|
487
|
+
return SUMMARY_TYPE_CORRECTION
|
|
478
488
|
if hint is not None:
|
|
479
489
|
return f"Previous output violated: {hint}."
|
|
480
490
|
return None
|
|
@@ -1548,6 +1558,13 @@ class Processor:
|
|
|
1548
1558
|
if item.get("native") is not True and isinstance(item.get("memory_id"), str)
|
|
1549
1559
|
]
|
|
1550
1560
|
gate_related_memory_ids = list(related_memory_ids)
|
|
1561
|
+
gate_related_memory_types = {
|
|
1562
|
+
item["memory_id"].casefold(): item["type"]
|
|
1563
|
+
for item in gate_related
|
|
1564
|
+
if isinstance(item, Mapping)
|
|
1565
|
+
and isinstance(item.get("memory_id"), str)
|
|
1566
|
+
and isinstance(item.get("type"), str)
|
|
1567
|
+
}
|
|
1551
1568
|
if scope_fallback is not None:
|
|
1552
1569
|
scoped_records, scope_ambiguous = scope_fallback
|
|
1553
1570
|
if scope_ambiguous and self._single_specific_scope(scope_background):
|
|
@@ -1559,6 +1576,11 @@ class Processor:
|
|
|
1559
1576
|
value.casefold() for value in gate_related_memory_ids
|
|
1560
1577
|
}:
|
|
1561
1578
|
gate_related_memory_ids.append(memory_id)
|
|
1579
|
+
if (
|
|
1580
|
+
isinstance(memory_id, str)
|
|
1581
|
+
and isinstance(entry.get("type"), str)
|
|
1582
|
+
):
|
|
1583
|
+
gate_related_memory_types[memory_id.casefold()] = entry["type"]
|
|
1562
1584
|
scope_registry = self._scope_registry_projection()
|
|
1563
1585
|
with self.service.vault.lock():
|
|
1564
1586
|
validation_scope_registry = self.service.vault.config().get("scopes", {})
|
|
@@ -1620,6 +1642,7 @@ class Processor:
|
|
|
1620
1642
|
raw,
|
|
1621
1643
|
current_event_keys=turn.event_keys,
|
|
1622
1644
|
related_memory_ids=gate_related_memory_ids,
|
|
1645
|
+
related_memory_types=gate_related_memory_types,
|
|
1623
1646
|
),
|
|
1624
1647
|
diagnostic_context={
|
|
1625
1648
|
"source": turn.source,
|
|
@@ -1728,6 +1751,22 @@ class Processor:
|
|
|
1728
1751
|
)
|
|
1729
1752
|
continue
|
|
1730
1753
|
|
|
1754
|
+
gate_update_target = candidate.get("update_memory_id")
|
|
1755
|
+
gate_target_type = None
|
|
1756
|
+
if isinstance(gate_update_target, str):
|
|
1757
|
+
gate_target_key = gate_update_target.casefold()
|
|
1758
|
+
gate_target_type = next(
|
|
1759
|
+
(
|
|
1760
|
+
item.get("type")
|
|
1761
|
+
for item in candidate_related
|
|
1762
|
+
if isinstance(item, Mapping)
|
|
1763
|
+
and isinstance(item.get("memory_id"), str)
|
|
1764
|
+
and item["memory_id"].casefold() == gate_target_key
|
|
1765
|
+
and isinstance(item.get("type"), str)
|
|
1766
|
+
),
|
|
1767
|
+
None,
|
|
1768
|
+
)
|
|
1769
|
+
|
|
1731
1770
|
summary = self._complete_json_stage(
|
|
1732
1771
|
backend,
|
|
1733
1772
|
summarize_prompt(
|
|
@@ -1745,6 +1784,20 @@ class Processor:
|
|
|
1745
1784
|
related_native_ids=candidate_native_ids,
|
|
1746
1785
|
related_memory_ids=candidate_memory_ids,
|
|
1747
1786
|
scope_registry=validation_scope_registry,
|
|
1787
|
+
expected_type=(
|
|
1788
|
+
candidate.get("type")
|
|
1789
|
+
if (
|
|
1790
|
+
gate_update_target is not None
|
|
1791
|
+
and gate_target_type == candidate.get("type")
|
|
1792
|
+
)
|
|
1793
|
+
else None
|
|
1794
|
+
),
|
|
1795
|
+
expected_update_memory_id=gate_update_target,
|
|
1796
|
+
expected_target_type=(
|
|
1797
|
+
gate_target_type
|
|
1798
|
+
if gate_target_type == candidate.get("type")
|
|
1799
|
+
else None
|
|
1800
|
+
),
|
|
1748
1801
|
),
|
|
1749
1802
|
diagnostic_context={
|
|
1750
1803
|
"source": turn.source,
|
|
@@ -1752,7 +1805,6 @@ class Processor:
|
|
|
1752
1805
|
"turn_index": turn.turn_index,
|
|
1753
1806
|
},
|
|
1754
1807
|
)
|
|
1755
|
-
gate_update_target = candidate.get("update_memory_id")
|
|
1756
1808
|
if gate_update_target is not None:
|
|
1757
1809
|
summary_update_target = summary.get("update_memory_id")
|
|
1758
1810
|
if summary_update_target is None:
|
|
@@ -1766,6 +1818,9 @@ class Processor:
|
|
|
1766
1818
|
"summary update target differs from gate target",
|
|
1767
1819
|
validation_detail="invalid_update_target",
|
|
1768
1820
|
)
|
|
1821
|
+
else:
|
|
1822
|
+
summary = dict(summary)
|
|
1823
|
+
summary["update_memory_id"] = gate_update_target
|
|
1769
1824
|
if summary["scopes"] == ["unscoped"] or summary.get("scope_source") == "insufficient_context":
|
|
1770
1825
|
self._defer_candidate(
|
|
1771
1826
|
turn_ref,
|
|
@@ -34,6 +34,11 @@ useful. Related active memories only identify a complete duplicate or valid
|
|
|
34
34
|
state-update target; they are not current evidence and cannot make a non-worthy
|
|
35
35
|
operational/test result worthy. Their item, body, and serialized-character
|
|
36
36
|
context is bounded; ellipses and omissions do not prove a fact is absent.
|
|
37
|
+
Within one gate response, each active memory target may appear at most once
|
|
38
|
+
across all candidates, whether it is named by duplicate_memory_id or
|
|
39
|
+
update_memory_id. If multiple facts share one future-use/update target, merge
|
|
40
|
+
them into one candidate and include all supporting evidence IDs; never emit
|
|
41
|
+
separate candidates for the same target.
|
|
37
42
|
Resolve an indirect reference only to one supplied active memory; otherwise
|
|
38
43
|
return no candidate or leave it unscoped for retry. Never copy a related
|
|
39
44
|
diagnostic/tool failure without a separate user-confirmed business fact, project
|
|
@@ -130,6 +135,10 @@ evidence; they cannot make a rejected operational or test result worthy. Their
|
|
|
130
135
|
item/body/serialized context is bounded; ellipses and omissions do not prove
|
|
131
136
|
absence. Resolve indirect references only from supplied scope and one related
|
|
132
137
|
active memory; otherwise return no candidate or leave it unscoped for retry.
|
|
138
|
+
When the gate candidate supplies update_memory_id, that target is immutable:
|
|
139
|
+
omit update_memory_id or copy that exact active ID, never replace it with a
|
|
140
|
+
different, guessed, native, or historical ID. Keep the summary type identical
|
|
141
|
+
to the gate candidate type and to the active update target type.
|
|
133
142
|
For the same use, preserve still-valid facts, add confirmed progress, replace
|
|
134
143
|
contradictions, and never reduce the memory to the latest operation or create a
|
|
135
144
|
sibling with a new label.
|
|
@@ -211,6 +220,32 @@ RELATIVE_TIME_CORRECTION = (
|
|
|
211
220
|
)
|
|
212
221
|
|
|
213
222
|
|
|
223
|
+
DUPLICATE_TARGET_CORRECTION = (
|
|
224
|
+
"Previous output violated: duplicate_update_target. Merge every candidate "
|
|
225
|
+
"that refers to the same active memory target into one candidate for one "
|
|
226
|
+
"future-use topic. A target may appear only once across duplicate_memory_id "
|
|
227
|
+
"and update_memory_id; preserve all supporting current evidence_event_ids "
|
|
228
|
+
"in that single candidate. Keep update_memory_id only when it is the exact "
|
|
229
|
+
"related active ID, and return no separate sibling candidate for that target. "
|
|
230
|
+
"Return only the strict gate JSON object."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
SUMMARY_TARGET_CORRECTION = (
|
|
235
|
+
"Previous output violated: invalid_update_target. The gate selected the "
|
|
236
|
+
"update target and it is immutable. In the summary, omit update_memory_id "
|
|
237
|
+
"or copy the gate-selected active memory_id exactly; never substitute a "
|
|
238
|
+
"different, guessed, native, or historical ID. Return only the strict JSON object."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
SUMMARY_TYPE_CORRECTION = (
|
|
243
|
+
"Previous output violated: invalid_type. Keep the summary type exactly equal "
|
|
244
|
+
"to the gate candidate type and to the active update target type. Do not "
|
|
245
|
+
"change the target or invent a new type. Return only the strict JSON object."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
214
249
|
COMPACT_SYSTEM = """You are memleaf's memory compactor. Return JSON only.
|
|
215
250
|
Merge only the supplied low-priority memories when they express compatible
|
|
216
251
|
information. Return an object with a memories array; [] is a safe no-op.
|
|
@@ -51,6 +51,7 @@ MODEL_VALIDATION_DETAILS = frozenset(
|
|
|
51
51
|
"unknown_fields",
|
|
52
52
|
"candidate_shape",
|
|
53
53
|
"duplicate_candidate_id",
|
|
54
|
+
"duplicate_update_target",
|
|
54
55
|
"invalid_evidence",
|
|
55
56
|
"invalid_flags",
|
|
56
57
|
"invalid_type",
|
|
@@ -397,6 +398,7 @@ def validate_gate_output(
|
|
|
397
398
|
*,
|
|
398
399
|
current_event_keys: Iterable[Any] | None = None,
|
|
399
400
|
related_memory_ids: Iterable[Any] | None = None,
|
|
401
|
+
related_memory_types: Mapping[str, Any] | None = None,
|
|
400
402
|
) -> dict[str, Any]:
|
|
401
403
|
"""Validate and return a normalized gate object without writing anything."""
|
|
402
404
|
|
|
@@ -426,6 +428,7 @@ def validate_gate_output(
|
|
|
426
428
|
}
|
|
427
429
|
normalized: list[dict[str, Any]] = []
|
|
428
430
|
candidate_ids: set[str] = set()
|
|
431
|
+
target_ids: set[str] = set()
|
|
429
432
|
for candidate in candidates:
|
|
430
433
|
if not isinstance(candidate, Mapping):
|
|
431
434
|
raise ModelOutputError("each gate candidate must be an object", validation_detail="candidate_shape")
|
|
@@ -504,7 +507,32 @@ def validate_gate_output(
|
|
|
504
507
|
"update_memory_id is not a related active memory",
|
|
505
508
|
validation_detail="invalid_update_target",
|
|
506
509
|
)
|
|
510
|
+
target_type = None
|
|
511
|
+
if isinstance(related_memory_types, Mapping):
|
|
512
|
+
target_type = next(
|
|
513
|
+
(
|
|
514
|
+
value
|
|
515
|
+
for key, value in related_memory_types.items()
|
|
516
|
+
if isinstance(key, str) and key.casefold() == update_id.casefold()
|
|
517
|
+
),
|
|
518
|
+
None,
|
|
519
|
+
)
|
|
520
|
+
if target_type is not None and candidate_type != target_type:
|
|
521
|
+
raise ModelOutputError(
|
|
522
|
+
"candidate type does not match update target",
|
|
523
|
+
validation_detail="invalid_type",
|
|
524
|
+
)
|
|
507
525
|
item["update_memory_id"] = update_id
|
|
526
|
+
for target_field in ("duplicate_memory_id", "update_memory_id"):
|
|
527
|
+
target = item.get(target_field)
|
|
528
|
+
if isinstance(target, str):
|
|
529
|
+
target_key = target.casefold()
|
|
530
|
+
if target_key in target_ids:
|
|
531
|
+
raise ModelOutputError(
|
|
532
|
+
"multiple gate candidates reference the same memory target; merge them into one candidate",
|
|
533
|
+
validation_detail="duplicate_update_target",
|
|
534
|
+
)
|
|
535
|
+
target_ids.add(target_key)
|
|
508
536
|
if not isinstance(item["scope_source"], str) or item["scope_source"] not in SCOPE_SOURCES:
|
|
509
537
|
raise ModelOutputError("invalid scope_source", validation_detail="invalid_scope_source")
|
|
510
538
|
_scopes(item["scopes"], item["scope_source"])
|
|
@@ -523,6 +551,7 @@ def parse_gate_output(
|
|
|
523
551
|
*,
|
|
524
552
|
current_event_keys: Iterable[Any] | None = None,
|
|
525
553
|
related_memory_ids: Iterable[Any] | None = None,
|
|
554
|
+
related_memory_types: Mapping[str, Any] | None = None,
|
|
526
555
|
) -> dict[str, Any]:
|
|
527
556
|
try:
|
|
528
557
|
parsed = parse_strict_json(raw)
|
|
@@ -536,6 +565,7 @@ def parse_gate_output(
|
|
|
536
565
|
event_keys,
|
|
537
566
|
current_event_keys=current_event_keys,
|
|
538
567
|
related_memory_ids=related_memory_ids,
|
|
568
|
+
related_memory_types=related_memory_types,
|
|
539
569
|
)
|
|
540
570
|
except ModelOutputError as error:
|
|
541
571
|
if error.validation_reason is None:
|
|
@@ -581,6 +611,9 @@ def validate_summarize_output(
|
|
|
581
611
|
related_native_ids: Iterable[Any] | None = None,
|
|
582
612
|
related_memory_ids: Iterable[Any] | None = None,
|
|
583
613
|
scope_registry: Mapping[str, Any] | None = None,
|
|
614
|
+
expected_type: str | None = None,
|
|
615
|
+
expected_update_memory_id: str | None = None,
|
|
616
|
+
expected_target_type: str | None = None,
|
|
584
617
|
) -> dict[str, Any]:
|
|
585
618
|
"""Validate one atomic memory summary; this function has no filesystem effects."""
|
|
586
619
|
|
|
@@ -615,6 +648,16 @@ def validate_summarize_output(
|
|
|
615
648
|
candidate_type = item["type"]
|
|
616
649
|
if not isinstance(candidate_type, str) or candidate_type not in MEMORY_TYPES:
|
|
617
650
|
raise ModelOutputError("invalid memory type", validation_detail="invalid_type")
|
|
651
|
+
if expected_type is not None and candidate_type != expected_type:
|
|
652
|
+
raise ModelOutputError(
|
|
653
|
+
"summary type differs from gate candidate",
|
|
654
|
+
validation_detail="invalid_type",
|
|
655
|
+
)
|
|
656
|
+
if expected_target_type is not None and candidate_type != expected_target_type:
|
|
657
|
+
raise ModelOutputError(
|
|
658
|
+
"summary type does not match update target",
|
|
659
|
+
validation_detail="invalid_type",
|
|
660
|
+
)
|
|
618
661
|
item["scopes"] = _scopes(item["scopes"], item.get("scope_source"))
|
|
619
662
|
item["scope_operations"] = _scope_operations(
|
|
620
663
|
item.get("scope_operations"),
|
|
@@ -635,6 +678,17 @@ def validate_summarize_output(
|
|
|
635
678
|
item["memory_id"] = _memory_id(item["memory_id"], "memory_id")
|
|
636
679
|
if "update_memory_id" in item:
|
|
637
680
|
item["update_memory_id"] = _memory_id(item["update_memory_id"], "update_memory_id")
|
|
681
|
+
if (
|
|
682
|
+
expected_update_memory_id is not None
|
|
683
|
+
and (
|
|
684
|
+
not isinstance(expected_update_memory_id, str)
|
|
685
|
+
or item["update_memory_id"].casefold() != expected_update_memory_id.casefold()
|
|
686
|
+
)
|
|
687
|
+
):
|
|
688
|
+
raise ModelOutputError(
|
|
689
|
+
"summary update target differs from gate target",
|
|
690
|
+
validation_detail="invalid_update_target",
|
|
691
|
+
)
|
|
638
692
|
if related_memory_ids is not None:
|
|
639
693
|
related_ids = {
|
|
640
694
|
memory_id.casefold()
|
|
@@ -689,6 +743,9 @@ def parse_summarize_output(
|
|
|
689
743
|
related_native_ids: Iterable[Any] | None = None,
|
|
690
744
|
related_memory_ids: Iterable[Any] | None = None,
|
|
691
745
|
scope_registry: Mapping[str, Any] | None = None,
|
|
746
|
+
expected_type: str | None = None,
|
|
747
|
+
expected_update_memory_id: str | None = None,
|
|
748
|
+
expected_target_type: str | None = None,
|
|
692
749
|
) -> dict[str, Any]:
|
|
693
750
|
try:
|
|
694
751
|
parsed = parse_strict_json(raw)
|
|
@@ -703,6 +760,9 @@ def parse_summarize_output(
|
|
|
703
760
|
related_native_ids=related_native_ids,
|
|
704
761
|
related_memory_ids=related_memory_ids,
|
|
705
762
|
scope_registry=scope_registry,
|
|
763
|
+
expected_type=expected_type,
|
|
764
|
+
expected_update_memory_id=expected_update_memory_id,
|
|
765
|
+
expected_target_type=expected_target_type,
|
|
706
766
|
)
|
|
707
767
|
except ModelOutputError as error:
|
|
708
768
|
if error.validation_reason is None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
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.5。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.5 通过 PyPI 分发。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
30
30
|
## 项目定位
|
|
@@ -1717,6 +1717,23 @@ class HermesProviderTests(unittest.TestCase):
|
|
|
1717
1717
|
self.assertIsNotNone(fields)
|
|
1718
1718
|
self.assertEqual(fields[4], "invalid_update_target")
|
|
1719
1719
|
|
|
1720
|
+
def test_duplicate_update_target_validation_detail_is_preserved(self) -> None:
|
|
1721
|
+
value = {
|
|
1722
|
+
"isError": True,
|
|
1723
|
+
"structuredContent": {
|
|
1724
|
+
"error": {
|
|
1725
|
+
"code": "model_invalid_response",
|
|
1726
|
+
"stage": "gate",
|
|
1727
|
+
"validation_reason": "schema_violation",
|
|
1728
|
+
"validation_detail": "duplicate_update_target",
|
|
1729
|
+
"attempt_count": 1,
|
|
1730
|
+
}
|
|
1731
|
+
},
|
|
1732
|
+
}
|
|
1733
|
+
fields = provider_module._mcp_error_fields(value)
|
|
1734
|
+
self.assertIsNotNone(fields)
|
|
1735
|
+
self.assertEqual(fields[4], "duplicate_update_target")
|
|
1736
|
+
|
|
1720
1737
|
def test_relative_time_validation_detail_is_preserved_and_logged(self) -> None:
|
|
1721
1738
|
value = {
|
|
1722
1739
|
"isError": True,
|
|
@@ -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.5", (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:
|