memleaf 0.2.15__tar.gz → 0.2.17__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.15 → memleaf-0.2.17}/CHANGELOG.md +19 -0
- {memleaf-0.2.15/src/memleaf.egg-info → memleaf-0.2.17}/PKG-INFO +3 -3
- {memleaf-0.2.15 → memleaf-0.2.17}/README.en.md +2 -2
- {memleaf-0.2.15 → memleaf-0.2.17}/README.md +2 -2
- {memleaf-0.2.15 → memleaf-0.2.17}/pyproject.toml +1 -1
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/hermes_provider/__init__.py +60 -2
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/processing.py +316 -14
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/prompts.py +16 -0
- {memleaf-0.2.15 → memleaf-0.2.17/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf.egg-info/SOURCES.txt +1 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_admission_noise.py +161 -28
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_hermes_provider.py +76 -5
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_pypi_install.py +5 -5
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_c3_packaging.py +1 -1
- memleaf-0.2.17/tests/test_todo_state_recovery.py +165 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/LICENSE +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/MANIFEST.in +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/examples/README.md +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/examples/basic_usage.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/install.ps1 +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/install.sh +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/setup.cfg +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/config.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/index.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/models.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/service.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/validation.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/__init__.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_extraction_quality_regressions.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_host_events.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_install.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_maintenance_v2.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b1.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b2a.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_update_target_recovery.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.17 — 2026-09-02
|
|
6
|
+
|
|
7
|
+
- Preserve explicit user-confirmed completion or cancellation of one uniquely
|
|
8
|
+
related active todo as an in-place state update, even when the gate omits the
|
|
9
|
+
candidate or the same message asks what remains.
|
|
10
|
+
- Keep todo-state recovery deterministic and bounded: reuse the existing
|
|
11
|
+
memory ID, anchor `completed_at` to the user event, archive the old version,
|
|
12
|
+
and reject queries, negation, future intent, uncertainty, assistant-only
|
|
13
|
+
claims, and ambiguous targets.
|
|
14
|
+
|
|
15
|
+
## 0.2.16 — 2026-09-02
|
|
16
|
+
|
|
17
|
+
- Keep read-only operational queries, derived overdue counters, and one-time
|
|
18
|
+
execution receipts out of permanent-memory extraction; preserve explicitly
|
|
19
|
+
confirmed durable facts and preferences.
|
|
20
|
+
- Deduplicate Hermes tool observations across cumulative conversation payloads,
|
|
21
|
+
preventing historical reads from being re-audited as uncontrolled while
|
|
22
|
+
retaining diagnostics for genuinely mismatched current-turn reads.
|
|
23
|
+
|
|
5
24
|
## 0.2.15 — 2026-09-02
|
|
6
25
|
|
|
7
26
|
- Recover a candidate whose `update_memory_id` has the wrong type at the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.17
|
|
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.17。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.17 通过 PyPI 分发。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
30
30
|
## 项目定位
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
[中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
|
|
6
6
|
|
|
7
|
-
> **Version: 0.2.
|
|
8
|
-
> The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. memleaf 0.2.
|
|
7
|
+
> **Version: 0.2.17.**
|
|
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.17 is distributed through PyPI.
|
|
9
9
|
> **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
|
|
10
10
|
|
|
11
11
|
## Project scope
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
[English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
|
|
6
6
|
|
|
7
|
-
> **当前版本:0.2.
|
|
8
|
-
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.
|
|
7
|
+
> **当前版本:0.2.17。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.17 通过 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.17"
|
|
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
|
|
@@ -49,6 +49,7 @@ _MAX_PENDING_TURN_NUMBERS = 128
|
|
|
49
49
|
_MAX_SESSION_ALIASES = 128
|
|
50
50
|
_MAX_LINEAGE_RETRIES = 2
|
|
51
51
|
_MAX_DEFERRED_PROCESS_SESSIONS = 128
|
|
52
|
+
_MAX_OBSERVED_TOOL_CALL_KEYS = 2048
|
|
52
53
|
_SAFE_COMPONENT_RE = re.compile(r"[^A-Za-z0-9_.-]+")
|
|
53
54
|
_PROVIDER_VERSION_RE = re.compile(r"^version:\s*([^\s#]+)\s*(?:#.*)?$", re.MULTILINE)
|
|
54
55
|
_ASCII_QUERY_TERM_RE = re.compile(r"[a-z0-9]+(?:[ ._-][a-z0-9]+)*")
|
|
@@ -628,6 +629,45 @@ def _tool_result_for_call(
|
|
|
628
629
|
return _CALL_FAILED
|
|
629
630
|
|
|
630
631
|
|
|
632
|
+
def _tool_observation_key(call: Mapping[str, Any], ordinal: int) -> str:
|
|
633
|
+
"""Build a stable in-memory identity for one visible tool call.
|
|
634
|
+
|
|
635
|
+
Hermes can pass the complete conversation back on every turn. The
|
|
636
|
+
retrieval token and result status are turn-relative, so they must not be
|
|
637
|
+
part of the identity: otherwise an old successful read is reclassified as
|
|
638
|
+
``uncontrolled_success`` when a newer token is observed. Call ids are
|
|
639
|
+
stable when Hermes supplies them; the ordinal/arguments fallback keeps
|
|
640
|
+
anonymous calls distinct within the cumulative message list.
|
|
641
|
+
"""
|
|
642
|
+
|
|
643
|
+
arguments = call.get("arguments")
|
|
644
|
+
try:
|
|
645
|
+
arguments_text = json.dumps(arguments, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
646
|
+
except (TypeError, ValueError):
|
|
647
|
+
arguments_text = ""
|
|
648
|
+
call_id = call.get("call_id") if isinstance(call.get("call_id"), str) else ""
|
|
649
|
+
name = call.get("name") if isinstance(call.get("name"), str) else ""
|
|
650
|
+
if call_id:
|
|
651
|
+
identity = f"{name}\x00call-id\x00{call_id}"
|
|
652
|
+
else:
|
|
653
|
+
identity = f"{name}\x00anonymous\x00{ordinal}\x00{arguments_text}"
|
|
654
|
+
return sha256(identity.encode("utf-8")).hexdigest()
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _record_tool_observation(seen: Any, key: str) -> bool:
|
|
658
|
+
"""Record one observation key and report whether it was new."""
|
|
659
|
+
|
|
660
|
+
if seen is None:
|
|
661
|
+
return True
|
|
662
|
+
if key in seen:
|
|
663
|
+
return False
|
|
664
|
+
if isinstance(seen, set):
|
|
665
|
+
seen.add(key)
|
|
666
|
+
else:
|
|
667
|
+
seen[key] = None
|
|
668
|
+
return True
|
|
669
|
+
|
|
670
|
+
|
|
631
671
|
def _hermes_read_status(value: Any) -> str:
|
|
632
672
|
"""Classify a visible read result without inspecting it in diagnostics."""
|
|
633
673
|
|
|
@@ -1125,6 +1165,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1125
1165
|
self._gate_turn_ids: "OrderedDict[Tuple[str, int], str]" = OrderedDict()
|
|
1126
1166
|
self._active_turn_numbers: "OrderedDict[str, int]" = OrderedDict()
|
|
1127
1167
|
self._active_retrieval_ids: "OrderedDict[str, Optional[str]]" = OrderedDict()
|
|
1168
|
+
self._observed_tool_call_keys: "OrderedDict[str, None]" = OrderedDict()
|
|
1128
1169
|
self._version_warning_emitted = False
|
|
1129
1170
|
# Hermes keeps one provider instance alive while compression rotates
|
|
1130
1171
|
# the physical session id. Keep only a bounded alias chain so a
|
|
@@ -1752,6 +1793,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1752
1793
|
if not continuous:
|
|
1753
1794
|
self._pending_lineage.clear()
|
|
1754
1795
|
self._deferred_process_sessions.clear()
|
|
1796
|
+
self._observed_tool_call_keys.clear()
|
|
1755
1797
|
if _as_bool(reset, False) or _as_bool(rewound, False):
|
|
1756
1798
|
self._clear_session_turn_state(old_session_id or next_session_id)
|
|
1757
1799
|
for key in list(self._retrieval_ids_by_turn):
|
|
@@ -1826,6 +1868,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1826
1868
|
self._gate_turn_ids.clear()
|
|
1827
1869
|
self._active_turn_numbers.clear()
|
|
1828
1870
|
self._active_retrieval_ids.clear()
|
|
1871
|
+
self._observed_tool_call_keys.clear()
|
|
1829
1872
|
self._session_aliases.clear()
|
|
1830
1873
|
self._pending_lineage.clear()
|
|
1831
1874
|
self._deferred_process_sessions.clear()
|
|
@@ -2073,6 +2116,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2073
2116
|
session_id: str = "",
|
|
2074
2117
|
turn_id: str = "",
|
|
2075
2118
|
vault_root: Optional[Path] = None,
|
|
2119
|
+
seen_call_keys: Any = None,
|
|
2076
2120
|
) -> str:
|
|
2077
2121
|
"""Observe explicit host MCP calls in public messages.
|
|
2078
2122
|
|
|
@@ -2087,25 +2131,32 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2087
2131
|
results = _visible_tool_results(messages)
|
|
2088
2132
|
statuses: list[str] = []
|
|
2089
2133
|
search_results_used: set[int] = set()
|
|
2134
|
+
search_ordinal = 0
|
|
2090
2135
|
for call in calls:
|
|
2091
2136
|
if call.get("name") != "mcp__memleaf__search":
|
|
2092
2137
|
continue
|
|
2138
|
+
search_ordinal += 1
|
|
2093
2139
|
arguments = call.get("arguments")
|
|
2094
2140
|
if not isinstance(arguments, Mapping) or arguments.get("retrieval_id") != retrieval_id:
|
|
2095
2141
|
continue
|
|
2096
2142
|
payload = _tool_result_for_call(call, calls, results, search_results_used)
|
|
2097
2143
|
if payload is not _CALL_FAILED:
|
|
2144
|
+
status = _hermes_search_status(payload)
|
|
2145
|
+
observation_key = _tool_observation_key(call, search_ordinal)
|
|
2146
|
+
if not _record_tool_observation(seen_call_keys, observation_key):
|
|
2147
|
+
continue
|
|
2098
2148
|
# Hermes has no public write-back hook for this soft observer.
|
|
2099
2149
|
# A valid current-turn result is enough to mark the local
|
|
2100
2150
|
# provider diagnostic; no Core ledger or body is touched here.
|
|
2101
|
-
statuses.append(
|
|
2151
|
+
statuses.append(status)
|
|
2102
2152
|
|
|
2103
2153
|
read_results_used: set[int] = set()
|
|
2104
2154
|
read_sequence = 0
|
|
2155
|
+
read_ordinal = 0
|
|
2105
2156
|
for call in calls:
|
|
2106
2157
|
if call.get("name") != "mcp__memleaf__read":
|
|
2107
2158
|
continue
|
|
2108
|
-
|
|
2159
|
+
read_ordinal += 1
|
|
2109
2160
|
arguments = call.get("arguments")
|
|
2110
2161
|
retrieval_present = isinstance(arguments, Mapping) and "retrieval_id" in arguments
|
|
2111
2162
|
retrieval_match = bool(
|
|
@@ -2120,6 +2171,10 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2120
2171
|
# mismatched gate binding. Do not make that look like a
|
|
2121
2172
|
# controlled success in the diagnostic stream.
|
|
2122
2173
|
result_status = "uncontrolled_success"
|
|
2174
|
+
observation_key = _tool_observation_key(call, read_ordinal)
|
|
2175
|
+
if not _record_tool_observation(seen_call_keys, observation_key):
|
|
2176
|
+
continue
|
|
2177
|
+
read_sequence += 1
|
|
2123
2178
|
logger.info(
|
|
2124
2179
|
"memleaf retrieval-read source=hermes session=%s turn=%s read_seq=%d retrieval_present=%s retrieval_match=%s result=%s",
|
|
2125
2180
|
_safe_component(session_id, "none") if session_id else "none",
|
|
@@ -2286,7 +2341,10 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
2286
2341
|
session_id=effective_session,
|
|
2287
2342
|
turn_id=turn_id,
|
|
2288
2343
|
vault_root=_resolve_vault(self._config()),
|
|
2344
|
+
seen_call_keys=self._observed_tool_call_keys,
|
|
2289
2345
|
)
|
|
2346
|
+
while len(self._observed_tool_call_keys) > _MAX_OBSERVED_TOOL_CALL_KEYS:
|
|
2347
|
+
self._observed_tool_call_keys.popitem(last=False)
|
|
2290
2348
|
self._last_retrieval_observation = observation
|
|
2291
2349
|
lineage_ready = self._retry_pending_lineage(effective_session)
|
|
2292
2350
|
for role, content in visible_events:
|
|
@@ -87,6 +87,214 @@ _DIAGNOSTIC_GATE_ALLOWED = frozenset(("candidates",))
|
|
|
87
87
|
_DIAGNOSTIC_CANDIDATE_REQUIRED = frozenset(
|
|
88
88
|
("candidate_id", "memory", "evidence_event_ids", "duplicate", "worth", "type", "scopes", "scope_source")
|
|
89
89
|
)
|
|
90
|
+
|
|
91
|
+
_READ_ONLY_QUERY_MARKERS = (
|
|
92
|
+
"有没有", "有哪些", "是什么", "是谁", "多少", "查询", "查一下", "查下",
|
|
93
|
+
"看看", "查看", "汇总", "列出", "告诉我", "what", "which", "who", "show me",
|
|
94
|
+
)
|
|
95
|
+
_USER_ASSERTION_MARKERS = (
|
|
96
|
+
"已确认", "确认了", "已决定", "我决定", "改为", "更新为", "变更为", "以后",
|
|
97
|
+
"今后", "约定", "我的偏好", "我喜欢", "必须", "要求是", "截止日期为",
|
|
98
|
+
"负责人是", "负责人为",
|
|
99
|
+
)
|
|
100
|
+
_DERIVED_OVERDUE_RE = re.compile(
|
|
101
|
+
r"(?:逾期|超期|overdue)\s*\d+(?:\.\d+)?\s*(?:天|日|days?)",
|
|
102
|
+
re.IGNORECASE,
|
|
103
|
+
)
|
|
104
|
+
_EXECUTION_RECEIPT_RE = re.compile(
|
|
105
|
+
r"(?:核验归档|归档核验|服务器已接受(?:提交|投递)|server accepted)",
|
|
106
|
+
re.IGNORECASE,
|
|
107
|
+
)
|
|
108
|
+
_TODO_COMPLETION_RE = re.compile(
|
|
109
|
+
r"(?:完成|做完|办完|搞定|处理完|closed|completed|finished|done)",
|
|
110
|
+
re.IGNORECASE,
|
|
111
|
+
)
|
|
112
|
+
_TODO_CANCEL_RE = re.compile(
|
|
113
|
+
r"(?:取消|作废|撤销|不做|不用做|不需要做|cancel(?:led|ed)?|abort(?:ed)?)",
|
|
114
|
+
re.IGNORECASE,
|
|
115
|
+
)
|
|
116
|
+
_TODO_NEGATION_MARKERS = (
|
|
117
|
+
"还没", "没有", "未", "尚未", "没", "not", "never", "will", "would", "准备", "打算", "计划", "将要", "会", "要",
|
|
118
|
+
)
|
|
119
|
+
_TODO_UNCERTAINTY_MARKERS = (
|
|
120
|
+
"应该", "可能", "大概", "似乎", "不确定", "也许", "好像", "probably", "perhaps", "maybe", "seems", "not sure",
|
|
121
|
+
)
|
|
122
|
+
_TODO_CONFIRMATION_RE = re.compile(
|
|
123
|
+
r"(?:对吗|对不对|是吗|正确吗|是不是|是否(?=(?:已|已经|完成|正确|这样|发|做|弄)))"
|
|
124
|
+
)
|
|
125
|
+
_TODO_QUERY_SUFFIXES = ("吗", "么", "?", "?", "呢")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _explicit_todo_state_change(
|
|
129
|
+
events: Iterable[Mapping[str, Any]],
|
|
130
|
+
) -> tuple[str, list[str], str] | None:
|
|
131
|
+
"""Detect one explicit user todo completion/cancellation declaration.
|
|
132
|
+
|
|
133
|
+
This intentionally reads user events only. A completion marker is valid
|
|
134
|
+
only when it is declarative (not negated, future, or a question), so an
|
|
135
|
+
assistant recap or a trailing "what remains?" cannot manufacture a state
|
|
136
|
+
transition.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
states: list[tuple[str, str, str]] = []
|
|
140
|
+
for event in events:
|
|
141
|
+
if str(event.get("role", "")).casefold() != "user":
|
|
142
|
+
continue
|
|
143
|
+
content = event.get("content")
|
|
144
|
+
event_key_value = event.get("event_key")
|
|
145
|
+
timestamp = event.get("timestamp")
|
|
146
|
+
if not isinstance(content, str) or not content.strip():
|
|
147
|
+
continue
|
|
148
|
+
if not isinstance(event_key_value, str) or not event_key_value:
|
|
149
|
+
continue
|
|
150
|
+
if not isinstance(timestamp, str) or _parse_time(timestamp) is None:
|
|
151
|
+
continue
|
|
152
|
+
folded = content.casefold()
|
|
153
|
+
event_states: set[str] = set()
|
|
154
|
+
for marker, state in (
|
|
155
|
+
(_TODO_COMPLETION_RE, "completed"),
|
|
156
|
+
(_TODO_CANCEL_RE, "cancelled"),
|
|
157
|
+
):
|
|
158
|
+
for match in marker.finditer(folded):
|
|
159
|
+
start, end = match.span()
|
|
160
|
+
prefix = folded[max(0, start - 8) : start]
|
|
161
|
+
suffix = folded[end : end + 4]
|
|
162
|
+
if any(value in prefix for value in _TODO_NEGATION_MARKERS + _TODO_UNCERTAINTY_MARKERS):
|
|
163
|
+
continue
|
|
164
|
+
if any(suffix.startswith(value) for value in _TODO_QUERY_SUFFIXES) or re.match(
|
|
165
|
+
r"^(?:了|啦)?(?:吗|么|呢|[??])", suffix
|
|
166
|
+
):
|
|
167
|
+
continue
|
|
168
|
+
# Confirmation questions may place the question marker after
|
|
169
|
+
# a comma ("已经完成了,对吗?"). Keep a follow-up about
|
|
170
|
+
# other work eligible; only direct confirmation wording is
|
|
171
|
+
# rejected here.
|
|
172
|
+
if _TODO_CONFIRMATION_RE.search(prefix) or _TODO_CONFIRMATION_RE.search(folded[end:]):
|
|
173
|
+
continue
|
|
174
|
+
# A bare English "done"/"completed" is declarative. For
|
|
175
|
+
# Chinese verbs require either a completion particle or an
|
|
176
|
+
# explicit completed-state prefix to avoid matching a future
|
|
177
|
+
# action such as "完成计划".
|
|
178
|
+
if state in {"completed", "cancelled"} and not (
|
|
179
|
+
any(value in prefix for value in ("已", "已经", "刚刚", "刚才", "i ", "i've", "has ", "was "))
|
|
180
|
+
or suffix.startswith(("了", "啦", ".", "!", "!", ",", ",", ";", ";"))
|
|
181
|
+
or match.group(0).casefold() in {"completed", "finished", "done", "closed", "cancelled", "canceled", "aborted"}
|
|
182
|
+
):
|
|
183
|
+
continue
|
|
184
|
+
event_states.add(state)
|
|
185
|
+
break
|
|
186
|
+
states.extend((state, event_key_value, timestamp) for state in sorted(event_states))
|
|
187
|
+
|
|
188
|
+
if not states:
|
|
189
|
+
return None
|
|
190
|
+
distinct_states = {state for state, _event_key_value, _timestamp in states}
|
|
191
|
+
if len(distinct_states) != 1:
|
|
192
|
+
return None
|
|
193
|
+
state = states[0][0]
|
|
194
|
+
evidence_event_ids = list(dict.fromkeys(item[1] for item in states))
|
|
195
|
+
# Preserve the event timestamp's absolute instant as completed_at. The
|
|
196
|
+
# parser accepts ISO-8601 strings; canonical UTC avoids offset ambiguity.
|
|
197
|
+
anchor = _parse_time(states[-1][2])
|
|
198
|
+
if anchor is None:
|
|
199
|
+
return None
|
|
200
|
+
completed_at = anchor.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
201
|
+
return state, evidence_event_ids, completed_at
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _automatic_read_only_query(events: Iterable[Mapping[str, Any]]) -> bool:
|
|
205
|
+
"""Return true when the user only asks for information in this turn."""
|
|
206
|
+
|
|
207
|
+
user_text = "\n".join(
|
|
208
|
+
str(event.get("content", ""))
|
|
209
|
+
for event in events
|
|
210
|
+
if str(event.get("role", "")).casefold() == "user"
|
|
211
|
+
).strip()
|
|
212
|
+
if not user_text:
|
|
213
|
+
return False
|
|
214
|
+
folded = user_text.casefold()
|
|
215
|
+
query_prefix = re.sub(r"^(?:请|帮我|麻烦|请帮我)\s*", "", folded)
|
|
216
|
+
asks = "?" in user_text or "?" in user_text or any(
|
|
217
|
+
query_prefix.startswith(marker) or query_prefix.startswith(f"我{marker}")
|
|
218
|
+
for marker in _READ_ONLY_QUERY_MARKERS
|
|
219
|
+
)
|
|
220
|
+
if not asks:
|
|
221
|
+
return False
|
|
222
|
+
return not any(marker in folded for marker in _USER_ASSERTION_MARKERS)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _automatic_transient_memory(value: Any) -> bool:
|
|
226
|
+
"""Reject volatile counters and one-off execution receipts at write time."""
|
|
227
|
+
|
|
228
|
+
if not isinstance(value, str):
|
|
229
|
+
return False
|
|
230
|
+
return bool(_DERIVED_OVERDUE_RE.search(value) or _EXECUTION_RECEIPT_RE.search(value))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _todo_state_recovery_candidate(
|
|
234
|
+
events: Iterable[Mapping[str, Any]],
|
|
235
|
+
related: Iterable[Mapping[str, Any]],
|
|
236
|
+
) -> tuple[dict[str, Any], str, str, str] | None:
|
|
237
|
+
"""Recover a missed todo transition from one explicit user declaration.
|
|
238
|
+
|
|
239
|
+
This is deliberately narrower than general admission: it only considers
|
|
240
|
+
active todo records already returned by the bounded related-memory query,
|
|
241
|
+
and proceeds only when exactly one record is lexically relevant to the
|
|
242
|
+
user's own completion/cancellation statement.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
state_change = _explicit_todo_state_change(events)
|
|
246
|
+
if state_change is None:
|
|
247
|
+
return None
|
|
248
|
+
state, evidence_event_ids, completed_at = state_change
|
|
249
|
+
user_text = "\n".join(
|
|
250
|
+
str(event.get("content", ""))
|
|
251
|
+
for event in events
|
|
252
|
+
if str(event.get("role", "")).casefold() == "user"
|
|
253
|
+
).strip()
|
|
254
|
+
matches: list[Memory] = []
|
|
255
|
+
seen_ids: set[str] = set()
|
|
256
|
+
for item in related:
|
|
257
|
+
if not isinstance(item, Mapping) or item.get("native") is True:
|
|
258
|
+
continue
|
|
259
|
+
if item.get("type") != "todo" or item.get("status") in {"completed", "cancelled"}:
|
|
260
|
+
continue
|
|
261
|
+
memory_id = item.get("memory_id")
|
|
262
|
+
if not isinstance(memory_id, str) or not memory_id or memory_id.casefold() in seen_ids:
|
|
263
|
+
continue
|
|
264
|
+
try:
|
|
265
|
+
memory = Memory.from_mapping(item)
|
|
266
|
+
except (TypeError, ValueError):
|
|
267
|
+
continue
|
|
268
|
+
if not candidate_matches_query(memory, user_text):
|
|
269
|
+
continue
|
|
270
|
+
seen_ids.add(memory_id.casefold())
|
|
271
|
+
matches.append(memory)
|
|
272
|
+
if len(matches) != 1:
|
|
273
|
+
return None
|
|
274
|
+
target = matches[0]
|
|
275
|
+
candidate_id_material = "|".join(
|
|
276
|
+
[target.memory_id, state, *sorted(item.casefold() for item in evidence_event_ids)]
|
|
277
|
+
)
|
|
278
|
+
candidate_id = "todo-state-" + hashlib.sha256(candidate_id_material.encode("utf-8")).hexdigest()[:16]
|
|
279
|
+
scopes = list(target.scopes) or ["global"]
|
|
280
|
+
scope_source = target.scope_source
|
|
281
|
+
if scope_source not in {"model", "user", "session_context", "insufficient_context"}:
|
|
282
|
+
scope_source = "model"
|
|
283
|
+
status_word = "completed" if state == "completed" else "cancelled"
|
|
284
|
+
candidate = {
|
|
285
|
+
"candidate_id": candidate_id,
|
|
286
|
+
"memory": f"{target.title} 用户已明确{status_word}。",
|
|
287
|
+
"evidence_event_ids": evidence_event_ids,
|
|
288
|
+
"duplicate": False,
|
|
289
|
+
"worth": True,
|
|
290
|
+
"type": "todo",
|
|
291
|
+
"scopes": scopes,
|
|
292
|
+
"scope_source": scope_source,
|
|
293
|
+
"update_memory_id": target.memory_id,
|
|
294
|
+
}
|
|
295
|
+
return candidate, state, target.memory_id, completed_at
|
|
296
|
+
|
|
297
|
+
|
|
90
298
|
_DIAGNOSTIC_CANDIDATE_ALLOWED = _DIAGNOSTIC_CANDIDATE_REQUIRED | frozenset(
|
|
91
299
|
("reason", "duplicate_memory_id", "update_memory_id")
|
|
92
300
|
)
|
|
@@ -2266,11 +2474,72 @@ class Processor:
|
|
|
2266
2474
|
"turn_index": turn.turn_index,
|
|
2267
2475
|
},
|
|
2268
2476
|
)
|
|
2477
|
+
# A model may correctly treat the rest of a mixed turn as a query and
|
|
2478
|
+
# still miss the user's explicit completion update. Recover only one
|
|
2479
|
+
# uniquely related active todo; all other gate decisions stay intact.
|
|
2480
|
+
todo_state_recovery = _todo_state_recovery_candidate(events, related)
|
|
2481
|
+
recovery_by_candidate: dict[str, tuple[str, str, str]] = {}
|
|
2482
|
+
if todo_state_recovery is not None:
|
|
2483
|
+
recovery_candidate, recovery_state, recovery_target_id, recovery_completed_at = todo_state_recovery
|
|
2484
|
+
matching_index = next(
|
|
2485
|
+
(
|
|
2486
|
+
index
|
|
2487
|
+
for index, item in enumerate(gate["candidates"])
|
|
2488
|
+
if isinstance(item, Mapping)
|
|
2489
|
+
and (
|
|
2490
|
+
item.get("update_memory_id") == recovery_target_id
|
|
2491
|
+
or item.get("duplicate_memory_id") == recovery_target_id
|
|
2492
|
+
)
|
|
2493
|
+
),
|
|
2494
|
+
None,
|
|
2495
|
+
)
|
|
2496
|
+
if matching_index is not None:
|
|
2497
|
+
existing_candidate = gate["candidates"][matching_index]
|
|
2498
|
+
if (
|
|
2499
|
+
isinstance(existing_candidate, Mapping)
|
|
2500
|
+
and existing_candidate.get("update_memory_id") == recovery_target_id
|
|
2501
|
+
and existing_candidate.get("type") == "todo"
|
|
2502
|
+
and existing_candidate.get("worth") is True
|
|
2503
|
+
and existing_candidate.get("duplicate") is False
|
|
2504
|
+
):
|
|
2505
|
+
recovery_by_candidate[str(existing_candidate["candidate_id"]).casefold()] = (
|
|
2506
|
+
recovery_state,
|
|
2507
|
+
recovery_target_id,
|
|
2508
|
+
recovery_completed_at,
|
|
2509
|
+
)
|
|
2510
|
+
else:
|
|
2511
|
+
# A duplicate or mismatched candidate cannot represent a
|
|
2512
|
+
# state transition. Replace only that target's candidate.
|
|
2513
|
+
gate["candidates"] = [
|
|
2514
|
+
item
|
|
2515
|
+
for index, item in enumerate(gate["candidates"])
|
|
2516
|
+
if index != matching_index
|
|
2517
|
+
]
|
|
2518
|
+
gate["candidates"].append(recovery_candidate)
|
|
2519
|
+
recovery_by_candidate[recovery_candidate["candidate_id"].casefold()] = (
|
|
2520
|
+
recovery_state,
|
|
2521
|
+
recovery_target_id,
|
|
2522
|
+
recovery_completed_at,
|
|
2523
|
+
)
|
|
2524
|
+
else:
|
|
2525
|
+
gate["candidates"].append(recovery_candidate)
|
|
2526
|
+
recovery_by_candidate[recovery_candidate["candidate_id"].casefold()] = (
|
|
2527
|
+
recovery_state,
|
|
2528
|
+
recovery_target_id,
|
|
2529
|
+
recovery_completed_at,
|
|
2530
|
+
)
|
|
2269
2531
|
requests: list[dict[str, Any]] = []
|
|
2270
2532
|
observed_scopes: list[str] = []
|
|
2533
|
+
read_only_query = _automatic_read_only_query(events)
|
|
2271
2534
|
for candidate in gate["candidates"]:
|
|
2272
2535
|
candidate_id_key = str(candidate.get("candidate_id", "")).casefold()
|
|
2536
|
+
recovery = recovery_by_candidate.get(candidate_id_key)
|
|
2273
2537
|
detached_update_target_id = detached_update_target_ids.get(candidate_id_key)
|
|
2538
|
+
if candidate.get("worth") and (
|
|
2539
|
+
(read_only_query and recovery is None)
|
|
2540
|
+
or _automatic_transient_memory(candidate.get("memory"))
|
|
2541
|
+
):
|
|
2542
|
+
continue
|
|
2274
2543
|
# A combined mailbox/daily digest is not an atomic memory. If a
|
|
2275
2544
|
# concrete action was worth retaining, the gate must emit it as
|
|
2276
2545
|
# its own candidate; the aggregate shell itself is NO_CHANGE.
|
|
@@ -2359,6 +2628,15 @@ class Processor:
|
|
|
2359
2628
|
else None
|
|
2360
2629
|
),
|
|
2361
2630
|
)
|
|
2631
|
+
if recovery is not None:
|
|
2632
|
+
recovery_target = self._active_memory_by_id(recovery[1])
|
|
2633
|
+
if recovery_target is not None and not any(
|
|
2634
|
+
isinstance(item, Mapping)
|
|
2635
|
+
and isinstance(item.get("memory_id"), str)
|
|
2636
|
+
and item["memory_id"].casefold() == recovery_target.memory_id.casefold()
|
|
2637
|
+
for item in candidate_related
|
|
2638
|
+
):
|
|
2639
|
+
candidate_related = [recovery_target.to_dict(), *candidate_related]
|
|
2362
2640
|
if detached_update_target_id is not None:
|
|
2363
2641
|
candidate_related = [
|
|
2364
2642
|
item
|
|
@@ -2513,18 +2791,24 @@ class Processor:
|
|
|
2513
2791
|
)
|
|
2514
2792
|
|
|
2515
2793
|
try:
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2794
|
+
def parse_summary(raw: str) -> dict[str, Any]:
|
|
2795
|
+
if recovery is not None and isinstance(raw, str):
|
|
2796
|
+
# Complete/cancel state is deterministic evidence from
|
|
2797
|
+
# the user event. Inject it before strict validation so
|
|
2798
|
+
# a model that omits status/completed_at cannot leave
|
|
2799
|
+
# the update active or fail the required-field check.
|
|
2800
|
+
parsed_raw = parse_strict_json(raw)
|
|
2801
|
+
if isinstance(parsed_raw, Mapping) and "decision" not in parsed_raw:
|
|
2802
|
+
state_change, target_id, completed_at = recovery
|
|
2803
|
+
parsed_raw = dict(parsed_raw)
|
|
2804
|
+
parsed_raw["update_memory_id"] = target_id
|
|
2805
|
+
parsed_raw["status"] = state_change
|
|
2806
|
+
if state_change == "completed":
|
|
2807
|
+
parsed_raw["completed_at"] = completed_at
|
|
2808
|
+
else:
|
|
2809
|
+
parsed_raw.pop("completed_at", None)
|
|
2810
|
+
raw = json.dumps(parsed_raw, ensure_ascii=False, separators=(",", ":"))
|
|
2811
|
+
parsed = parse_summarize_output(
|
|
2528
2812
|
_normalize_summary_dates(raw, turn, candidate),
|
|
2529
2813
|
current_event_keys=turn.event_keys,
|
|
2530
2814
|
related_native_ids=candidate_native_ids,
|
|
@@ -2532,15 +2816,29 @@ class Processor:
|
|
|
2532
2816
|
scope_registry=validation_scope_registry,
|
|
2533
2817
|
expected_scopes=candidate["scopes"],
|
|
2534
2818
|
expected_scope_source=candidate["scope_source"],
|
|
2535
|
-
allow_no_change=
|
|
2819
|
+
allow_no_change=recovery is None,
|
|
2536
2820
|
# The summarize stage may not reinterpret a gate
|
|
2537
|
-
# candidate, including CREATE candidates.
|
|
2821
|
+
# candidate, including CREATE candidates. Updates
|
|
2538
2822
|
# additionally retain the active target's immutable
|
|
2539
2823
|
# type below.
|
|
2540
2824
|
expected_type=candidate.get("type"),
|
|
2541
2825
|
expected_update_memory_id=gate_update_target,
|
|
2542
2826
|
expected_target_type=gate_target_type,
|
|
2827
|
+
)
|
|
2828
|
+
return parsed
|
|
2829
|
+
|
|
2830
|
+
summary = self._complete_json_stage(
|
|
2831
|
+
backend,
|
|
2832
|
+
summarize_prompt(
|
|
2833
|
+
candidate,
|
|
2834
|
+
events,
|
|
2835
|
+
related_memories=candidate_related,
|
|
2836
|
+
scope_background=candidate_scope_background,
|
|
2837
|
+
scope_registry=scope_registry,
|
|
2543
2838
|
),
|
|
2839
|
+
system=SUMMARIZE_SYSTEM,
|
|
2840
|
+
purpose="summarize",
|
|
2841
|
+
parser=parse_summary,
|
|
2544
2842
|
diagnostic_context={
|
|
2545
2843
|
"source": turn.source,
|
|
2546
2844
|
"session_id": turn.session_id,
|
|
@@ -2566,6 +2864,10 @@ class Processor:
|
|
|
2566
2864
|
f"{summary.get('title', '')}\n{summary.get('body', '')}"
|
|
2567
2865
|
):
|
|
2568
2866
|
continue
|
|
2867
|
+
if _automatic_transient_memory(
|
|
2868
|
+
f"{summary.get('title', '')}\n{summary.get('body', '')}"
|
|
2869
|
+
):
|
|
2870
|
+
continue
|
|
2569
2871
|
if gate_update_target is not None:
|
|
2570
2872
|
summary_update_target = summary.get("update_memory_id")
|
|
2571
2873
|
if summary_update_target is None:
|
|
@@ -84,6 +84,15 @@ active memory, return candidates=[]; never set duplicate_memory_id or
|
|
|
84
84
|
update_memory_id merely to record the query or append its source. A query turn
|
|
85
85
|
that also contains a newly confirmed fact or state change remains eligible for
|
|
86
86
|
that new information only.
|
|
87
|
+
An explicit user statement that a related active todo is now completed or
|
|
88
|
+
cancelled is a durable state change for the same future use, even when the
|
|
89
|
+
statement also asks what remains. Emit one worthy todo UPDATE for the exact
|
|
90
|
+
related active memory_id, keep type=todo, and make the candidate memory text
|
|
91
|
+
state that the todo is completed or cancelled. The summary must set the
|
|
92
|
+
corresponding status field; for status=completed it must also set completed_at
|
|
93
|
+
from the supporting event timestamp. Do not treat "完成了吗?"/"是否完成" as completion, and do not
|
|
94
|
+
infer completion from assistant-only text, a future promise ("我会完成"), or a
|
|
95
|
+
negative statement ("还没完成").
|
|
87
96
|
An explicit user request to remember, keep, or use a concrete object bypasses
|
|
88
97
|
the worth test only in the actual explicit remember API path, and only for its
|
|
89
98
|
requested content. In the automatic capture/process path, a request to invoke or test the remember tool
|
|
@@ -237,6 +246,13 @@ confirmed change, retain the existing memory unchanged (the gate should emit
|
|
|
237
246
|
worth=false) rather than creating a sibling. When the current evidence is a
|
|
238
247
|
customer/user request that is not confirmed implemented, state it as
|
|
239
248
|
requested/proposed/pending and never as completed or deployed.
|
|
249
|
+
When the current user explicitly confirms that a related active todo was
|
|
250
|
+
completed or cancelled, this is a state update rather than a read-only query.
|
|
251
|
+
Preserve the todo's same memory_id and type, set status=completed or cancelled,
|
|
252
|
+
and for completed set completed_at to the timestamp of the supporting user
|
|
253
|
+
event. A later question in the same user message does not erase that update.
|
|
254
|
+
Never infer this transition from assistant-only text, "完成了吗?", "还没完成",
|
|
255
|
+
or a future promise such as "准备完成"/"我会完成".
|
|
240
256
|
fields and JSON types are title (string), body (string), tags (string list),
|
|
241
257
|
type (one of preference, fact, project, todo, event, identity, other), scopes
|
|
242
258
|
(non-empty string list), and sources (non-empty object list). scope_source, if
|