memleaf 0.2.11__tar.gz → 0.2.12__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.11 → memleaf-0.2.12}/CHANGELOG.md +14 -0
- {memleaf-0.2.11/src/memleaf.egg-info → memleaf-0.2.12}/PKG-INFO +3 -3
- {memleaf-0.2.11 → memleaf-0.2.12}/README.en.md +2 -2
- {memleaf-0.2.11 → memleaf-0.2.12}/README.md +2 -2
- {memleaf-0.2.11 → memleaf-0.2.12}/pyproject.toml +1 -1
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/processing.py +119 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/prompts.py +17 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/validation.py +54 -0
- {memleaf-0.2.11 → memleaf-0.2.12/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_admission_noise.py +53 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_hermes_provider.py +4 -4
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_pypi_install.py +5 -5
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_b1.py +48 -1
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_b2a.py +187 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_c3_packaging.py +1 -1
- {memleaf-0.2.11 → memleaf-0.2.12}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/LICENSE +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/MANIFEST.in +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/examples/README.md +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/examples/basic_usage.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/install.ps1 +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/install.sh +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/setup.cfg +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/config.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/index.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/models.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/service.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf.egg-info/SOURCES.txt +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/__init__.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_host_events.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_install.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_maintenance_v2.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.11 → memleaf-0.2.12}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.12 — 2026-09-02
|
|
6
|
+
|
|
7
|
+
- Remove empty ASCII or Chinese parentheses left after an absolute date is
|
|
8
|
+
normalized, so permanent memory never stores values such as
|
|
9
|
+
`2026-09-03()`.
|
|
10
|
+
- Reconcile a same-scoped implementation-plan candidate with its unique active
|
|
11
|
+
project-plan memory even when the model labels the candidate as a fact or
|
|
12
|
+
omits `update_memory_id`; preserve the target's immutable type and history.
|
|
13
|
+
- Exclude sent-mail, attachment, archive, and meeting records from automatic
|
|
14
|
+
plan-target inference, and defer instead of guessing when multiple project
|
|
15
|
+
plan targets remain.
|
|
16
|
+
- Reject combined mailbox or daily-report count/status digests as permanent
|
|
17
|
+
memory while keeping separately emitted atomic actions eligible.
|
|
18
|
+
|
|
5
19
|
## 0.2.11 — 2026-09-02
|
|
6
20
|
|
|
7
21
|
- Document one complete macOS/Linux upgrade command that upgrades the core
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.12
|
|
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.12。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.12 通过 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.12.**
|
|
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.12 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.12。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.12 通过 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.12"
|
|
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
|
|
@@ -64,6 +64,7 @@ from .validation import (
|
|
|
64
64
|
parse_strict_json,
|
|
65
65
|
parse_summarize_output,
|
|
66
66
|
normalize_relative_calendar_text,
|
|
67
|
+
is_aggregate_operational_text,
|
|
67
68
|
)
|
|
68
69
|
from .vault import safe_component
|
|
69
70
|
|
|
@@ -1682,6 +1683,114 @@ class Processor:
|
|
|
1682
1683
|
# parser has already limited the target to an active related memory.
|
|
1683
1684
|
return candidate_scope_source in {"user", "session_context"}
|
|
1684
1685
|
|
|
1686
|
+
@staticmethod
|
|
1687
|
+
def _project_scope_keys(scopes: Any) -> set[str]:
|
|
1688
|
+
if not isinstance(scopes, list):
|
|
1689
|
+
return set()
|
|
1690
|
+
return {
|
|
1691
|
+
scope.casefold()
|
|
1692
|
+
for scope in scopes
|
|
1693
|
+
if isinstance(scope, str) and scope.startswith("project:")
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
@staticmethod
|
|
1697
|
+
def _is_project_plan_title(value: Any) -> bool:
|
|
1698
|
+
text = normalize_term(value) if isinstance(value, str) else ""
|
|
1699
|
+
return any(
|
|
1700
|
+
marker in text
|
|
1701
|
+
for marker in ("实施计划", "项目计划", "implementation plan", "project plan")
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
@staticmethod
|
|
1705
|
+
def _is_adjacent_plan_record(value: Any) -> bool:
|
|
1706
|
+
text = normalize_term(value) if isinstance(value, str) else ""
|
|
1707
|
+
return any(
|
|
1708
|
+
marker in text
|
|
1709
|
+
for marker in (
|
|
1710
|
+
"已发送", "发送", "邮件", "附件", "存档", "会议", "启动会", "纪要",
|
|
1711
|
+
"sent", "email", "mail", "attachment", "archive", "meeting", "minutes",
|
|
1712
|
+
)
|
|
1713
|
+
)
|
|
1714
|
+
|
|
1715
|
+
def _infer_update_target(
|
|
1716
|
+
self,
|
|
1717
|
+
candidate: Mapping[str, Any],
|
|
1718
|
+
related: Iterable[Mapping[str, Any]],
|
|
1719
|
+
) -> dict[str, Any]:
|
|
1720
|
+
"""Reuse one unambiguously matching project plan/constraint memory.
|
|
1721
|
+
|
|
1722
|
+
The gate remains authoritative when it names a target. This narrow
|
|
1723
|
+
fallback handles a common model omission: a project-plan candidate is
|
|
1724
|
+
emitted as a new ``fact`` without ``update_memory_id`` even though the
|
|
1725
|
+
same scoped plan is already active. The active target's type is the
|
|
1726
|
+
canonical type; it is copied before summary validation so the target
|
|
1727
|
+
type invariant remains enforced.
|
|
1728
|
+
"""
|
|
1729
|
+
|
|
1730
|
+
result = dict(candidate)
|
|
1731
|
+
if (
|
|
1732
|
+
not result.get("worth")
|
|
1733
|
+
or result.get("duplicate")
|
|
1734
|
+
or any(result.get(field) for field in ("duplicate_memory_id", "update_memory_id"))
|
|
1735
|
+
or not isinstance(result.get("memory"), str)
|
|
1736
|
+
):
|
|
1737
|
+
return result
|
|
1738
|
+
if result.get("type") not in {"fact", "project"}:
|
|
1739
|
+
return result
|
|
1740
|
+
if not self._is_project_plan_title(result["memory"]):
|
|
1741
|
+
return result
|
|
1742
|
+
project_keys = self._project_scope_keys(result.get("scopes"))
|
|
1743
|
+
if len(project_keys) != 1:
|
|
1744
|
+
return result
|
|
1745
|
+
|
|
1746
|
+
project_matches: list[Memory] = []
|
|
1747
|
+
fact_matches: list[Memory] = []
|
|
1748
|
+
seen: set[str] = set()
|
|
1749
|
+
for item in related:
|
|
1750
|
+
if not isinstance(item, Mapping) or item.get("native") is True:
|
|
1751
|
+
continue
|
|
1752
|
+
memory_id = item.get("memory_id")
|
|
1753
|
+
item_type = item.get("type")
|
|
1754
|
+
item_scopes = item.get("scopes")
|
|
1755
|
+
if (
|
|
1756
|
+
not isinstance(memory_id, str)
|
|
1757
|
+
or not isinstance(item_type, str)
|
|
1758
|
+
or item_type not in {"fact", "project"}
|
|
1759
|
+
or self._project_scope_keys(item_scopes) != project_keys
|
|
1760
|
+
or memory_id.casefold() in seen
|
|
1761
|
+
or not self._is_project_plan_title(item.get("title"))
|
|
1762
|
+
or self._is_adjacent_plan_record(item.get("title"))
|
|
1763
|
+
):
|
|
1764
|
+
continue
|
|
1765
|
+
try:
|
|
1766
|
+
target = Memory.from_mapping(item)
|
|
1767
|
+
except (TypeError, ValueError):
|
|
1768
|
+
continue
|
|
1769
|
+
seen.add(memory_id.casefold())
|
|
1770
|
+
if target.type == "project":
|
|
1771
|
+
project_matches.append(target)
|
|
1772
|
+
else:
|
|
1773
|
+
fact_matches.append(target)
|
|
1774
|
+
|
|
1775
|
+
# Prefer a project target over a same-topic fact. If there are
|
|
1776
|
+
# multiple project targets, defer rather than creating a sibling by
|
|
1777
|
+
# guessing; the same conservative rule applies when only multiple
|
|
1778
|
+
# durable fact targets remain.
|
|
1779
|
+
if len(project_matches) > 1 or (not project_matches and len(fact_matches) > 1):
|
|
1780
|
+
result["_defer_reason"] = "ambiguous_update_target"
|
|
1781
|
+
return result
|
|
1782
|
+
if project_matches:
|
|
1783
|
+
target = project_matches[0]
|
|
1784
|
+
elif len(fact_matches) == 1:
|
|
1785
|
+
target = fact_matches[0]
|
|
1786
|
+
else:
|
|
1787
|
+
return result
|
|
1788
|
+
result["update_memory_id"] = target.memory_id
|
|
1789
|
+
# The update target's type is immutable. Correct a model's fact-vs-
|
|
1790
|
+
# project label only after the same-use target was identified.
|
|
1791
|
+
result["type"] = target.type
|
|
1792
|
+
return result
|
|
1793
|
+
|
|
1685
1794
|
def _defer_candidate(
|
|
1686
1795
|
self,
|
|
1687
1796
|
turn_ref: tuple[str, str, str],
|
|
@@ -2026,6 +2135,11 @@ class Processor:
|
|
|
2026
2135
|
requests: list[dict[str, Any]] = []
|
|
2027
2136
|
observed_scopes: list[str] = []
|
|
2028
2137
|
for candidate in gate["candidates"]:
|
|
2138
|
+
# A combined mailbox/daily digest is not an atomic memory. If a
|
|
2139
|
+
# concrete action was worth retaining, the gate must emit it as
|
|
2140
|
+
# its own candidate; the aggregate shell itself is NO_CHANGE.
|
|
2141
|
+
if candidate.get("worth") and is_aggregate_operational_text(candidate.get("memory")):
|
|
2142
|
+
continue
|
|
2029
2143
|
candidate_scopes = list(candidate["scopes"])
|
|
2030
2144
|
# An automatic candidate with no reliable project attribution is
|
|
2031
2145
|
# retained as a retryable inbox turn, never silently promoted to
|
|
@@ -2075,6 +2189,11 @@ class Processor:
|
|
|
2075
2189
|
priority_only=scope_directory is not None,
|
|
2076
2190
|
scope_records=scoped_records if scope_directory is not None else None,
|
|
2077
2191
|
)
|
|
2192
|
+
candidate = self._infer_update_target(candidate, candidate_related)
|
|
2193
|
+
defer_reason = candidate.pop("_defer_reason", None)
|
|
2194
|
+
if defer_reason:
|
|
2195
|
+
self._defer_candidate(turn_ref, candidate, defer_reason)
|
|
2196
|
+
continue
|
|
2078
2197
|
candidate_native_ids = [item["native_id"] for item in candidate_native_refs]
|
|
2079
2198
|
candidate_memory_ids = [
|
|
2080
2199
|
item["memory_id"]
|
|
@@ -141,6 +141,16 @@ the current turn adds no confirmed change, use worth=false and do not create a
|
|
|
141
141
|
sibling. If a customer or user proposes a new change without confirmation of
|
|
142
142
|
implementation, preserve it as requested/proposed/pending and never state that
|
|
143
143
|
it is already implemented.
|
|
144
|
+
When a candidate names a project plan or project constraint already represented
|
|
145
|
+
by one same-scoped active memory, prefer that exact active memory as
|
|
146
|
+
update_memory_id, even when the candidate's initial type label is fact; the
|
|
147
|
+
active target type is immutable and must be copied. Never create a sibling for
|
|
148
|
+
the same future question/action. Prefer the durable project-plan target over
|
|
149
|
+
same-topic sent-mail, attachment/archive, or meeting records; if multiple
|
|
150
|
+
same-use project targets remain, do not guess an update target. A candidate
|
|
151
|
+
that still combines a mailbox or daily digest shell with counts/statuses is not
|
|
152
|
+
atomic: set worth=false for the aggregate, and emit any independent concrete
|
|
153
|
+
action as a separate candidate.
|
|
144
154
|
Return no prose, markdown fences, comments, or trailing text."""
|
|
145
155
|
|
|
146
156
|
|
|
@@ -243,6 +253,13 @@ surrounding diagnosis into a memory. If a user-confirmed future-use
|
|
|
243
253
|
preference, identity, constraint, project risk, or durable lesson appears in
|
|
244
254
|
the same evidence, summarize only that future-use topic. In explicit remember mode,
|
|
245
255
|
summarize only the requested object and do not append tool/test diagnostics.
|
|
256
|
+
If the gate candidate points to a same-scoped existing plan or constraint,
|
|
257
|
+
update that exact target and retain its still-valid content; do not create a
|
|
258
|
+
new sibling merely because the gate called it a fact. Prefer a durable project
|
|
259
|
+
plan target over same-topic sent-mail, attachment/archive, or meeting records;
|
|
260
|
+
the target type remains immutable. Never summarize a combined mailbox/daily
|
|
261
|
+
digest shell with counts or statuses; return NO_CHANGE for that aggregate,
|
|
262
|
+
while a separate atomic future-use action may still be summarized.
|
|
246
263
|
Return no prose, markdown fences, comments, or trailing text."""
|
|
247
264
|
|
|
248
265
|
|
|
@@ -104,6 +104,10 @@ _ISO_CALENDAR_DATE = re.compile(
|
|
|
104
104
|
r"(?<![A-Za-z\d./-])\d{4}-(?:0?[1-9]|1[0-2])-(?:0?[1-9]|[12]\d|3[01])"
|
|
105
105
|
r"(?![A-Za-z\d./-])"
|
|
106
106
|
)
|
|
107
|
+
_EMPTY_ISO_DATE_PARENTHESIS = re.compile(
|
|
108
|
+
r"(?P<date>\d{4}-(?:0?[1-9]|1[0-2])-(?:0?[1-9]|[12]\d|3[01]))"
|
|
109
|
+
r"[ \t]*(?:\([ \t]*[))]|([ \t]*[))])"
|
|
110
|
+
)
|
|
107
111
|
_DUPLICATE_ISO_DATE = re.compile(
|
|
108
112
|
r"(?P<date>\d{4}-\d{2}-\d{2})"
|
|
109
113
|
r"\s*[,,、:]?\s*(?:(?:就是|即|即为|即是|也就是|是|为)|[//])\s*"
|
|
@@ -275,6 +279,14 @@ def _collapse_duplicate_calendar_dates(value: str) -> str:
|
|
|
275
279
|
return value
|
|
276
280
|
|
|
277
281
|
|
|
282
|
+
def _strip_empty_iso_date_parenthesis(value: str) -> str:
|
|
283
|
+
"""Remove empty ASCII/Chinese parentheses attached to an ISO date."""
|
|
284
|
+
|
|
285
|
+
if not isinstance(value, str):
|
|
286
|
+
return value
|
|
287
|
+
return _EMPTY_ISO_DATE_PARENTHESIS.sub(r"\g<date>", value)
|
|
288
|
+
|
|
289
|
+
|
|
278
290
|
def _normalize_relative_calendar_text(
|
|
279
291
|
text: str,
|
|
280
292
|
anchor: Any,
|
|
@@ -328,6 +340,7 @@ def _normalize_relative_calendar_text(
|
|
|
328
340
|
normalized = _RELATIVE_CALENDAR_EXPRESSION.sub(replace_token, text)
|
|
329
341
|
if relative_replaced:
|
|
330
342
|
normalized = _collapse_duplicate_calendar_dates(normalized)
|
|
343
|
+
normalized = _strip_empty_iso_date_parenthesis(normalized)
|
|
331
344
|
return normalized, safe
|
|
332
345
|
|
|
333
346
|
|
|
@@ -345,6 +358,43 @@ def normalize_relative_calendar_text(text: str, anchor: Any) -> str | None:
|
|
|
345
358
|
return normalized if safe else None
|
|
346
359
|
|
|
347
360
|
|
|
361
|
+
_AGGREGATE_DIGEST_MARKERS = (
|
|
362
|
+
"日报",
|
|
363
|
+
"汇总",
|
|
364
|
+
"巡检",
|
|
365
|
+
"收件箱",
|
|
366
|
+
"邮箱巡检",
|
|
367
|
+
"daily report",
|
|
368
|
+
"digest",
|
|
369
|
+
"watchlist",
|
|
370
|
+
"mailbox sweep",
|
|
371
|
+
"inbox sweep",
|
|
372
|
+
)
|
|
373
|
+
_AGGREGATE_COUNT = re.compile(
|
|
374
|
+
r"(?:\d+\s*(?:条|项|个|件|封|tasks?|items?)|(?:完成|待受理|派发|新增|逾期)\s*\d+)"
|
|
375
|
+
r"|(?:completed|pending|assigned|new|overdue)\s*[::]?\s*\d+",
|
|
376
|
+
re.IGNORECASE,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def is_aggregate_operational_text(value: Any) -> bool:
|
|
381
|
+
"""Recognize a combined operational digest, not one concrete work item.
|
|
382
|
+
|
|
383
|
+
This deliberately requires multiple aggregate signals. A single overdue
|
|
384
|
+
task reported by a daily scan remains eligible for the model's normal
|
|
385
|
+
future-use decision; a multi-count sweep must be split before persistence.
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
if not isinstance(value, str):
|
|
389
|
+
return False
|
|
390
|
+
folded = value.casefold()
|
|
391
|
+
if not any(marker in folded for marker in _AGGREGATE_DIGEST_MARKERS):
|
|
392
|
+
return False
|
|
393
|
+
count_signals = len(_AGGREGATE_COUNT.findall(value))
|
|
394
|
+
separator_count = len(re.findall(r"[;;]|[—-].*[;;]", value))
|
|
395
|
+
return count_signals >= 2 or (count_signals >= 1 and separator_count >= 1)
|
|
396
|
+
|
|
397
|
+
|
|
348
398
|
def _reject_constant(value: str) -> None:
|
|
349
399
|
raise ModelOutputError("non-finite JSON number is not allowed", validation_reason="invalid_json")
|
|
350
400
|
|
|
@@ -1022,6 +1072,9 @@ def validate_summarize_output(
|
|
|
1022
1072
|
}
|
|
1023
1073
|
_require_keys(value, {"title", "body", "tags", "type", "scopes", "sources"}, allowed)
|
|
1024
1074
|
item = dict(value)
|
|
1075
|
+
for field in ("title", "body"):
|
|
1076
|
+
if isinstance(item.get(field), str):
|
|
1077
|
+
item[field] = _strip_empty_iso_date_parenthesis(item[field])
|
|
1025
1078
|
_string(item["title"], "title")
|
|
1026
1079
|
_string(item["body"], "body", multiline=True)
|
|
1027
1080
|
_reject_relative_calendar_expression(item)
|
|
@@ -1281,6 +1334,7 @@ __all__ = [
|
|
|
1281
1334
|
"parse_gate_output",
|
|
1282
1335
|
"parse_compact_output",
|
|
1283
1336
|
"parse_strict_json",
|
|
1337
|
+
"is_aggregate_operational_text",
|
|
1284
1338
|
"normalize_relative_calendar_text",
|
|
1285
1339
|
"parse_summarize_output",
|
|
1286
1340
|
"parse_summary_output",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.12
|
|
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.12。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.12 通过 PyPI 分发。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
30
30
|
## 项目定位
|
|
@@ -305,6 +305,59 @@ class AdmissionFlowTests(unittest.TestCase):
|
|
|
305
305
|
self.assertEqual(len(memories), 1)
|
|
306
306
|
self.assertIn("交付延期风险", memories[0].memory.body)
|
|
307
307
|
|
|
308
|
+
def test_combined_orion_digest_is_dropped_but_atomic_action_is_kept(self):
|
|
309
|
+
service = self.make_service("orion-digest")
|
|
310
|
+
backend = QueueBackend()
|
|
311
|
+
user_key, _ = self.capture_turn(
|
|
312
|
+
service,
|
|
313
|
+
session="orion-digest",
|
|
314
|
+
turn="turn-1",
|
|
315
|
+
user="查看 Orion 邮件汇总并告诉我需要关注的事项。",
|
|
316
|
+
assistant="Orion汇总:子任务完成4条;现场增补待受理2条;另派发提供旧版本生产取数脚本。",
|
|
317
|
+
)
|
|
318
|
+
aggregate = candidate(
|
|
319
|
+
"orion-aggregate",
|
|
320
|
+
[user_key],
|
|
321
|
+
memory="Orion汇总(2026-09-02):子任务完成4条;现场增补待受理2条——另派发子任务提供旧版本生产取数脚本。",
|
|
322
|
+
type="fact",
|
|
323
|
+
)
|
|
324
|
+
aggregate["scopes"] = ["project:orion"]
|
|
325
|
+
atomic = candidate(
|
|
326
|
+
"orion-script-task",
|
|
327
|
+
[user_key],
|
|
328
|
+
memory="Orion需要提供旧版本生产取数脚本。",
|
|
329
|
+
type="todo",
|
|
330
|
+
)
|
|
331
|
+
atomic["scopes"] = ["project:orion"]
|
|
332
|
+
atomic_summary = json.loads(
|
|
333
|
+
summary(
|
|
334
|
+
user_key,
|
|
335
|
+
title="Orion提供旧版本生产取数脚本",
|
|
336
|
+
body="Orion需要提供旧版本生产取数脚本。",
|
|
337
|
+
type="todo",
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
atomic_summary.update(
|
|
341
|
+
{
|
|
342
|
+
"scopes": ["project:orion"],
|
|
343
|
+
"status": "active",
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
backend.responses.extend(
|
|
347
|
+
[gate([aggregate, atomic]), json.dumps(atomic_summary, ensure_ascii=False)]
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
result = service.process(source="hermes", session_id="orion-digest", model=backend)
|
|
351
|
+
|
|
352
|
+
self.assertEqual(result["memories_written"], 1)
|
|
353
|
+
self.assertEqual(result["deferred_candidates"], 0)
|
|
354
|
+
memories = service._read_memories_unlocked("knowledge")
|
|
355
|
+
self.assertEqual(len(memories), 1)
|
|
356
|
+
self.assertEqual(memories[0].memory.title, "Orion提供旧版本生产取数脚本")
|
|
357
|
+
self.assertNotIn("汇总", memories[0].memory.title)
|
|
358
|
+
self.assertNotIn("完成4条", memories[0].memory.body)
|
|
359
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize"])
|
|
360
|
+
|
|
308
361
|
def test_mixed_preference_is_kept_while_mcp_failure_is_not(self):
|
|
309
362
|
service = self.make_service("mixed-preference")
|
|
310
363
|
backend = QueueBackend()
|
|
@@ -284,7 +284,7 @@ class HermesProviderTests(unittest.TestCase):
|
|
|
284
284
|
|
|
285
285
|
def test_provider_warns_with_one_line_update_when_core_versions_differ(self) -> None:
|
|
286
286
|
client = FakeClient(responses=[{"stats": True}])
|
|
287
|
-
client.server_version = "0.2.
|
|
287
|
+
client.server_version = "0.2.12"
|
|
288
288
|
provider = provider_module.MemleafMemoryProvider()
|
|
289
289
|
with patch.object(provider_module, "_resolve_command", return_value="memleaf-mcp"), \
|
|
290
290
|
patch.object(provider_module, "_MCPClient", return_value=client), \
|
|
@@ -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.
|
|
306
|
+
client.server_version = "0.2.12"
|
|
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.
|
|
310
|
+
patch.object(provider_module, "_provider_manifest_version", return_value="0.2.12"), \
|
|
311
311
|
patch.object(provider_module.logger, "warning") as warning:
|
|
312
312
|
provider.initialize(
|
|
313
313
|
"version-match-session",
|
|
@@ -324,7 +324,7 @@ class HermesProviderTests(unittest.TestCase):
|
|
|
324
324
|
provider = provider_module.MemleafMemoryProvider()
|
|
325
325
|
with patch.object(provider_module, "_resolve_command", return_value="memleaf-mcp"), \
|
|
326
326
|
patch.object(provider_module, "_MCPClient", return_value=client), \
|
|
327
|
-
patch.object(provider_module, "_provider_manifest_version", return_value="0.2.
|
|
327
|
+
patch.object(provider_module, "_provider_manifest_version", return_value="0.2.12"), \
|
|
328
328
|
patch.object(provider_module.logger, "warning") as warning:
|
|
329
329
|
provider.initialize(
|
|
330
330
|
"version-missing-session",
|
|
@@ -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.
|
|
84
|
+
self.assertIn("version: 0.2.12", (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.
|
|
216
|
-
"provider_version": "0.2.
|
|
215
|
+
"core_version": "0.2.12",
|
|
216
|
+
"provider_version": "0.2.12",
|
|
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.
|
|
224
|
-
self.assertIn("Hermes provider=0.2.
|
|
223
|
+
self.assertIn("core=0.2.12", output)
|
|
224
|
+
self.assertIn("Hermes provider=0.2.12", 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"
|
|
@@ -23,7 +23,12 @@ from memleaf.prompts import (
|
|
|
23
23
|
gate_prompt,
|
|
24
24
|
summarize_prompt,
|
|
25
25
|
)
|
|
26
|
-
from memleaf.validation import
|
|
26
|
+
from memleaf.validation import (
|
|
27
|
+
ModelOutputError,
|
|
28
|
+
NO_CHANGE_DECISION,
|
|
29
|
+
is_aggregate_operational_text,
|
|
30
|
+
normalize_relative_calendar_text,
|
|
31
|
+
)
|
|
27
32
|
from memleaf.index import event_key, turn_key
|
|
28
33
|
from memleaf.llm import (
|
|
29
34
|
CallableBackend,
|
|
@@ -398,6 +403,48 @@ class StageB1Test(unittest.TestCase):
|
|
|
398
403
|
self.assertIsNone(normalize_relative_calendar_text("明天完成", None))
|
|
399
404
|
self.assertIsNone(normalize_relative_calendar_text("明天完成", "invalid"))
|
|
400
405
|
|
|
406
|
+
def test_date_parentheses_are_removed_only_when_empty(self):
|
|
407
|
+
anchor = "2026-09-02T02:01:41Z"
|
|
408
|
+
self.assertEqual(
|
|
409
|
+
normalize_relative_calendar_text("2026-09-03()", anchor),
|
|
410
|
+
"2026-09-03",
|
|
411
|
+
)
|
|
412
|
+
self.assertEqual(
|
|
413
|
+
normalize_relative_calendar_text("2026-09-03()", anchor),
|
|
414
|
+
"2026-09-03",
|
|
415
|
+
)
|
|
416
|
+
summary = {
|
|
417
|
+
"title": "反馈截止 2026-09-03()",
|
|
418
|
+
"body": "English spelling: 2026-09-03()",
|
|
419
|
+
"tags": [],
|
|
420
|
+
"type": "todo",
|
|
421
|
+
"scopes": ["global"],
|
|
422
|
+
"sources": [{"event_key": "event-a"}],
|
|
423
|
+
"status": "active",
|
|
424
|
+
}
|
|
425
|
+
parsed = parse_summarize_output(json.dumps(summary), current_event_keys=["event-a"])
|
|
426
|
+
self.assertEqual(parsed["title"], "反馈截止 2026-09-03")
|
|
427
|
+
self.assertEqual(parsed["body"], "English spelling: 2026-09-03")
|
|
428
|
+
self.assertEqual(
|
|
429
|
+
parse_summarize_output(
|
|
430
|
+
json.dumps(dict(summary, body="2026-09-03(周四)")),
|
|
431
|
+
current_event_keys=["event-a"],
|
|
432
|
+
)["body"],
|
|
433
|
+
"2026-09-03(周四)",
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
def test_aggregate_operational_detector_requires_a_combined_digest(self):
|
|
437
|
+
self.assertTrue(
|
|
438
|
+
is_aggregate_operational_text(
|
|
439
|
+
"Orion汇总(2026-09-02):子任务完成4条;现场增补待受理2条——另派发1条。"
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
self.assertFalse(
|
|
443
|
+
is_aggregate_operational_text(
|
|
444
|
+
"Orion项目巡检:浦银安盛任务提供测试数据逾期5天。"
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
|
|
401
448
|
def test_process_retries_schema_violation_until_third_gate_attempt_and_commits(self):
|
|
402
449
|
responses = [
|
|
403
450
|
json.dumps({"candidates": "invalid"}),
|
|
@@ -1255,6 +1255,193 @@ class StageB2ATest(unittest.TestCase):
|
|
|
1255
1255
|
self.assertEqual(service.read(old.memory_id).body, "金元顺安实施计划新增部署要求。")
|
|
1256
1256
|
self.assertEqual(len(service.vault.list_markdown("history")), 1)
|
|
1257
1257
|
|
|
1258
|
+
def test_same_project_plan_fact_candidate_is_reconciled_to_immutable_project_target(self):
|
|
1259
|
+
backend = QueueBackend()
|
|
1260
|
+
service = self.service(backend, name="inferred-plan-update")
|
|
1261
|
+
old = service.create_memory(
|
|
1262
|
+
memory_id="mem-zhongyin-plan",
|
|
1263
|
+
title="中银国际员工投资行为申报系统信创改造实施计划",
|
|
1264
|
+
body="中银国际实施计划当前按原始安排执行。",
|
|
1265
|
+
type="project",
|
|
1266
|
+
scopes=["project:中银国际"],
|
|
1267
|
+
)
|
|
1268
|
+
user_key, assistant_key = self.capture_turn(
|
|
1269
|
+
service,
|
|
1270
|
+
turn="inferred-plan-update",
|
|
1271
|
+
user_event="inferred-plan-update-user",
|
|
1272
|
+
assistant_event="inferred-plan-update-assistant",
|
|
1273
|
+
user="中银国际客户提出实施计划调整建议。",
|
|
1274
|
+
assistant="已记录中银国际实施计划调整建议,待更新计划。",
|
|
1275
|
+
)
|
|
1276
|
+
candidate = self.candidate(
|
|
1277
|
+
"zhongyin-plan-feedback",
|
|
1278
|
+
[user_key, assistant_key],
|
|
1279
|
+
memory="中银国际客户提出实施计划调整建议:提前部署测试环境并重新压实计划。",
|
|
1280
|
+
type="fact",
|
|
1281
|
+
)
|
|
1282
|
+
candidate["scopes"] = ["project:中银国际"]
|
|
1283
|
+
backend.responses.extend(
|
|
1284
|
+
[
|
|
1285
|
+
self.gate([candidate]),
|
|
1286
|
+
self.summary(
|
|
1287
|
+
user_key,
|
|
1288
|
+
title=old.title,
|
|
1289
|
+
body="中银国际实施计划当前按原始安排执行;客户提出提前部署测试环境并重新压实计划。",
|
|
1290
|
+
type="project",
|
|
1291
|
+
scopes=["project:中银国际"],
|
|
1292
|
+
),
|
|
1293
|
+
]
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
result = service.process(source="codex", session_id="s", model=backend)
|
|
1297
|
+
|
|
1298
|
+
self.assertEqual(result["memory_ids"], [old.memory_id])
|
|
1299
|
+
self.assertEqual(len(self.knowledge(service)), 1)
|
|
1300
|
+
self.assertEqual(service.read(old.memory_id).type, "project")
|
|
1301
|
+
self.assertIn("原始安排", service.read(old.memory_id).body)
|
|
1302
|
+
self.assertIn("提前部署测试环境", service.read(old.memory_id).body)
|
|
1303
|
+
self.assertEqual(len(service.vault.list_markdown("history")), 1)
|
|
1304
|
+
self.assertNotIn("update_target_type_mismatch", " ".join(call["prompt"] for call in backend.calls))
|
|
1305
|
+
|
|
1306
|
+
def test_project_plan_target_wins_over_sent_mail_for_zhongyin_and_jinyuan(self):
|
|
1307
|
+
cases = (
|
|
1308
|
+
(
|
|
1309
|
+
"zhongyin",
|
|
1310
|
+
"中银国际",
|
|
1311
|
+
"中银国际员工申报系统信创改造实施计划",
|
|
1312
|
+
"陈国金",
|
|
1313
|
+
),
|
|
1314
|
+
(
|
|
1315
|
+
"jinyuan",
|
|
1316
|
+
"金元顺安",
|
|
1317
|
+
"金元顺安员工投资行为申报系统信创改造实施计划",
|
|
1318
|
+
"方明",
|
|
1319
|
+
),
|
|
1320
|
+
)
|
|
1321
|
+
for slug, project, plan_title, recipient in cases:
|
|
1322
|
+
with self.subTest(project=project):
|
|
1323
|
+
backend = QueueBackend()
|
|
1324
|
+
service = self.service(backend, name=f"multi-target-{slug}")
|
|
1325
|
+
plan = service.create_memory(
|
|
1326
|
+
memory_id=f"mem-{slug}-plan",
|
|
1327
|
+
title=plan_title,
|
|
1328
|
+
body=f"{project}实施计划当前按原始安排执行。",
|
|
1329
|
+
type="project",
|
|
1330
|
+
scopes=[f"project:{project}"],
|
|
1331
|
+
)
|
|
1332
|
+
adjacent = []
|
|
1333
|
+
for suffix, title, body in (
|
|
1334
|
+
(
|
|
1335
|
+
"sent-mail",
|
|
1336
|
+
f"已发送{plan_title}邮件给{recipient}",
|
|
1337
|
+
f"已向{recipient}发送{project}实施计划邮件。",
|
|
1338
|
+
),
|
|
1339
|
+
(
|
|
1340
|
+
"attachment",
|
|
1341
|
+
f"{project}实施计划附件清单",
|
|
1342
|
+
f"{project}实施计划附件已归档。",
|
|
1343
|
+
),
|
|
1344
|
+
(
|
|
1345
|
+
"meeting",
|
|
1346
|
+
f"{project}实施计划会议纪要",
|
|
1347
|
+
f"{project}实施计划会议已完成。",
|
|
1348
|
+
),
|
|
1349
|
+
):
|
|
1350
|
+
adjacent.append(
|
|
1351
|
+
service.create_memory(
|
|
1352
|
+
memory_id=f"mem-{slug}-{suffix}",
|
|
1353
|
+
title=title,
|
|
1354
|
+
body=body,
|
|
1355
|
+
type="fact",
|
|
1356
|
+
scopes=[f"project:{project}"],
|
|
1357
|
+
)
|
|
1358
|
+
)
|
|
1359
|
+
user_key, assistant_key = self.capture_turn(
|
|
1360
|
+
service,
|
|
1361
|
+
turn=f"multi-target-{slug}",
|
|
1362
|
+
user_event=f"multi-target-{slug}-user",
|
|
1363
|
+
assistant_event=f"multi-target-{slug}-assistant",
|
|
1364
|
+
user=f"{project}客户提出实施计划调整建议。",
|
|
1365
|
+
assistant=f"已记录{project}实施计划的新约束,待更新计划。",
|
|
1366
|
+
)
|
|
1367
|
+
candidate = self.candidate(
|
|
1368
|
+
f"{slug}-plan-feedback",
|
|
1369
|
+
[user_key, assistant_key],
|
|
1370
|
+
memory=f"{project}客户提出实施计划调整建议:提前部署测试环境并重新压实计划。",
|
|
1371
|
+
type="fact",
|
|
1372
|
+
)
|
|
1373
|
+
candidate["scopes"] = [f"project:{project}"]
|
|
1374
|
+
backend.responses.extend(
|
|
1375
|
+
[
|
|
1376
|
+
self.gate([candidate]),
|
|
1377
|
+
self.summary(
|
|
1378
|
+
user_key,
|
|
1379
|
+
title=plan_title,
|
|
1380
|
+
body=(
|
|
1381
|
+
f"{project}实施计划当前按原始安排执行;"
|
|
1382
|
+
"客户提出提前部署测试环境并重新压实计划。"
|
|
1383
|
+
),
|
|
1384
|
+
type="project",
|
|
1385
|
+
scopes=[f"project:{project}"],
|
|
1386
|
+
),
|
|
1387
|
+
]
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
result = service.process(source="codex", session_id="s", model=backend)
|
|
1391
|
+
|
|
1392
|
+
self.assertEqual(result["memory_ids"], [plan.memory_id])
|
|
1393
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize"])
|
|
1394
|
+
self.assertEqual(len(self.knowledge(service)), 4)
|
|
1395
|
+
self.assertIn("提前部署测试环境", service.read(plan.memory_id).body)
|
|
1396
|
+
for record in adjacent:
|
|
1397
|
+
self.assertEqual(service.read(record.memory_id).body, record.body)
|
|
1398
|
+
self.assertEqual(len(service.vault.list_markdown("history")), 1)
|
|
1399
|
+
|
|
1400
|
+
def test_multiple_same_project_plan_targets_are_deferred_without_sibling(self):
|
|
1401
|
+
backend = QueueBackend()
|
|
1402
|
+
service = self.service(backend, name="ambiguous-project-plan-target")
|
|
1403
|
+
for memory_id, title in (
|
|
1404
|
+
("mem-jinyuan-plan-test", "金元顺安实施计划:测试环境"),
|
|
1405
|
+
("mem-jinyuan-plan-release", "金元顺安实施计划:上线安排"),
|
|
1406
|
+
):
|
|
1407
|
+
service.create_memory(
|
|
1408
|
+
memory_id=memory_id,
|
|
1409
|
+
title=title,
|
|
1410
|
+
body=f"{title}按原始安排执行。",
|
|
1411
|
+
type="project",
|
|
1412
|
+
scopes=["project:金元顺安"],
|
|
1413
|
+
)
|
|
1414
|
+
user_key, assistant_key = self.capture_turn(
|
|
1415
|
+
service,
|
|
1416
|
+
turn="ambiguous-project-plan-target",
|
|
1417
|
+
user_event="ambiguous-project-plan-target-user",
|
|
1418
|
+
assistant_event="ambiguous-project-plan-target-assistant",
|
|
1419
|
+
user="金元顺安客户提出实施计划调整建议。",
|
|
1420
|
+
assistant="已记录金元顺安实施计划的新约束,待确认对应计划。",
|
|
1421
|
+
)
|
|
1422
|
+
candidate = self.candidate(
|
|
1423
|
+
"ambiguous-jinyuan-plan-feedback",
|
|
1424
|
+
[user_key, assistant_key],
|
|
1425
|
+
memory="金元顺安实施计划",
|
|
1426
|
+
type="fact",
|
|
1427
|
+
)
|
|
1428
|
+
candidate["scopes"] = ["project:金元顺安"]
|
|
1429
|
+
backend.responses.append(self.gate([candidate]))
|
|
1430
|
+
|
|
1431
|
+
result = service.process(source="codex", session_id="s", model=backend)
|
|
1432
|
+
|
|
1433
|
+
self.assertEqual(result["memory_ids"], [])
|
|
1434
|
+
self.assertEqual(result["memories_written"], 0)
|
|
1435
|
+
self.assertEqual(result["deferred_candidates"], 1)
|
|
1436
|
+
self.assertEqual([call["purpose"] for call in backend.calls], ["gate"])
|
|
1437
|
+
self.assertEqual(
|
|
1438
|
+
self.processed(service)["sessions"]["codex/s"]["processed_turns"][0]["deferred_candidates"][0]["reason"],
|
|
1439
|
+
"ambiguous_update_target",
|
|
1440
|
+
)
|
|
1441
|
+
self.assertEqual(len(self.knowledge(service)), 2)
|
|
1442
|
+
self.assertEqual(len(service.vault.list_markdown("history")), 0)
|
|
1443
|
+
self.assertTrue((service.vault.inbox_path / "codex" / "s.md").is_file())
|
|
1444
|
+
|
|
1258
1445
|
def test_same_project_wrong_update_target_final_retry_becomes_create(self):
|
|
1259
1446
|
backend = QueueBackend()
|
|
1260
1447
|
service = self.service(backend, name="same-project-wrong-target-final-retry")
|
|
@@ -104,7 +104,7 @@ class StageC1MCPTest(unittest.TestCase):
|
|
|
104
104
|
testcase.assertEqual(result["resultType"], "complete")
|
|
105
105
|
testcase.assertEqual(
|
|
106
106
|
result["_meta"]["io.modelcontextprotocol/serverInfo"],
|
|
107
|
-
{"name": "memleaf", "version": "0.2.
|
|
107
|
+
{"name": "memleaf", "version": "0.2.12"},
|
|
108
108
|
)
|
|
109
109
|
return result
|
|
110
110
|
|
|
@@ -24,7 +24,7 @@ class StageC3PackagingTests(unittest.TestCase):
|
|
|
24
24
|
|
|
25
25
|
def test_project_metadata_and_version_export(self) -> None:
|
|
26
26
|
self.assertEqual(self.project["name"], "memleaf")
|
|
27
|
-
self.assertEqual(self.project["version"], "0.2.
|
|
27
|
+
self.assertEqual(self.project["version"], "0.2.12")
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|