memleaf 0.2.24__tar.gz → 0.2.25__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.24 → memleaf-0.2.25}/CHANGELOG.md +11 -0
- {memleaf-0.2.24/src/memleaf.egg-info → memleaf-0.2.25}/PKG-INFO +3 -3
- {memleaf-0.2.24 → memleaf-0.2.25}/README.en.md +2 -2
- {memleaf-0.2.24 → memleaf-0.2.25}/README.md +2 -2
- {memleaf-0.2.24 → memleaf-0.2.25}/pyproject.toml +1 -1
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/processing.py +471 -9
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/prompts.py +12 -1
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/validation.py +2 -2
- {memleaf-0.2.24 → memleaf-0.2.25/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf.egg-info/SOURCES.txt +2 -0
- memleaf-0.2.25/tests/test_email_actionable_coverage.py +369 -0
- memleaf-0.2.25/tests/test_global_todo_query_no_write.py +444 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/LICENSE +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/MANIFEST.in +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/examples/README.md +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/examples/basic_usage.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/install.ps1 +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/install.sh +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/setup.cfg +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/config.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/hermes_runtime.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/index.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/models.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/native_registration.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/service.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/__init__.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_admission_noise.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_cross_turn_dedupe_regressions.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_extraction_quality_regressions.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_global_todo_acceptance.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_global_todo_retrieval.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_hermes_native_registration.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_hermes_provider.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_hermes_runtime_install.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_host_events.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_install.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_maintenance_v2.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_pypi_install.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b1.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b2a.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_c1_mcp.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_stage_c3_packaging.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_todo_state_recovery.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_update_target_recovery.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_v023_scope_correction.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.24 → memleaf-0.2.25}/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.25 — 2026-09-04
|
|
6
|
+
|
|
7
|
+
- Keep global todo questions and cross-session recaps strictly read-only,
|
|
8
|
+
without creating memories, appending sources, or producing history; an
|
|
9
|
+
explicit user assertion before a follow-up question remains write-eligible.
|
|
10
|
+
- Recover project-grounded, explicitly counted repair items omitted from a
|
|
11
|
+
mailbox extraction gate, split concrete detail lines into independent todos,
|
|
12
|
+
and reuse an existing same-scope item when it is an update.
|
|
13
|
+
- Record compact candidate-level `CREATE`, `UPDATE`, `NO_CHANGE`, and
|
|
14
|
+
`DEFERRED` dispositions in the processed-turn ledger for auditable coverage.
|
|
15
|
+
|
|
5
16
|
## 0.2.24 — 2026-09-04
|
|
6
17
|
|
|
7
18
|
- Make Hermes MCP installation reliably persistent: write `mcp_servers.memleaf` through Hermes' canonical `config set` interface, disable the entry while command/arguments are updated, then read `config.yaml` back and require an exact command plus `--vault` match before reporting success.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.25
|
|
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.25。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.25 通过 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.25.**
|
|
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.25 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.25。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.25 通过 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.25"
|
|
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
|
|
@@ -93,8 +93,17 @@ _DIAGNOSTIC_CANDIDATE_REQUIRED = frozenset(
|
|
|
93
93
|
)
|
|
94
94
|
|
|
95
95
|
_READ_ONLY_QUERY_MARKERS = (
|
|
96
|
-
"有没有", "有哪些", "是什么", "是谁", "多少", "
|
|
97
|
-
"
|
|
96
|
+
"有没有", "有什么", "有哪些", "是什么", "是谁", "多少", "哪个", "哪些",
|
|
97
|
+
"什么时候", "何时", "如何", "怎么", "为什么", "是否", "能否", "可否",
|
|
98
|
+
"查询", "查一下", "查下", "看看", "看下", "查看", "汇总", "列出", "告诉我",
|
|
99
|
+
"what", "which", "who", "show me", "list",
|
|
100
|
+
)
|
|
101
|
+
# A mailbox scan is a query-shaped request, but its answer may contain new
|
|
102
|
+
# external facts (for example, a customer asks for two fixes). Do not apply
|
|
103
|
+
# the pure-existing-memory no-write rule to those requests; the gate still
|
|
104
|
+
# decides which individual facts/actions are durable.
|
|
105
|
+
_EXTERNAL_SOURCE_QUERY_MARKERS = (
|
|
106
|
+
"邮箱", "收件箱", "mailbox", "inbox",
|
|
98
107
|
)
|
|
99
108
|
_USER_ASSERTION_MARKERS = (
|
|
100
109
|
"已确认", "确认了", "已决定", "我决定", "改为", "更新为", "变更为", "以后",
|
|
@@ -140,6 +149,16 @@ _REWORK_ACTION_MARKERS = (
|
|
|
140
149
|
"修改", "修复", "整改", "补充", "补上", "补齐", "调整", "改下", "改一下", "改成", "更改",
|
|
141
150
|
"完善", "修订", "重新", "更新", "rework", "revise", "redraw", "fix", "update", "change",
|
|
142
151
|
)
|
|
152
|
+
_EXPLICIT_REPAIR_COUNT_RE = re.compile(
|
|
153
|
+
r"(?:\d+|[一二三四五六七八九十百千万两俩]+)\s*"
|
|
154
|
+
r"(?:个|项|条|处|点|件|问题|缺陷|模块)",
|
|
155
|
+
re.IGNORECASE,
|
|
156
|
+
)
|
|
157
|
+
_EXPLICIT_REPAIR_RE = re.compile(
|
|
158
|
+
r"(?:需|需要|须|待|仍需|还需|尚需|有待|要)\s*"
|
|
159
|
+
r"(?:修正|修复|改正|整改|修改|更正|补充|完善|fix|repair|remediate)",
|
|
160
|
+
re.IGNORECASE,
|
|
161
|
+
)
|
|
143
162
|
|
|
144
163
|
|
|
145
164
|
|
|
@@ -480,14 +499,239 @@ def _automatic_read_only_query(events: Iterable[Mapping[str, Any]]) -> bool:
|
|
|
480
499
|
if not user_text:
|
|
481
500
|
return False
|
|
482
501
|
folded = user_text.casefold()
|
|
483
|
-
query_prefix =
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
502
|
+
query_prefix = folded
|
|
503
|
+
# Requests often stack polite prefixes ("请帮我看下…"). Strip them
|
|
504
|
+
# repeatedly so the actual interrogative is visible to the classifier.
|
|
505
|
+
while True:
|
|
506
|
+
stripped = re.sub(r"^(?:请|帮我|麻烦|请问|麻烦你)\s*", "", query_prefix)
|
|
507
|
+
if stripped == query_prefix:
|
|
508
|
+
break
|
|
509
|
+
query_prefix = stripped
|
|
510
|
+
interrogative_markers = {
|
|
511
|
+
"有没有", "有什么", "有哪些", "是什么", "是谁", "多少", "哪个", "哪些",
|
|
512
|
+
"什么时候", "何时", "如何", "怎么", "为什么", "是否", "能否", "可否",
|
|
513
|
+
"what", "which", "who",
|
|
514
|
+
}
|
|
515
|
+
imperative_markers = {
|
|
516
|
+
"查询", "查一下", "查下", "看看", "看下", "查看", "汇总", "列出", "告诉我",
|
|
517
|
+
"show me",
|
|
518
|
+
}
|
|
519
|
+
asks = bool(
|
|
520
|
+
re.search(r"[??]", user_text)
|
|
521
|
+
or re.search(r"(?:吗|么|呢)\s*$", user_text)
|
|
522
|
+
or any(marker in query_prefix for marker in interrogative_markers)
|
|
523
|
+
or bool(re.search(r"\b(?:what|which|who|list)\b", query_prefix, flags=re.IGNORECASE))
|
|
524
|
+
or any(
|
|
525
|
+
re.search(
|
|
526
|
+
rf"(?:^|[。!?!?;;::])\s*{re.escape(marker)}",
|
|
527
|
+
query_prefix,
|
|
528
|
+
flags=re.IGNORECASE,
|
|
529
|
+
)
|
|
530
|
+
for marker in imperative_markers
|
|
531
|
+
)
|
|
487
532
|
)
|
|
488
533
|
if not asks:
|
|
489
534
|
return False
|
|
490
|
-
|
|
535
|
+
# A mailbox read can surface new customer facts/actions. It is not a
|
|
536
|
+
# query over existing knowledge, even though the user's wording is a
|
|
537
|
+
# question; let the gate admit concrete project-local items from it.
|
|
538
|
+
if any(marker in folded for marker in _EXTERNAL_SOURCE_QUERY_MARKERS):
|
|
539
|
+
return False
|
|
540
|
+
|
|
541
|
+
# An assertion-shaped phrase can be part of the requested result rather
|
|
542
|
+
# than a new fact. For example, both “有什么是我必须完成的” and
|
|
543
|
+
# “我有哪些已确认的需求” are pure queries. Conversely,
|
|
544
|
+
# “这个需求必须完成,什么时候开始?” contains a new
|
|
545
|
+
# assertion before the question and must remain write-eligible.
|
|
546
|
+
question_words = ("谁", "什么", "哪些", "哪个", "哪项", "多少", "吗", "么", "呢")
|
|
547
|
+
for marker in _USER_ASSERTION_MARKERS:
|
|
548
|
+
start = 0
|
|
549
|
+
while True:
|
|
550
|
+
position = folded.find(marker, start)
|
|
551
|
+
if position < 0:
|
|
552
|
+
break
|
|
553
|
+
suffix = folded[position + len(marker) :]
|
|
554
|
+
clause_start = max(
|
|
555
|
+
(folded.rfind(separator, 0, position) for separator in "。!?!?;;,,"),
|
|
556
|
+
default=-1,
|
|
557
|
+
) + 1
|
|
558
|
+
later_separators = [
|
|
559
|
+
folded.find(separator, position + len(marker))
|
|
560
|
+
for separator in "。!?!?;;,,"
|
|
561
|
+
]
|
|
562
|
+
clause_end = min(
|
|
563
|
+
(value for value in later_separators if value >= 0),
|
|
564
|
+
default=len(folded),
|
|
565
|
+
)
|
|
566
|
+
question_before = any(
|
|
567
|
+
folded.find(word, clause_start, position) >= 0
|
|
568
|
+
for word in interrogative_markers
|
|
569
|
+
)
|
|
570
|
+
question_after = marker == "必须" and any(
|
|
571
|
+
folded.find(word, position + len(marker), clause_end) >= 0
|
|
572
|
+
for word in (*interrogative_markers, *question_words)
|
|
573
|
+
)
|
|
574
|
+
if (
|
|
575
|
+
not question_before
|
|
576
|
+
and not question_after
|
|
577
|
+
and not any(suffix.startswith(word) for word in question_words)
|
|
578
|
+
):
|
|
579
|
+
return False
|
|
580
|
+
start = position + len(marker)
|
|
581
|
+
return True
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _repair_line_text(value: str) -> str:
|
|
585
|
+
"""Keep a deterministic repair candidate focused on its action."""
|
|
586
|
+
|
|
587
|
+
text = re.sub(r"^\s*(?:[-*•]|\d+[.)、])\s*", "", value).strip()
|
|
588
|
+
text = re.sub(r"\*+", "", text)
|
|
589
|
+
text = re.sub(
|
|
590
|
+
r"[((][^()()]{0,120}(?:附件|文档|下载|邮件|message|attachment)[^()()]{0,120}[))]",
|
|
591
|
+
"",
|
|
592
|
+
text,
|
|
593
|
+
flags=re.IGNORECASE,
|
|
594
|
+
)
|
|
595
|
+
text = re.sub(r"(?:她|他|对方|客户)(?:说|反馈|回复)\s*[::]?", "", text)
|
|
596
|
+
text = re.sub(r"[“\"]([^”\"]+)[”\"]", r"\1", text)
|
|
597
|
+
text = re.sub(r"\s+", " ", text).strip(" \t,,;;。.!!??::")
|
|
598
|
+
return text
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _deterministic_project_repair_candidates(
|
|
602
|
+
events: Iterable[Mapping[str, Any]],
|
|
603
|
+
scope_registry: Mapping[str, Any] | None,
|
|
604
|
+
existing_candidates: Iterable[Mapping[str, Any]],
|
|
605
|
+
) -> list[dict[str, Any]]:
|
|
606
|
+
"""Recover explicit counted project repairs omitted by the gate.
|
|
607
|
+
|
|
608
|
+
Mailbox summaries frequently put the project name in a numbered heading
|
|
609
|
+
and the concrete repair count in the following bullet. The normal gate
|
|
610
|
+
can conservatively discard that attachment-shaped bullet; this narrow
|
|
611
|
+
recovery only handles a candidate-local, registered project plus an
|
|
612
|
+
explicit quantity and an unfinished repair verb. It never promotes a
|
|
613
|
+
generic attachment or follow-up line.
|
|
614
|
+
"""
|
|
615
|
+
|
|
616
|
+
if not isinstance(scope_registry, Mapping):
|
|
617
|
+
return []
|
|
618
|
+
existing_topics: dict[str, list[str]] = {}
|
|
619
|
+
for candidate in existing_candidates:
|
|
620
|
+
if not isinstance(candidate, Mapping) or candidate.get("worth") is not True:
|
|
621
|
+
continue
|
|
622
|
+
text = candidate.get("memory")
|
|
623
|
+
if not isinstance(text, str):
|
|
624
|
+
continue
|
|
625
|
+
if not _EXPLICIT_REPAIR_COUNT_RE.search(text) or not _EXPLICIT_REPAIR_RE.search(text):
|
|
626
|
+
continue
|
|
627
|
+
occurrences = _project_scope_occurrences(text, scope_registry)
|
|
628
|
+
if occurrences is None:
|
|
629
|
+
continue
|
|
630
|
+
for _start, _end, scope in occurrences:
|
|
631
|
+
existing_topics.setdefault(scope.casefold(), []).append(normalize_term(text))
|
|
632
|
+
|
|
633
|
+
# Keep the extraction candidate-local. A heading such as “6. 摩根基金”
|
|
634
|
+
# commonly precedes the actual repair bullet, so retain that scope until
|
|
635
|
+
# the next project heading (or a separator) and collect only explicit
|
|
636
|
+
# remediation bullets underneath a counted repair statement.
|
|
637
|
+
sections: list[tuple[str, str, str, list[str]]] = []
|
|
638
|
+
for event in events:
|
|
639
|
+
if str(event.get("role", "")).casefold() not in {"user", "assistant"}:
|
|
640
|
+
continue
|
|
641
|
+
content = event.get("content")
|
|
642
|
+
evidence_event_id = event.get("event_key")
|
|
643
|
+
if not isinstance(content, str) or not content.strip() or not isinstance(evidence_event_id, str):
|
|
644
|
+
continue
|
|
645
|
+
current_scope: str | None = None
|
|
646
|
+
aggregate_line: str | None = None
|
|
647
|
+
aggregate_scope: str | None = None
|
|
648
|
+
detail_lines: list[str] = []
|
|
649
|
+
|
|
650
|
+
def flush_section() -> None:
|
|
651
|
+
nonlocal aggregate_line, aggregate_scope, detail_lines
|
|
652
|
+
if aggregate_line is not None and aggregate_scope is not None:
|
|
653
|
+
sections.append(
|
|
654
|
+
(evidence_event_id, aggregate_scope, aggregate_line, list(detail_lines))
|
|
655
|
+
)
|
|
656
|
+
aggregate_line = None
|
|
657
|
+
aggregate_scope = None
|
|
658
|
+
detail_lines = []
|
|
659
|
+
|
|
660
|
+
for line in content.splitlines():
|
|
661
|
+
line_text = line.strip()
|
|
662
|
+
if re.fullmatch(r"[-—]{3,}", line_text):
|
|
663
|
+
flush_section()
|
|
664
|
+
current_scope = None
|
|
665
|
+
continue
|
|
666
|
+
if not line_text:
|
|
667
|
+
continue
|
|
668
|
+
line_occurrences = _project_scope_occurrences(line_text, scope_registry)
|
|
669
|
+
line_scopes = list(dict.fromkeys(item[2] for item in (line_occurrences or [])))
|
|
670
|
+
if len(line_scopes) == 1:
|
|
671
|
+
if current_scope is not None and line_scopes[0].casefold() != current_scope.casefold():
|
|
672
|
+
flush_section()
|
|
673
|
+
current_scope = line_scopes[0]
|
|
674
|
+
elif len(line_scopes) > 1:
|
|
675
|
+
flush_section()
|
|
676
|
+
current_scope = None
|
|
677
|
+
if _EXPLICIT_REPAIR_COUNT_RE.search(line_text) and _EXPLICIT_REPAIR_RE.search(line_text):
|
|
678
|
+
flush_section()
|
|
679
|
+
if current_scope is not None:
|
|
680
|
+
aggregate_line = line_text
|
|
681
|
+
aggregate_scope = current_scope
|
|
682
|
+
continue
|
|
683
|
+
# Only explicit repair/remediation language can turn a following
|
|
684
|
+
# bullet into an independent item. Generic “见附件/需跟进” text
|
|
685
|
+
# remains outside this recovery path.
|
|
686
|
+
if (
|
|
687
|
+
aggregate_line is not None
|
|
688
|
+
and current_scope is not None
|
|
689
|
+
and current_scope.casefold() == aggregate_scope.casefold()
|
|
690
|
+
and _EXPLICIT_REPAIR_RE.search(line_text)
|
|
691
|
+
):
|
|
692
|
+
detail_lines.append(line_text)
|
|
693
|
+
flush_section()
|
|
694
|
+
|
|
695
|
+
recovered: list[dict[str, Any]] = []
|
|
696
|
+
emitted_keys: set[tuple[str, str]] = set()
|
|
697
|
+
for evidence_event_id, scope, aggregate_line, detail_lines in sections:
|
|
698
|
+
lines = detail_lines or [aggregate_line]
|
|
699
|
+
scope_name = scope.partition(":")[2]
|
|
700
|
+
for line in lines:
|
|
701
|
+
cleaned = _repair_line_text(line)
|
|
702
|
+
if not cleaned:
|
|
703
|
+
continue
|
|
704
|
+
if scope_name.casefold() not in cleaned.casefold():
|
|
705
|
+
cleaned = f"{scope_name}:{cleaned}"
|
|
706
|
+
normalized = normalize_term(cleaned)
|
|
707
|
+
key = (scope.casefold(), normalized)
|
|
708
|
+
if not normalized or key in emitted_keys:
|
|
709
|
+
continue
|
|
710
|
+
# A model candidate covering this exact repair already has a
|
|
711
|
+
# normal UPDATE/NO_CHANGE path; do not create a sibling. Other
|
|
712
|
+
# repairs in the same project remain independently recoverable.
|
|
713
|
+
if any(
|
|
714
|
+
normalized in existing_text or existing_text in normalized
|
|
715
|
+
for existing_text in existing_topics.get(scope.casefold(), [])
|
|
716
|
+
):
|
|
717
|
+
continue
|
|
718
|
+
candidate_id = "email-repair-" + hashlib.sha256(
|
|
719
|
+
f"{evidence_event_id}|{scope}|{normalized}".encode("utf-8")
|
|
720
|
+
).hexdigest()[:16]
|
|
721
|
+
recovered.append(
|
|
722
|
+
{
|
|
723
|
+
"candidate_id": candidate_id,
|
|
724
|
+
"memory": cleaned,
|
|
725
|
+
"evidence_event_ids": [evidence_event_id],
|
|
726
|
+
"duplicate": False,
|
|
727
|
+
"worth": True,
|
|
728
|
+
"type": "todo",
|
|
729
|
+
"scopes": [scope],
|
|
730
|
+
"scope_source": "model",
|
|
731
|
+
}
|
|
732
|
+
)
|
|
733
|
+
emitted_keys.add(key)
|
|
734
|
+
return recovered
|
|
491
735
|
|
|
492
736
|
|
|
493
737
|
def _automatic_transient_memory(value: Any) -> bool:
|
|
@@ -1160,6 +1404,84 @@ class Processor:
|
|
|
1160
1404
|
# exposing an uncommitted body outside this processor.
|
|
1161
1405
|
self._planned_related: list[dict[str, Any]] = []
|
|
1162
1406
|
self._deferred_by_turn: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
|
|
1407
|
+
self._dispositions_by_turn: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
|
|
1408
|
+
|
|
1409
|
+
def _record_disposition(
|
|
1410
|
+
self,
|
|
1411
|
+
turn_ref: tuple[str, str, str],
|
|
1412
|
+
candidate: Mapping[str, Any],
|
|
1413
|
+
disposition: str,
|
|
1414
|
+
*,
|
|
1415
|
+
reason: str | None = None,
|
|
1416
|
+
memory_id: str | None = None,
|
|
1417
|
+
) -> None:
|
|
1418
|
+
"""Record a compact, candidate-level processing outcome.
|
|
1419
|
+
|
|
1420
|
+
The ledger deliberately keeps only the stable candidate id and the
|
|
1421
|
+
outcome metadata. Candidate text belongs in the inbox/model
|
|
1422
|
+
diagnostics, not in the durable processing index.
|
|
1423
|
+
"""
|
|
1424
|
+
|
|
1425
|
+
candidate_id = candidate.get("candidate_id")
|
|
1426
|
+
if not isinstance(candidate_id, str) or not candidate_id:
|
|
1427
|
+
return
|
|
1428
|
+
if disposition not in {"CREATE", "UPDATE", "NO_CHANGE", "DEFERRED"}:
|
|
1429
|
+
return
|
|
1430
|
+
value: dict[str, Any] = {
|
|
1431
|
+
"candidate_id": candidate_id,
|
|
1432
|
+
"disposition": disposition,
|
|
1433
|
+
}
|
|
1434
|
+
if isinstance(reason, str) and reason:
|
|
1435
|
+
value["reason"] = reason
|
|
1436
|
+
if isinstance(memory_id, str) and memory_id:
|
|
1437
|
+
value["memory_id"] = memory_id
|
|
1438
|
+
values = self._dispositions_by_turn.setdefault(turn_ref, [])
|
|
1439
|
+
candidate_key = candidate_id.casefold()
|
|
1440
|
+
for index, previous in enumerate(values):
|
|
1441
|
+
if (
|
|
1442
|
+
isinstance(previous, Mapping)
|
|
1443
|
+
and isinstance(previous.get("candidate_id"), str)
|
|
1444
|
+
and previous["candidate_id"].casefold() == candidate_key
|
|
1445
|
+
):
|
|
1446
|
+
values[index] = value
|
|
1447
|
+
return
|
|
1448
|
+
values.append(value)
|
|
1449
|
+
|
|
1450
|
+
def _record_request_disposition(
|
|
1451
|
+
self,
|
|
1452
|
+
request: Mapping[str, Any],
|
|
1453
|
+
disposition: str,
|
|
1454
|
+
*,
|
|
1455
|
+
reason: str | None = None,
|
|
1456
|
+
memory_id: str | None = None,
|
|
1457
|
+
) -> None:
|
|
1458
|
+
turn = request.get("turn")
|
|
1459
|
+
if not isinstance(turn, InboxTurn):
|
|
1460
|
+
return
|
|
1461
|
+
self._record_disposition(
|
|
1462
|
+
(turn.source, turn.session_id, turn.turn_key),
|
|
1463
|
+
request,
|
|
1464
|
+
disposition,
|
|
1465
|
+
reason=reason,
|
|
1466
|
+
memory_id=memory_id,
|
|
1467
|
+
)
|
|
1468
|
+
|
|
1469
|
+
def _candidate_dispositions(
|
|
1470
|
+
self,
|
|
1471
|
+
snapshots: Iterable[_Snapshot] = (),
|
|
1472
|
+
) -> list[dict[str, Any]]:
|
|
1473
|
+
"""Return compact outcomes in the same order as processed turns."""
|
|
1474
|
+
|
|
1475
|
+
result: list[dict[str, Any]] = []
|
|
1476
|
+
for snapshot in snapshots:
|
|
1477
|
+
turn = snapshot.turn
|
|
1478
|
+
turn_ref = (turn.source, turn.session_id, turn.turn_key)
|
|
1479
|
+
result.extend(
|
|
1480
|
+
dict(item)
|
|
1481
|
+
for item in self._dispositions_by_turn.get(turn_ref, [])
|
|
1482
|
+
if isinstance(item, Mapping)
|
|
1483
|
+
)
|
|
1484
|
+
return result
|
|
1163
1485
|
|
|
1164
1486
|
def _resolve_backend(self, model: Any = None, router: Any = None) -> Any:
|
|
1165
1487
|
backend = router if router is not None else model
|
|
@@ -2755,6 +3077,19 @@ class Processor:
|
|
|
2755
3077
|
scopes: Optional[Iterable[str]] = None,
|
|
2756
3078
|
scope_source: Any = _UNSET,
|
|
2757
3079
|
) -> None:
|
|
3080
|
+
self._record_disposition(
|
|
3081
|
+
turn_ref,
|
|
3082
|
+
candidate,
|
|
3083
|
+
"DEFERRED",
|
|
3084
|
+
reason=reason,
|
|
3085
|
+
memory_id=(
|
|
3086
|
+
candidate.get("update_memory_id")
|
|
3087
|
+
if isinstance(candidate.get("update_memory_id"), str)
|
|
3088
|
+
else candidate.get("duplicate_memory_id")
|
|
3089
|
+
if isinstance(candidate.get("duplicate_memory_id"), str)
|
|
3090
|
+
else None
|
|
3091
|
+
),
|
|
3092
|
+
)
|
|
2758
3093
|
self._deferred_by_turn[turn_ref].append(
|
|
2759
3094
|
{
|
|
2760
3095
|
"candidate_id": str(candidate["candidate_id"]),
|
|
@@ -3237,6 +3572,23 @@ class Processor:
|
|
|
3237
3572
|
"turn_index": turn.turn_index,
|
|
3238
3573
|
},
|
|
3239
3574
|
)
|
|
3575
|
+
# A mailbox summary may put a project in a numbered heading and the
|
|
3576
|
+
# concrete “N items need repair” action in its following bullet. If
|
|
3577
|
+
# the gate conservatively omits that attachment-shaped item, recover
|
|
3578
|
+
# only this narrow, project-grounded action before candidate-level
|
|
3579
|
+
# dedupe. Pure existing-memory queries remain write-free.
|
|
3580
|
+
if not _automatic_read_only_query(events):
|
|
3581
|
+
recovered_repairs = _deterministic_project_repair_candidates(
|
|
3582
|
+
events,
|
|
3583
|
+
validation_scope_registry,
|
|
3584
|
+
gate.get("candidates", []),
|
|
3585
|
+
)
|
|
3586
|
+
if recovered_repairs:
|
|
3587
|
+
gate = dict(gate)
|
|
3588
|
+
gate["candidates"] = [
|
|
3589
|
+
*gate.get("candidates", []),
|
|
3590
|
+
*recovered_repairs,
|
|
3591
|
+
]
|
|
3240
3592
|
# A model may correctly treat the rest of a mixed turn as a query and
|
|
3241
3593
|
# still miss the user's explicit completion update. Recover only one
|
|
3242
3594
|
# uniquely related active todo; all other gate decisions stay intact.
|
|
@@ -3415,6 +3767,17 @@ class Processor:
|
|
|
3415
3767
|
correction_plan = scope_correction_plans.get(candidate_id_key)
|
|
3416
3768
|
detached_update_target_id = detached_update_target_ids.get(candidate_id_key)
|
|
3417
3769
|
defer_reason = candidate.get("_defer_reason")
|
|
3770
|
+
# A pure existing-memory query must not leave even a deferred
|
|
3771
|
+
# candidate behind when the model mislabels its recap. Explicit
|
|
3772
|
+
# todo state recovery is the only write-eligible exception.
|
|
3773
|
+
if read_only_query and recovery is None:
|
|
3774
|
+
self._record_disposition(
|
|
3775
|
+
turn_ref,
|
|
3776
|
+
candidate,
|
|
3777
|
+
"NO_CHANGE",
|
|
3778
|
+
reason="read_only_query",
|
|
3779
|
+
)
|
|
3780
|
+
continue
|
|
3418
3781
|
if isinstance(defer_reason, str) and defer_reason in {"mixed_future_use", "scope_conflict"}:
|
|
3419
3782
|
self._defer_candidate(
|
|
3420
3783
|
turn_ref,
|
|
@@ -3436,16 +3799,33 @@ class Processor:
|
|
|
3436
3799
|
)
|
|
3437
3800
|
continue
|
|
3438
3801
|
if candidate.get("worth") and (
|
|
3439
|
-
(
|
|
3440
|
-
or _automatic_transient_memory(candidate.get("memory"))
|
|
3802
|
+
_automatic_transient_memory(candidate.get("memory"))
|
|
3441
3803
|
):
|
|
3804
|
+
self._record_disposition(
|
|
3805
|
+
turn_ref,
|
|
3806
|
+
candidate,
|
|
3807
|
+
"NO_CHANGE",
|
|
3808
|
+
reason="transient",
|
|
3809
|
+
)
|
|
3442
3810
|
continue
|
|
3443
3811
|
# A combined mailbox/daily digest is not an atomic memory. If a
|
|
3444
3812
|
# concrete action was worth retaining, the gate must emit it as
|
|
3445
3813
|
# its own candidate; the aggregate shell itself is NO_CHANGE.
|
|
3446
3814
|
if candidate.get("worth") and is_aggregate_operational_text(candidate.get("memory")):
|
|
3815
|
+
self._record_disposition(
|
|
3816
|
+
turn_ref,
|
|
3817
|
+
candidate,
|
|
3818
|
+
"NO_CHANGE",
|
|
3819
|
+
reason="aggregate",
|
|
3820
|
+
)
|
|
3447
3821
|
continue
|
|
3448
3822
|
if candidate.get("worth") and is_attachment_followup_only_text(candidate.get("memory")):
|
|
3823
|
+
self._record_disposition(
|
|
3824
|
+
turn_ref,
|
|
3825
|
+
candidate,
|
|
3826
|
+
"NO_CHANGE",
|
|
3827
|
+
reason="attachment_followup",
|
|
3828
|
+
)
|
|
3449
3829
|
continue
|
|
3450
3830
|
candidate_scopes = list(candidate["scopes"])
|
|
3451
3831
|
# An automatic candidate with no reliable project attribution is
|
|
@@ -3709,6 +4089,20 @@ class Processor:
|
|
|
3709
4089
|
and observed_scope not in observed_scopes
|
|
3710
4090
|
):
|
|
3711
4091
|
observed_scopes.append(observed_scope)
|
|
4092
|
+
self._record_disposition(
|
|
4093
|
+
turn_ref,
|
|
4094
|
+
candidate,
|
|
4095
|
+
"NO_CHANGE",
|
|
4096
|
+
reason="duplicate",
|
|
4097
|
+
memory_id=duplicate_memory_id,
|
|
4098
|
+
)
|
|
4099
|
+
else:
|
|
4100
|
+
self._record_disposition(
|
|
4101
|
+
turn_ref,
|
|
4102
|
+
candidate,
|
|
4103
|
+
"NO_CHANGE",
|
|
4104
|
+
reason="not_worthy",
|
|
4105
|
+
)
|
|
3712
4106
|
continue
|
|
3713
4107
|
|
|
3714
4108
|
gate_update_target = candidate.get("update_memory_id")
|
|
@@ -3797,14 +4191,37 @@ class Processor:
|
|
|
3797
4191
|
)
|
|
3798
4192
|
continue
|
|
3799
4193
|
if summary.get("decision") == NO_CHANGE_DECISION:
|
|
4194
|
+
self._record_disposition(
|
|
4195
|
+
turn_ref,
|
|
4196
|
+
candidate,
|
|
4197
|
+
"NO_CHANGE",
|
|
4198
|
+
reason="summary_no_change",
|
|
4199
|
+
memory_id=(
|
|
4200
|
+
summary.get("update_memory_id")
|
|
4201
|
+
if isinstance(summary.get("update_memory_id"), str)
|
|
4202
|
+
else None
|
|
4203
|
+
),
|
|
4204
|
+
)
|
|
3800
4205
|
continue
|
|
3801
4206
|
if is_attachment_followup_only_text(
|
|
3802
4207
|
f"{summary.get('title', '')}\n{summary.get('body', '')}"
|
|
3803
4208
|
):
|
|
4209
|
+
self._record_disposition(
|
|
4210
|
+
turn_ref,
|
|
4211
|
+
candidate,
|
|
4212
|
+
"NO_CHANGE",
|
|
4213
|
+
reason="attachment_followup",
|
|
4214
|
+
)
|
|
3804
4215
|
continue
|
|
3805
4216
|
if _automatic_transient_memory(
|
|
3806
4217
|
f"{summary.get('title', '')}\n{summary.get('body', '')}"
|
|
3807
4218
|
):
|
|
4219
|
+
self._record_disposition(
|
|
4220
|
+
turn_ref,
|
|
4221
|
+
candidate,
|
|
4222
|
+
"NO_CHANGE",
|
|
4223
|
+
reason="transient",
|
|
4224
|
+
)
|
|
3808
4225
|
continue
|
|
3809
4226
|
if gate_update_target is not None:
|
|
3810
4227
|
summary_update_target = summary.get("update_memory_id")
|
|
@@ -3853,8 +4270,24 @@ class Processor:
|
|
|
3853
4270
|
candidate_related,
|
|
3854
4271
|
ignore_memory_ids=current_turn_request_ids,
|
|
3855
4272
|
):
|
|
4273
|
+
self._record_disposition(
|
|
4274
|
+
turn_ref,
|
|
4275
|
+
candidate,
|
|
4276
|
+
"NO_CHANGE",
|
|
4277
|
+
reason="already_covered",
|
|
4278
|
+
)
|
|
3856
4279
|
continue
|
|
3857
4280
|
requests.append(pending_request)
|
|
4281
|
+
self._record_disposition(
|
|
4282
|
+
turn_ref,
|
|
4283
|
+
candidate,
|
|
4284
|
+
"UPDATE" if not final_is_create else "CREATE",
|
|
4285
|
+
memory_id=(
|
|
4286
|
+
summary_update_target
|
|
4287
|
+
if not final_is_create
|
|
4288
|
+
else pending_request["memory_id"]
|
|
4289
|
+
),
|
|
4290
|
+
)
|
|
3858
4291
|
for observed_scope in summary["scopes"]:
|
|
3859
4292
|
if (
|
|
3860
4293
|
isinstance(observed_scope, str)
|
|
@@ -4074,6 +4507,34 @@ class Processor:
|
|
|
4074
4507
|
self.writer.last_metadata_merged = 0
|
|
4075
4508
|
self.writer.last_noop_memory_ids = set()
|
|
4076
4509
|
noop_memory_ids = self.writer.last_noop_memory_ids
|
|
4510
|
+
for request in all_requests:
|
|
4511
|
+
request_memory_id = request.get("memory_id")
|
|
4512
|
+
if request_memory_id not in noop_memory_ids:
|
|
4513
|
+
continue
|
|
4514
|
+
duplicate_id = request.get("duplicate_memory_id")
|
|
4515
|
+
summary = request.get("summary")
|
|
4516
|
+
update_id = (
|
|
4517
|
+
summary.get("update_memory_id")
|
|
4518
|
+
if isinstance(summary, Mapping)
|
|
4519
|
+
else None
|
|
4520
|
+
)
|
|
4521
|
+
target_id = (
|
|
4522
|
+
duplicate_id
|
|
4523
|
+
if isinstance(duplicate_id, str) and duplicate_id
|
|
4524
|
+
else update_id
|
|
4525
|
+
if isinstance(update_id, str) and update_id
|
|
4526
|
+
else None
|
|
4527
|
+
)
|
|
4528
|
+
self._record_request_disposition(
|
|
4529
|
+
request,
|
|
4530
|
+
"NO_CHANGE",
|
|
4531
|
+
reason=(
|
|
4532
|
+
"duplicate"
|
|
4533
|
+
if isinstance(duplicate_id, str) and duplicate_id
|
|
4534
|
+
else "unchanged"
|
|
4535
|
+
),
|
|
4536
|
+
memory_id=target_id,
|
|
4537
|
+
)
|
|
4077
4538
|
if prepared_scopes is not None:
|
|
4078
4539
|
try:
|
|
4079
4540
|
ScopeMaintainer(self.service).apply_unlocked(
|
|
@@ -4177,6 +4638,7 @@ class Processor:
|
|
|
4177
4638
|
entry.pop("deferred_candidates", None)
|
|
4178
4639
|
if not entry.get("cleanup_done_at"):
|
|
4179
4640
|
entry["eligible_cleanup_at"] = _add_hours(now, cleanup_hours)
|
|
4641
|
+
entry["candidate_dispositions"] = self._candidate_dispositions([snapshot])
|
|
4180
4642
|
state["processed_turns"] = entries
|
|
4181
4643
|
watermark = max(_as_int(state.get("watermark"), 0), _as_int(snapshot.turn.turn_index, 0))
|
|
4182
4644
|
state["watermark"] = watermark
|