memleaf 0.2.7__tar.gz → 0.2.9__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.7 → memleaf-0.2.9}/CHANGELOG.md +24 -0
- {memleaf-0.2.7/src/memleaf.egg-info → memleaf-0.2.9}/PKG-INFO +3 -3
- {memleaf-0.2.7 → memleaf-0.2.9}/README.en.md +2 -2
- {memleaf-0.2.7 → memleaf-0.2.9}/README.md +2 -2
- {memleaf-0.2.7 → memleaf-0.2.9}/pyproject.toml +1 -1
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/hermes_provider/__init__.py +3 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/processing.py +251 -25
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/prompts.py +63 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/scope_state.py +14 -2
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/validation.py +121 -2
- {memleaf-0.2.7 → memleaf-0.2.9/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_admission_noise.py +9 -11
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_cross_host_acceptance.py +4 -1
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_hermes_provider.py +19 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_maintenance_v2.py +35 -20
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_pypi_install.py +1 -1
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_session_lineage.py +2 -2
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b1.py +143 -3
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b2a.py +467 -16
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b2b.py +19 -5
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3b_native_context.py +6 -6
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3b_scope.py +29 -8
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3d_scope_maintenance.py +11 -2
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_c3_packaging.py +1 -1
- {memleaf-0.2.7 → memleaf-0.2.9}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/LICENSE +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/MANIFEST.in +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/examples/README.md +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/examples/basic_usage.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/install.ps1 +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/install.sh +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/setup.cfg +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/config.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/index.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/models.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/service.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf.egg-info/SOURCES.txt +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/__init__.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_host_events.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_install.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.7 → memleaf-0.2.9}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.9 — 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Reject update and duplicate targets that are relevant only to another item
|
|
8
|
+
in the same aggregate turn, preventing cross-project memory overwrites.
|
|
9
|
+
- Retry an unrelated gate target with an explicit correction, then recover
|
|
10
|
+
deterministically after the bounded final attempt: preserve unrelated
|
|
11
|
+
updates as independent candidates and discard unsafe duplicate claims.
|
|
12
|
+
- Query related memories from each candidate's own complete topic instead of
|
|
13
|
+
the whole mailbox turn, while allowing the remaining valid candidates to be
|
|
14
|
+
processed without holding the retained inbox turn hostage.
|
|
15
|
+
|
|
16
|
+
## 0.2.8 — 2026-09-01
|
|
17
|
+
|
|
18
|
+
- Let the automatic summarizer independently reject an over-admitted gate
|
|
19
|
+
candidate with an exact `NO_CHANGE` response, without writing memory,
|
|
20
|
+
history, or Scope state; explicit remember remains write-oriented and does
|
|
21
|
+
not permit this response.
|
|
22
|
+
- Ground every model-attributed project Scope in the candidate's own project
|
|
23
|
+
name or registered alias, and prevent the summarizer from drifting away
|
|
24
|
+
from the gate-selected Scope.
|
|
25
|
+
- Register session and Vault Scopes only for committed memories or trusted
|
|
26
|
+
active duplicate targets, so temporary, deferred, and no-change mailbox
|
|
27
|
+
items cannot pollute Scope state.
|
|
28
|
+
|
|
5
29
|
## 0.2.7 — 2026-09-01
|
|
6
30
|
|
|
7
31
|
- Distinguish an invalid gate type enum from a candidate type that conflicts
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
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.9。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 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.9.**
|
|
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.9 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.9。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 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.9"
|
|
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
|
|
@@ -78,6 +78,9 @@ _MODEL_VALIDATION_DETAILS = frozenset(
|
|
|
78
78
|
"duplicate_update_target",
|
|
79
79
|
"mixed_project_scopes",
|
|
80
80
|
"update_target_type_mismatch",
|
|
81
|
+
"target_not_relevant",
|
|
82
|
+
"scope_not_grounded",
|
|
83
|
+
"scope_drift",
|
|
81
84
|
"invalid_evidence",
|
|
82
85
|
"invalid_flags",
|
|
83
86
|
"invalid_type",
|
|
@@ -37,9 +37,12 @@ from .prompts import (
|
|
|
37
37
|
MIXED_PROJECT_SCOPES_CORRECTION,
|
|
38
38
|
RELATIVE_TIME_CORRECTION,
|
|
39
39
|
GATE_SYSTEM,
|
|
40
|
+
SCOPE_GROUNDING_CORRECTION,
|
|
41
|
+
SUMMARY_SCOPE_CORRECTION,
|
|
40
42
|
SUMMARY_TARGET_CORRECTION,
|
|
41
43
|
SUMMARY_TYPE_CORRECTION,
|
|
42
44
|
SUMMARIZE_SYSTEM,
|
|
45
|
+
TARGET_RELEVANCE_CORRECTION,
|
|
43
46
|
UPDATE_TARGET_TYPE_CORRECTION,
|
|
44
47
|
gate_prompt,
|
|
45
48
|
summarize_prompt,
|
|
@@ -51,6 +54,7 @@ from .scope_maintenance import ScopeMaintainer, ScopeMaintenanceError, scope_reg
|
|
|
51
54
|
from .validation import (
|
|
52
55
|
MODEL_VALIDATION_DETAILS,
|
|
53
56
|
ModelOutputError,
|
|
57
|
+
NO_CHANGE_DECISION,
|
|
54
58
|
parse_gate_output,
|
|
55
59
|
parse_summarize_output,
|
|
56
60
|
)
|
|
@@ -488,6 +492,12 @@ class Processor:
|
|
|
488
492
|
return UPDATE_TARGET_TYPE_CORRECTION
|
|
489
493
|
if stage == "gate" and hint == "invalid_type":
|
|
490
494
|
return GATE_TYPE_CORRECTION
|
|
495
|
+
if stage == "gate" and hint == "scope_not_grounded":
|
|
496
|
+
return SCOPE_GROUNDING_CORRECTION
|
|
497
|
+
if stage == "gate" and hint == "target_not_relevant":
|
|
498
|
+
return TARGET_RELEVANCE_CORRECTION
|
|
499
|
+
if stage == "summarize" and hint == "scope_drift":
|
|
500
|
+
return SUMMARY_SCOPE_CORRECTION
|
|
491
501
|
if hint == "relative_time":
|
|
492
502
|
return RELATIVE_TIME_CORRECTION
|
|
493
503
|
if stage == "summarize" and hint == "invalid_update_target":
|
|
@@ -1412,6 +1422,157 @@ class Processor:
|
|
|
1412
1422
|
strict_relevance=True,
|
|
1413
1423
|
)
|
|
1414
1424
|
|
|
1425
|
+
def _target_is_relevant_to_candidate(
|
|
1426
|
+
self,
|
|
1427
|
+
turn: InboxTurn,
|
|
1428
|
+
candidate: Mapping[str, Any],
|
|
1429
|
+
*,
|
|
1430
|
+
scope_directory: Optional[list[dict[str, Any]]] = None,
|
|
1431
|
+
scope_directory_complete: bool = True,
|
|
1432
|
+
) -> bool:
|
|
1433
|
+
"""Check a model-selected target against the candidate topic only.
|
|
1434
|
+
|
|
1435
|
+
The ordinary related-memory prompt may include several independent
|
|
1436
|
+
items from one mailbox turn. Target selection must not inherit that
|
|
1437
|
+
aggregate relevance: only the candidate's own extracted topic may
|
|
1438
|
+
make an active target eligible. A complete scope directory remains a
|
|
1439
|
+
bounded escape hatch for an explicitly indirect user/session scope;
|
|
1440
|
+
it never turns the target into an unconditional priority hit for a
|
|
1441
|
+
direct model-attributed candidate.
|
|
1442
|
+
"""
|
|
1443
|
+
|
|
1444
|
+
target = next(
|
|
1445
|
+
(
|
|
1446
|
+
candidate.get(field)
|
|
1447
|
+
for field in ("duplicate_memory_id", "update_memory_id")
|
|
1448
|
+
if isinstance(candidate.get(field), str) and candidate.get(field)
|
|
1449
|
+
),
|
|
1450
|
+
None,
|
|
1451
|
+
)
|
|
1452
|
+
memory = candidate.get("memory")
|
|
1453
|
+
candidate_scopes = candidate.get("scopes")
|
|
1454
|
+
if (
|
|
1455
|
+
not isinstance(target, str)
|
|
1456
|
+
or not isinstance(memory, str)
|
|
1457
|
+
or not isinstance(candidate_scopes, list)
|
|
1458
|
+
):
|
|
1459
|
+
return False
|
|
1460
|
+
|
|
1461
|
+
target_key = target.casefold()
|
|
1462
|
+
candidate_scope_source = candidate.get("scope_source")
|
|
1463
|
+
|
|
1464
|
+
# A complete bounded directory is the existing indirect resolution
|
|
1465
|
+
# path. It contains only active IDs from the requested inherited
|
|
1466
|
+
# scope, so preserve its exact-ID selection semantics rather than
|
|
1467
|
+
# forcing a body relevance check that the directory intentionally
|
|
1468
|
+
# cannot provide.
|
|
1469
|
+
if scope_directory is not None:
|
|
1470
|
+
if not scope_directory_complete:
|
|
1471
|
+
return False
|
|
1472
|
+
selected = next(
|
|
1473
|
+
(
|
|
1474
|
+
item
|
|
1475
|
+
for item in scope_directory
|
|
1476
|
+
if isinstance(item, Mapping)
|
|
1477
|
+
and isinstance(item.get("memory_id"), str)
|
|
1478
|
+
and item["memory_id"].casefold() == target_key
|
|
1479
|
+
),
|
|
1480
|
+
None,
|
|
1481
|
+
)
|
|
1482
|
+
if selected is not None and selected.get("type") == candidate.get("type"):
|
|
1483
|
+
return True
|
|
1484
|
+
|
|
1485
|
+
# Model-attributed, directly named scopes must match the active
|
|
1486
|
+
# target's stable topic title. Matching the whole old body is too
|
|
1487
|
+
# permissive: two different project memories can share implementation
|
|
1488
|
+
# details such as milestones while serving different future uses.
|
|
1489
|
+
if candidate_scope_source == "model":
|
|
1490
|
+
scope_terms: list[str] = []
|
|
1491
|
+
try:
|
|
1492
|
+
scope_config = self.service.vault.config()
|
|
1493
|
+
configured_scopes = scope_config.get("scopes", {})
|
|
1494
|
+
except (OSError, UnicodeError, ValueError, TypeError):
|
|
1495
|
+
scope_config = {}
|
|
1496
|
+
configured_scopes = {}
|
|
1497
|
+
for candidate_scope in candidate_scopes:
|
|
1498
|
+
if not isinstance(candidate_scope, str) or not candidate_scope.startswith("project:"):
|
|
1499
|
+
continue
|
|
1500
|
+
scope_terms.append(candidate_scope.partition(":")[2])
|
|
1501
|
+
metadata = (
|
|
1502
|
+
configured_scopes.get(candidate_scope)
|
|
1503
|
+
if isinstance(configured_scopes, Mapping)
|
|
1504
|
+
else None
|
|
1505
|
+
)
|
|
1506
|
+
aliases = metadata.get("aliases") if isinstance(metadata, Mapping) else None
|
|
1507
|
+
if isinstance(aliases, list):
|
|
1508
|
+
scope_terms.extend(
|
|
1509
|
+
alias for alias in aliases if isinstance(alias, str) and alias
|
|
1510
|
+
)
|
|
1511
|
+
|
|
1512
|
+
# The regression is project-scope drift inside aggregate turns.
|
|
1513
|
+
# Global/domain/portfolio targets keep their established semantic
|
|
1514
|
+
# gate behavior because there is no project identity to remove
|
|
1515
|
+
# before comparing stable topics.
|
|
1516
|
+
if not scope_terms:
|
|
1517
|
+
return True
|
|
1518
|
+
|
|
1519
|
+
visible_turn = " ".join(
|
|
1520
|
+
event.content for event in turn.events if isinstance(event.content, str)
|
|
1521
|
+
)
|
|
1522
|
+
if scope_terms and not any(
|
|
1523
|
+
normalize_term(term) in normalize_term(visible_turn) for term in scope_terms
|
|
1524
|
+
):
|
|
1525
|
+
# The visible turn is elliptical (for example, "this
|
|
1526
|
+
# project"); the inherited scope and bounded related context
|
|
1527
|
+
# already resolved its target.
|
|
1528
|
+
return True
|
|
1529
|
+
|
|
1530
|
+
target_memory = None
|
|
1531
|
+
with self.service.vault.lock():
|
|
1532
|
+
for record in self.service._read_memories_unlocked("knowledge"):
|
|
1533
|
+
if record.memory.memory_id.casefold() == target_key:
|
|
1534
|
+
target_memory = record.memory
|
|
1535
|
+
break
|
|
1536
|
+
if target_memory is None:
|
|
1537
|
+
for item in self._planned_related:
|
|
1538
|
+
if (
|
|
1539
|
+
isinstance(item, Mapping)
|
|
1540
|
+
and isinstance(item.get("memory_id"), str)
|
|
1541
|
+
and item["memory_id"].casefold() == target_key
|
|
1542
|
+
):
|
|
1543
|
+
try:
|
|
1544
|
+
target_memory = Memory.from_mapping(item)
|
|
1545
|
+
except (TypeError, ValueError):
|
|
1546
|
+
target_memory = None
|
|
1547
|
+
break
|
|
1548
|
+
if target_memory is None:
|
|
1549
|
+
return False
|
|
1550
|
+
if not filter_by_scope([target_memory], candidate_scopes, scope_config):
|
|
1551
|
+
return False
|
|
1552
|
+
|
|
1553
|
+
def without_scope_terms(value: str) -> str:
|
|
1554
|
+
result = value
|
|
1555
|
+
for term in sorted(set(scope_terms), key=len, reverse=True):
|
|
1556
|
+
result = re.sub(re.escape(term), "", result, flags=re.IGNORECASE)
|
|
1557
|
+
return result.strip()
|
|
1558
|
+
|
|
1559
|
+
topic_title = without_scope_terms(target_memory.title)
|
|
1560
|
+
topic_query = without_scope_terms(memory)
|
|
1561
|
+
if not topic_title or not topic_query:
|
|
1562
|
+
return False
|
|
1563
|
+
topic_memory = Memory(
|
|
1564
|
+
memory_id=target_memory.memory_id,
|
|
1565
|
+
title=topic_query,
|
|
1566
|
+
body="",
|
|
1567
|
+
type=target_memory.type,
|
|
1568
|
+
scopes=target_memory.scopes,
|
|
1569
|
+
)
|
|
1570
|
+
return candidate_matches_query(topic_memory, topic_title)
|
|
1571
|
+
|
|
1572
|
+
# User/session scopes are authoritative indirect context. The base
|
|
1573
|
+
# parser has already limited the target to an active related memory.
|
|
1574
|
+
return candidate_scope_source in {"user", "session_context"}
|
|
1575
|
+
|
|
1415
1576
|
def _defer_candidate(
|
|
1416
1577
|
self,
|
|
1417
1578
|
turn_ref: tuple[str, str, str],
|
|
@@ -1614,6 +1775,7 @@ class Processor:
|
|
|
1614
1775
|
related_native_ids=related_native_ids,
|
|
1615
1776
|
related_memory_ids=related_memory_ids,
|
|
1616
1777
|
scope_registry=validation_scope_registry,
|
|
1778
|
+
allow_no_change=False,
|
|
1617
1779
|
),
|
|
1618
1780
|
diagnostic_context={
|
|
1619
1781
|
"source": turn.source,
|
|
@@ -1635,6 +1797,60 @@ class Processor:
|
|
|
1635
1797
|
list(summary["scopes"]),
|
|
1636
1798
|
)
|
|
1637
1799
|
|
|
1800
|
+
gate_attempt_count = 0
|
|
1801
|
+
|
|
1802
|
+
def parse_gate(raw: str) -> dict[str, Any]:
|
|
1803
|
+
nonlocal gate_attempt_count
|
|
1804
|
+
gate_attempt_count += 1
|
|
1805
|
+
parsed = parse_gate_output(
|
|
1806
|
+
raw,
|
|
1807
|
+
current_event_keys=turn.event_keys,
|
|
1808
|
+
related_memory_ids=gate_related_memory_ids,
|
|
1809
|
+
related_memory_types=gate_related_memory_types,
|
|
1810
|
+
scope_registry=validation_scope_registry,
|
|
1811
|
+
)
|
|
1812
|
+
invalid_targets: dict[str, set[str]] = {}
|
|
1813
|
+
for candidate in parsed["candidates"]:
|
|
1814
|
+
target_fields = {
|
|
1815
|
+
field
|
|
1816
|
+
for field in ("duplicate_memory_id", "update_memory_id")
|
|
1817
|
+
if isinstance(candidate.get(field), str) and candidate.get(field)
|
|
1818
|
+
}
|
|
1819
|
+
if target_fields and not self._target_is_relevant_to_candidate(
|
|
1820
|
+
turn,
|
|
1821
|
+
candidate,
|
|
1822
|
+
scope_directory=scope_directory,
|
|
1823
|
+
scope_directory_complete=scope_directory_complete,
|
|
1824
|
+
):
|
|
1825
|
+
invalid_targets[candidate["candidate_id"].casefold()] = target_fields
|
|
1826
|
+
|
|
1827
|
+
if invalid_targets and gate_attempt_count < 3:
|
|
1828
|
+
raise ModelOutputError(
|
|
1829
|
+
"selected target is not relevant to the candidate topic",
|
|
1830
|
+
validation_detail="target_not_relevant",
|
|
1831
|
+
)
|
|
1832
|
+
|
|
1833
|
+
if invalid_targets:
|
|
1834
|
+
# A persistently non-converging model must not hold an entire
|
|
1835
|
+
# inbox turn hostage. An unrelated update target can safely
|
|
1836
|
+
# become an independent CREATE candidate; an unrelated
|
|
1837
|
+
# duplicate has no independent fact to write and is dropped.
|
|
1838
|
+
candidates: list[dict[str, Any]] = []
|
|
1839
|
+
for candidate in parsed["candidates"]:
|
|
1840
|
+
fields = invalid_targets.get(candidate["candidate_id"].casefold())
|
|
1841
|
+
if not fields:
|
|
1842
|
+
candidates.append(candidate)
|
|
1843
|
+
continue
|
|
1844
|
+
if "duplicate_memory_id" in fields:
|
|
1845
|
+
continue
|
|
1846
|
+
if "update_memory_id" in fields and candidate.get("worth"):
|
|
1847
|
+
independent = dict(candidate)
|
|
1848
|
+
independent.pop("update_memory_id", None)
|
|
1849
|
+
candidates.append(independent)
|
|
1850
|
+
parsed = dict(parsed)
|
|
1851
|
+
parsed["candidates"] = candidates
|
|
1852
|
+
return parsed
|
|
1853
|
+
|
|
1638
1854
|
gate = self._complete_json_stage(
|
|
1639
1855
|
backend,
|
|
1640
1856
|
gate_prompt(
|
|
@@ -1647,12 +1863,7 @@ class Processor:
|
|
|
1647
1863
|
),
|
|
1648
1864
|
system=GATE_SYSTEM,
|
|
1649
1865
|
purpose="gate",
|
|
1650
|
-
parser=
|
|
1651
|
-
raw,
|
|
1652
|
-
current_event_keys=turn.event_keys,
|
|
1653
|
-
related_memory_ids=gate_related_memory_ids,
|
|
1654
|
-
related_memory_types=gate_related_memory_types,
|
|
1655
|
-
),
|
|
1866
|
+
parser=parse_gate,
|
|
1656
1867
|
diagnostic_context={
|
|
1657
1868
|
"source": turn.source,
|
|
1658
1869
|
"session_id": turn.session_id,
|
|
@@ -1663,13 +1874,6 @@ class Processor:
|
|
|
1663
1874
|
observed_scopes: list[str] = []
|
|
1664
1875
|
for candidate in gate["candidates"]:
|
|
1665
1876
|
candidate_scopes = list(candidate["scopes"])
|
|
1666
|
-
if (
|
|
1667
|
-
candidate_scopes != ["unscoped"]
|
|
1668
|
-
and candidate.get("scope_source") != "insufficient_context"
|
|
1669
|
-
):
|
|
1670
|
-
for observed_scope in candidate_scopes:
|
|
1671
|
-
if observed_scope not in observed_scopes:
|
|
1672
|
-
observed_scopes.append(observed_scope)
|
|
1673
1877
|
# An automatic candidate with no reliable project attribution is
|
|
1674
1878
|
# retained as a retryable inbox turn, never silently promoted to
|
|
1675
1879
|
# global knowledge. The processed ledger records only a compact
|
|
@@ -1708,15 +1912,7 @@ class Processor:
|
|
|
1708
1912
|
candidate_related, candidate_scope_background, candidate_native_refs, _ = self._related_query(
|
|
1709
1913
|
turn,
|
|
1710
1914
|
state,
|
|
1711
|
-
(
|
|
1712
|
-
str(candidate.get("memory", ""))
|
|
1713
|
-
+ "\n"
|
|
1714
|
-
+ " ".join(
|
|
1715
|
-
event.get("content", "")
|
|
1716
|
-
for event in events
|
|
1717
|
-
if isinstance(event.get("content"), str)
|
|
1718
|
-
)
|
|
1719
|
-
).strip(),
|
|
1915
|
+
str(candidate.get("memory", "")).strip(),
|
|
1720
1916
|
candidate_scopes,
|
|
1721
1917
|
overlay=self._planned_related,
|
|
1722
1918
|
priority_memory_ids=[
|
|
@@ -1758,6 +1954,27 @@ class Processor:
|
|
|
1758
1954
|
native_refs=candidate_native_refs,
|
|
1759
1955
|
)
|
|
1760
1956
|
)
|
|
1957
|
+
# Automatic duplicate observations are metadata no-ops;
|
|
1958
|
+
# only the already-active target's scopes are trustworthy
|
|
1959
|
+
# session context, never a transient model-provided scope.
|
|
1960
|
+
duplicate_scopes = next(
|
|
1961
|
+
(
|
|
1962
|
+
item.get("scopes")
|
|
1963
|
+
for item in candidate_related
|
|
1964
|
+
if isinstance(item, Mapping)
|
|
1965
|
+
and isinstance(item.get("memory_id"), str)
|
|
1966
|
+
and item["memory_id"].casefold() == duplicate_memory_id.casefold()
|
|
1967
|
+
and isinstance(item.get("scopes"), list)
|
|
1968
|
+
),
|
|
1969
|
+
[],
|
|
1970
|
+
)
|
|
1971
|
+
for observed_scope in duplicate_scopes:
|
|
1972
|
+
if (
|
|
1973
|
+
isinstance(observed_scope, str)
|
|
1974
|
+
and observed_scope != "unscoped"
|
|
1975
|
+
and observed_scope not in observed_scopes
|
|
1976
|
+
):
|
|
1977
|
+
observed_scopes.append(observed_scope)
|
|
1761
1978
|
continue
|
|
1762
1979
|
|
|
1763
1980
|
gate_update_target = candidate.get("update_memory_id")
|
|
@@ -1793,6 +2010,9 @@ class Processor:
|
|
|
1793
2010
|
related_native_ids=candidate_native_ids,
|
|
1794
2011
|
related_memory_ids=candidate_memory_ids,
|
|
1795
2012
|
scope_registry=validation_scope_registry,
|
|
2013
|
+
expected_scopes=candidate["scopes"],
|
|
2014
|
+
expected_scope_source=candidate["scope_source"],
|
|
2015
|
+
allow_no_change=True,
|
|
1796
2016
|
expected_type=(
|
|
1797
2017
|
candidate.get("type")
|
|
1798
2018
|
if (
|
|
@@ -1814,6 +2034,8 @@ class Processor:
|
|
|
1814
2034
|
"turn_index": turn.turn_index,
|
|
1815
2035
|
},
|
|
1816
2036
|
)
|
|
2037
|
+
if summary.get("decision") == NO_CHANGE_DECISION:
|
|
2038
|
+
continue
|
|
1817
2039
|
if gate_update_target is not None:
|
|
1818
2040
|
summary_update_target = summary.get("update_memory_id")
|
|
1819
2041
|
if summary_update_target is None:
|
|
@@ -1839,9 +2061,6 @@ class Processor:
|
|
|
1839
2061
|
scope_source=summary.get("scope_source"),
|
|
1840
2062
|
)
|
|
1841
2063
|
continue
|
|
1842
|
-
for observed_scope in summary["scopes"]:
|
|
1843
|
-
if observed_scope not in observed_scopes:
|
|
1844
|
-
observed_scopes.append(observed_scope)
|
|
1845
2064
|
requests.append(
|
|
1846
2065
|
self._request(
|
|
1847
2066
|
summary,
|
|
@@ -1851,6 +2070,13 @@ class Processor:
|
|
|
1851
2070
|
native_refs=candidate_native_refs,
|
|
1852
2071
|
)
|
|
1853
2072
|
)
|
|
2073
|
+
for observed_scope in summary["scopes"]:
|
|
2074
|
+
if (
|
|
2075
|
+
isinstance(observed_scope, str)
|
|
2076
|
+
and observed_scope != "unscoped"
|
|
2077
|
+
and observed_scope not in observed_scopes
|
|
2078
|
+
):
|
|
2079
|
+
observed_scopes.append(observed_scope)
|
|
1854
2080
|
return requests, observed_scopes
|
|
1855
2081
|
|
|
1856
2082
|
def _state_for_snapshot_unlocked(self, snapshot: _Snapshot, processed: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
@@ -34,6 +34,15 @@ useful. Related active memories only identify a complete duplicate or valid
|
|
|
34
34
|
state-update target; they are not current evidence and cannot make a non-worthy
|
|
35
35
|
operational/test result worthy. Their item, body, and serialized-character
|
|
36
36
|
context is bounded; ellipses and omissions do not prove a fact is absent.
|
|
37
|
+
For a worthy candidate with scope_source=model, each selected project scope must
|
|
38
|
+
be grounded by this candidate's own memory text: use the project's registered
|
|
39
|
+
scope name or one of its registered aliases; for a new unregistered project
|
|
40
|
+
scope, use that project's name itself. Do not borrow a project name from another
|
|
41
|
+
event, the session background, related memories, or an aggregate mailbox turn.
|
|
42
|
+
If the candidate text does not identify exactly one selected project, choose an
|
|
43
|
+
evidence-supported project, use unscoped with insufficient_context, defer it, or
|
|
44
|
+
set worth=false. user and session_context scope sources may rely on their
|
|
45
|
+
authoritative scope context.
|
|
37
46
|
An update target's existing type is immutable: when the same future use is being
|
|
38
47
|
updated, candidate type must exactly equal the related active target type. If a
|
|
39
48
|
fact serves a different future use, do not force it onto that target; omit
|
|
@@ -156,6 +165,16 @@ When the gate candidate supplies update_memory_id, that target is immutable:
|
|
|
156
165
|
omit update_memory_id or copy that exact active ID, never replace it with a
|
|
157
166
|
different, guessed, native, or historical ID. Keep the summary type identical
|
|
158
167
|
to the gate candidate type and to the active update target type.
|
|
168
|
+
For automatic capture/process, independently re-check whether this candidate
|
|
169
|
+
still has a concrete future-use fact or action. If not, return exactly
|
|
170
|
+
{"decision":"NO_CHANGE"}; this is the only no-write summary response. Use it
|
|
171
|
+
for temporary delegation, waiting for feedback, a one-off meeting arrangement with
|
|
172
|
+
no durable outcome/decision/project constraint, or a subject/attachment-only item
|
|
173
|
+
without a concrete impact or conclusion.
|
|
174
|
+
Explicit remember mode never permits NO_CHANGE and must return the normal summary
|
|
175
|
+
object. When producing a normal automatic summary, copy the gate candidate's
|
|
176
|
+
scopes exactly. scope_source may be omitted to inherit the gate value, but an
|
|
177
|
+
explicit value must match it; never drift to another project.
|
|
159
178
|
For the same use, preserve still-valid facts, add confirmed progress, replace
|
|
160
179
|
contradictions, and never reduce the memory to the latest operation or create a
|
|
161
180
|
sibling with a new label.
|
|
@@ -306,6 +325,40 @@ SUMMARY_TYPE_CORRECTION = (
|
|
|
306
325
|
)
|
|
307
326
|
|
|
308
327
|
|
|
328
|
+
SUMMARY_SCOPE_CORRECTION = (
|
|
329
|
+
"Previous output violated: scope_drift. For a normal automatic summary, copy "
|
|
330
|
+
"the gate candidate scopes exactly and keep its scope_source unchanged. If "
|
|
331
|
+
"scope_source was omitted, inherit the gate value; an explicit value must "
|
|
332
|
+
"match it. Do not move the memory to another project. If the candidate has "
|
|
333
|
+
"no independent future-use fact or action, return exactly "
|
|
334
|
+
'{"decision":"NO_CHANGE"}. Return only the strict JSON object.'
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
TARGET_RELEVANCE_CORRECTION = (
|
|
339
|
+
"Previous output violated: target_not_relevant. An update or duplicate "
|
|
340
|
+
"target must match the candidate's own memory topic through the supplied "
|
|
341
|
+
"active memory; do not borrow relevance from another event in the same "
|
|
342
|
+
"mailbox turn, aggregate context, or a priority ID. For a different "
|
|
343
|
+
"future use, remove the target and emit a separate atomic candidate. An "
|
|
344
|
+
"indirect same-use update may use a complete scope directory or session "
|
|
345
|
+
"context only when exactly one supplied active target is clear; otherwise "
|
|
346
|
+
"leave the target unset or defer it. Return only the strict gate JSON object."
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
SCOPE_GROUNDING_CORRECTION = (
|
|
351
|
+
"Previous output violated: scope_not_grounded. For each worthy candidate "
|
|
352
|
+
"with scope_source=model, choose a project scope named by that candidate's "
|
|
353
|
+
"own memory text: use the registered project name or alias, or the name "
|
|
354
|
+
"itself for a new scope. Do not borrow a name from another event, related "
|
|
355
|
+
"memory, session background, or an aggregate mailbox turn. If exactly one "
|
|
356
|
+
"project cannot be supported, choose the evidence-supported scope, use "
|
|
357
|
+
"unscoped with insufficient_context, defer it, or set worth=false. Return "
|
|
358
|
+
"only the strict gate JSON object."
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
309
362
|
COMPACT_SYSTEM = """You are memleaf's memory compactor. Return JSON only.
|
|
310
363
|
Merge only the supplied low-priority memories when they express compatible
|
|
311
364
|
information. Return an object with a memories array; [] is a safe no-op.
|
|
@@ -398,6 +451,14 @@ def summarize_prompt(
|
|
|
398
451
|
f"Current scope registry (safe projection; no paths):\n"
|
|
399
452
|
f"{_json(scope_registry if scope_registry is not None else [])}\n"
|
|
400
453
|
)
|
|
454
|
+
if not explicit:
|
|
455
|
+
prompt += (
|
|
456
|
+
"Automatic admission re-check: if the candidate has no independent "
|
|
457
|
+
"future-use fact or action after reviewing the evidence, return exactly "
|
|
458
|
+
'{"decision":"NO_CHANGE"}; do not return an empty or partial memory object. '
|
|
459
|
+
"For a normal summary, copy Candidate scopes exactly; omit scope_source "
|
|
460
|
+
"to inherit it or repeat the same value, never choose another scope.\n"
|
|
461
|
+
)
|
|
401
462
|
example_key = _first_event_key(events)
|
|
402
463
|
if example_key is not None:
|
|
403
464
|
candidate_type = candidate.get("type") if isinstance(candidate, dict) else None
|
|
@@ -431,6 +492,8 @@ def summarize_prompt(
|
|
|
431
492
|
}
|
|
432
493
|
)
|
|
433
494
|
)
|
|
495
|
+
if not explicit:
|
|
496
|
+
return prompt + '\nReturn one summary JSON object or exactly {"decision":"NO_CHANGE"}.'
|
|
434
497
|
return prompt + "\nReturn one summary JSON object."
|
|
435
498
|
|
|
436
499
|
|
|
@@ -256,9 +256,20 @@ def _term_matches(query: str, term: str) -> bool:
|
|
|
256
256
|
def resolve_query_project_scope(query: Any, config: Mapping[str, Any]) -> str | None:
|
|
257
257
|
"""Return one unambiguous project scope named or aliased by ``query``."""
|
|
258
258
|
|
|
259
|
+
matches = project_scope_matches_text(query, config)
|
|
260
|
+
return matches[0] if len(matches) == 1 else None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def project_scope_matches_text(query: Any, config: Mapping[str, Any]) -> list[str]:
|
|
264
|
+
"""Return registered project scopes named or aliased by text.
|
|
265
|
+
|
|
266
|
+
Matching uses the same token/substring rules as query scope resolution,
|
|
267
|
+
but retains every match so callers can reject ambiguous model attribution.
|
|
268
|
+
"""
|
|
269
|
+
|
|
259
270
|
query_value = _query_text(query)
|
|
260
271
|
if not query_value:
|
|
261
|
-
return
|
|
272
|
+
return []
|
|
262
273
|
registry = validate_scope_registry(config.get("scopes", {}) if isinstance(config, Mapping) else {})
|
|
263
274
|
matches: set[str] = set()
|
|
264
275
|
for scope, node in registry.items():
|
|
@@ -271,7 +282,7 @@ def resolve_query_project_scope(query: Any, config: Mapping[str, Any]) -> str |
|
|
|
271
282
|
terms.extend(item for item in aliases if isinstance(item, str))
|
|
272
283
|
if any(_term_matches(query_value, term) for term in terms):
|
|
273
284
|
matches.add(scope)
|
|
274
|
-
return
|
|
285
|
+
return sorted(matches, key=str.casefold)
|
|
275
286
|
|
|
276
287
|
|
|
277
288
|
def _resolved_path(value: str | Path, *, base_dir: Path | None = None) -> Path:
|
|
@@ -320,6 +331,7 @@ def resolve_project_path_scope(
|
|
|
320
331
|
__all__ = [
|
|
321
332
|
"ScopeError",
|
|
322
333
|
"normalize_scopes",
|
|
334
|
+
"project_scope_matches_text",
|
|
323
335
|
"register_scope_nodes",
|
|
324
336
|
"resolve_project_path_scope",
|
|
325
337
|
"resolve_query_project_scope",
|