memleaf 0.2.77__tar.gz → 0.2.79__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.77 → memleaf-0.2.79}/CHANGELOG.md +23 -0
- {memleaf-0.2.77/src/memleaf.egg-info → memleaf-0.2.79}/PKG-INFO +2 -2
- {memleaf-0.2.77 → memleaf-0.2.79}/README.en.md +1 -1
- {memleaf-0.2.77 → memleaf-0.2.79}/README.md +1 -1
- {memleaf-0.2.77 → memleaf-0.2.79}/pyproject.toml +1 -1
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/_provider.py +221 -31
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/_shared.py +6 -1
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_commit.py +7 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_dates.py +2 -69
- memleaf-0.2.79/src/memleaf/incremental_explicit.py +211 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_journal.py +4 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_preview.py +15 -1
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_prompts.py +1 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_protocol.py +13 -16
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_recovery.py +9 -1
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/mcp_server.py +2 -1
- {memleaf-0.2.77 → memleaf-0.2.79/src/memleaf.egg-info}/PKG-INFO +2 -2
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf.egg-info/SOURCES.txt +1 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/LICENSE +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/MANIFEST.in +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/acceptance-evidence-guide.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/automatic-processing-route.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/body-compaction.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/candidate-reconciliation.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/capture-budget-design.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/config-migrations.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/core-refactor.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/development-closeout.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/evidence-retention.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/explicit-memory-update.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/extraction-latency.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/gate-evidence-boundary.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/general-processing.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/hermes-mcp-runtime.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/hermes-provider-compatibility.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/host-acceptance-runbook.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/immutable-release-assets.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-commit-contract.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-execution.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-native-comparison.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-partial-recovery.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-planner-preview.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-runtime-contract.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-scope-registration.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/incremental-selected-retention.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/installed-artifact-verification.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/memory-state-contract.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/migration-preflight.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/processing-quality-acceptance.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/public-query-integrity.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/query-performance.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/remember-incremental-route.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/runtime-state-retention.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/semantic-extraction-protocol.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/source-work-contract.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/docs/v0.2.26-processing-status.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/examples/README.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/examples/basic_usage.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/examples/incremental_acceptance.json +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/examples/query_benchmark.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/install.ps1 +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/install.sh +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/setup.cfg +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/acceptance.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/admission.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/batch_review.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/config.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/create_coordinator.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/evidence_budget.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/evidence_policy.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/evidence_structure.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/evidence_syntax.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/explicit_text_source.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/extraction_budget.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/extraction_capability.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/extraction_work_state.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_provider/provider_compatibility.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/hermes_runtime.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_execution.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_native.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_partial.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_run_state.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_runtime.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_scopes.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/incremental_selection.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/index.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/inspection.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/legacy_update_journal.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/llm/thinking.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/memory_commit.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/memory_planner.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/memory_retraction.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/memory_update.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/migration.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/model_capabilities.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/model_execution.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/models.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/native_registration.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/parallel_model.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/planning_context.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/process_common.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/process_jobs.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/process_journal.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/process_owner.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/processing.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/processing_route.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/prompts.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/provenance.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/provider_compatibility.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/query_clock.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/query_progress.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/query_scan.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/receipt_codec.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/recording_policy.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/remember_route.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/retention.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/runtime_retention.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/semantic_maintenance.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/semantic_protocol.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/service.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/single_pass_memory_planner.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/single_pass_plan.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/source_policy.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/state_layout.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/subprocess_flags.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/summary_batch.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/target_reconciliation.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/turn_audit.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/turn_plan.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/update_coordinator.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/update_review.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/validation.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.77 → memleaf-0.2.79}/src/memleaf.egg-info/top_level.txt +0 -0
|
@@ -4,6 +4,29 @@ All notable changes to memleaf are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.2.79 — 2026-09-29
|
|
8
|
+
|
|
9
|
+
### Model-owned content dates
|
|
10
|
+
|
|
11
|
+
- Remove evidence-grounding rejection for dates in memory titles and bodies;
|
|
12
|
+
date content is now judged by the extraction model, without a replacement
|
|
13
|
+
date-format gate.
|
|
14
|
+
- Retain structural protocol checks and source-time calendar conversion.
|
|
15
|
+
- Advance the incremental semantic protocol to v8 for the changed contract.
|
|
16
|
+
|
|
17
|
+
## 0.2.78 — 2026-09-29
|
|
18
|
+
|
|
19
|
+
### Hermes host-turn provenance and explicit-write context
|
|
20
|
+
|
|
21
|
+
- Capture corrected user messages as separate events from host admission
|
|
22
|
+
snapshots; reject incomplete or mismatched turn context instead of inferring
|
|
23
|
+
event identity from composed text.
|
|
24
|
+
- Bind external tool calls to verified host/session/turn identity, and keep
|
|
25
|
+
explicit-save receipts tied to their source evidence and target revision
|
|
26
|
+
across planning and recovery.
|
|
27
|
+
- Keep MCP retrieval authorization enforced at execution while allowing hosts
|
|
28
|
+
and bare clients to supply the retrieval token through their own transport.
|
|
29
|
+
|
|
7
30
|
## 0.2.77 — 2026-09-29
|
|
8
31
|
|
|
9
32
|
### Hermes source metadata pairing
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.79
|
|
4
4
|
Summary: A local-first Markdown memory core for AI agents
|
|
5
5
|
Author: memleaf contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -23,7 +23,7 @@ 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.
|
|
26
|
+
> **版本:0.2.79。**
|
|
27
27
|
> 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
|
|
28
28
|
> 提炼契约现在只有一份中文语义契约(旧版英文 B3 提示词已删除),首轮、主题选择、候选修复和 `semantic_maintenance` 使用同一套保留价值判断;同一件事只写一次。
|
|
29
29
|
> Core 不再因为归属、日期、任务依据或跨项目而丢弃整条候选:归属缺失会归一到证据里唯一被点名的项目、否则落 `global`;日期只接受能在本候选证据里找到出处、且能锚定成 ISO 的写法(`明天`、`周五`、`月底` 都会被解析),无法解析时只丢掉日期字段,记忆照常写入;未获授权的改归属沿用目标原归属。
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
|
|
6
6
|
|
|
7
|
-
> **Version: 0.2.
|
|
7
|
+
> **Version: 0.2.79.**
|
|
8
8
|
> Automatic extraction and review share one future-value standard: the model weighs likely reuse, information gain, direct usability when read again, and the cost of forgetting. It keeps only the smallest core that can materially help future understanding, decisions, or actions; information without clear value is not extracted, and no business-specific exclusion rule is hard-coded.
|
|
9
9
|
> A single Chinese semantic contract is sent for extraction, topic selection, candidate repair, and `semantic_maintenance`; the older English B3 prompt has been deleted and B3 remains only as a compatibility read format. One topic is written once.
|
|
10
10
|
> Core no longer discards a whole candidate over ownership, dates, task basis or cross-project wording. A missing owner normalizes to the single project named by that candidate's own evidence, otherwise `global`. A deadline is accepted only when it appears in the candidate's own evidence and can be anchored to ISO (`明天`, `周五`, `月底` all resolve); when it cannot, only the date field is dropped and the memory is still written. An unauthorized ownership change keeps the target's existing scope.
|
|
@@ -4,7 +4,7 @@
|
|
|
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.
|
|
7
|
+
> **版本:0.2.79。**
|
|
8
8
|
> 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
|
|
9
9
|
> 提炼契约现在只有一份中文语义契约(旧版英文 B3 提示词已删除),首轮、主题选择、候选修复和 `semantic_maintenance` 使用同一套保留价值判断;同一件事只写一次。
|
|
10
10
|
> Core 不再因为归属、日期、任务依据或跨项目而丢弃整条候选:归属缺失会归一到证据里唯一被点名的项目、否则落 `global`;日期只接受能在本候选证据里找到出处、且能锚定成 ISO 的写法(`明天`、`周五`、`月底` 都会被解析),无法解析时只丢掉日期字段,记忆照常写入;未获授权的改归属沿用目标原归属。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Local-first Markdown memory core for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
3
|
+
__version__ = "0.2.79"
|
|
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
|
|
@@ -34,7 +34,7 @@ def _sync_source_metadata(messages: Any, user_content: str, assistant_content: s
|
|
|
34
34
|
|
|
35
35
|
Hermes' base API does not guarantee IDs or timestamps. Do not consult tools,
|
|
36
36
|
attachments, older matching text, or a local clock to invent missing fields.
|
|
37
|
-
|
|
37
|
+
This legacy helper supplies metadata only; host-v1 uses admission snapshots.
|
|
38
38
|
"""
|
|
39
39
|
from datetime import datetime
|
|
40
40
|
from math import isfinite
|
|
@@ -55,10 +55,10 @@ def _sync_source_metadata(messages: Any, user_content: str, assistant_content: s
|
|
|
55
55
|
if isinstance(item, Mapping) and item.get("role") == "user"), None)
|
|
56
56
|
if user is None:
|
|
57
57
|
logger.info("memleaf source-metadata reason=user_missing")
|
|
58
|
-
return
|
|
58
|
+
return _independent_assistant_time(assistant, assistant_content)
|
|
59
59
|
if user.get("content") != user_content:
|
|
60
60
|
logger.info("memleaf source-metadata reason=user_mismatch")
|
|
61
|
-
return
|
|
61
|
+
return _independent_assistant_time(assistant, assistant_content)
|
|
62
62
|
result: dict[str, dict[str, Any]] = {}
|
|
63
63
|
for role, item in (("user", user), ("assistant", assistant)):
|
|
64
64
|
row: dict[str, Any] = {}
|
|
@@ -101,6 +101,115 @@ def _sync_source_metadata(messages: Any, user_content: str, assistant_content: s
|
|
|
101
101
|
return result
|
|
102
102
|
|
|
103
103
|
|
|
104
|
+
def _independent_assistant_time(assistant: Mapping[str, Any], content: str) -> dict[str, dict[str, Any]]:
|
|
105
|
+
from datetime import datetime
|
|
106
|
+
from math import isfinite
|
|
107
|
+
value = assistant.get("source_time")
|
|
108
|
+
if value is None:
|
|
109
|
+
value = assistant.get("timestamp")
|
|
110
|
+
parsed = None
|
|
111
|
+
try:
|
|
112
|
+
if type(value) in (int, float) and isfinite(value) and 0 <= value < 253402300800:
|
|
113
|
+
parsed = datetime.fromtimestamp(value).astimezone()
|
|
114
|
+
elif isinstance(value, str) and len(value) <= 80:
|
|
115
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
116
|
+
if parsed is not None and parsed.tzinfo is not None and parsed.utcoffset() is not None:
|
|
117
|
+
return {"assistant": {"source_time": parsed.isoformat()}}
|
|
118
|
+
except (ValueError, OSError, OverflowError):
|
|
119
|
+
pass
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _host_turn_id(context: Any) -> Optional[str]:
|
|
124
|
+
if not isinstance(context, Mapping) or context.get("version") != 1:
|
|
125
|
+
return None
|
|
126
|
+
raw = context.get("turn_id")
|
|
127
|
+
if not isinstance(raw, str) or not raw or len(raw) > 800 or any(c in raw for c in "\x00\r\n"):
|
|
128
|
+
return None
|
|
129
|
+
return "host-" + sha256(("v1:" + raw).encode()).hexdigest()[:24]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _host_visible_events(messages: Any, context: Any, session_id: str,
|
|
133
|
+
assistant_content: str) -> Optional[list[dict[str, Any]]]:
|
|
134
|
+
"""Resolve the host's enumerated visible user events, never split model text."""
|
|
135
|
+
if (_host_turn_id(context) is None or context.get("session_id") != session_id
|
|
136
|
+
or not isinstance(messages, list) or not messages):
|
|
137
|
+
return None
|
|
138
|
+
if type(context.get("turn_number")) is not int or context["turn_number"] <= 0:
|
|
139
|
+
return None
|
|
140
|
+
ids = context.get("user_message_ids")
|
|
141
|
+
if (not isinstance(ids, list) or not 1 <= len(ids) < 64
|
|
142
|
+
or any(not isinstance(i, str) or not i or len(i) > 800 for i in ids)
|
|
143
|
+
or len(set(ids)) != len(ids)):
|
|
144
|
+
return None
|
|
145
|
+
snapshots = context.get("user_events")
|
|
146
|
+
if (not isinstance(snapshots, list) or len(snapshots) != len(ids)
|
|
147
|
+
or any(not isinstance(row, Mapping) or row.get("role") != "user"
|
|
148
|
+
or row.get("message_uid") != uid for row, uid in zip(snapshots, ids))):
|
|
149
|
+
return None
|
|
150
|
+
final = messages[-1]
|
|
151
|
+
if (not isinstance(final, Mapping) or final.get("role") != "assistant"
|
|
152
|
+
or final.get("content") != assistant_content or final.get("tool_calls")):
|
|
153
|
+
return None
|
|
154
|
+
completed_uid = context.get("completed_assistant_message_id")
|
|
155
|
+
completed = completed_uid is not None
|
|
156
|
+
if completed and (not isinstance(completed_uid, str) or not completed_uid
|
|
157
|
+
or completed_uid != final.get("message_uid")):
|
|
158
|
+
return None
|
|
159
|
+
found = []
|
|
160
|
+
for uid, snapshot in zip(ids, snapshots):
|
|
161
|
+
matches = [(index, row) for index, row in enumerate(messages[:-1])
|
|
162
|
+
if isinstance(row, Mapping) and row.get("message_uid") == uid]
|
|
163
|
+
if len(matches) > 1 or (matches and matches[0][1].get("role") != "user"):
|
|
164
|
+
return None
|
|
165
|
+
if not matches and not completed:
|
|
166
|
+
return None
|
|
167
|
+
index = matches[0][0] if matches else None
|
|
168
|
+
# Live API content can contain host/model scaffolding. Only the clean
|
|
169
|
+
# admission snapshot supplies user facts and timestamps; live rows prove
|
|
170
|
+
# UID/role/order membership, not their subsequently modified text.
|
|
171
|
+
visible = _visible_message_text(snapshot)
|
|
172
|
+
if not visible.strip():
|
|
173
|
+
return None
|
|
174
|
+
item = {key: snapshot[key] for key in ("message_uid", "source_time", "timestamp", "source_sequence") if key in snapshot}
|
|
175
|
+
found.append((index, dict(item, role="user", content=visible)))
|
|
176
|
+
positions = [i for i, row in found if i is not None]
|
|
177
|
+
if positions != sorted(positions):
|
|
178
|
+
return None
|
|
179
|
+
# Every actual user after the anchor must belong to this turn. A stale
|
|
180
|
+
# context cannot capture a subsequent turn or borrow an old matching body.
|
|
181
|
+
if not completed:
|
|
182
|
+
actual = [row.get("message_uid") for row in messages[positions[0]:-1]
|
|
183
|
+
if isinstance(row, Mapping) and row.get("role") == "user"]
|
|
184
|
+
if actual != ids:
|
|
185
|
+
return None
|
|
186
|
+
# A completed host context is bound to this final UID and was frozen while
|
|
187
|
+
# its turn was active. Its admission ledger survives lossy compaction; API
|
|
188
|
+
# summary carriers are not new user events and never supply captured facts.
|
|
189
|
+
if not isinstance(final.get("message_uid"), str) or not final["message_uid"]:
|
|
190
|
+
return None
|
|
191
|
+
assistant = dict(final)
|
|
192
|
+
if isinstance(final.get("message_uid"), str):
|
|
193
|
+
assistant["message_id"] = final["message_uid"]
|
|
194
|
+
events = []
|
|
195
|
+
final_metadata = {}
|
|
196
|
+
for _, item in found:
|
|
197
|
+
user = dict(item, message_id=item["message_uid"])
|
|
198
|
+
pair = _sync_source_metadata([user, assistant], user["content"], assistant_content)
|
|
199
|
+
events.append({"role": "user", "content": user["content"], "metadata": pair.get("user", {})})
|
|
200
|
+
final_metadata = pair.get("assistant", {})
|
|
201
|
+
# Never manufacture a sequence by assuming a turn always has two events.
|
|
202
|
+
if not all(type(row.get("source_sequence")) is int for row in [r for _, r in found] + [final]):
|
|
203
|
+
for event in events:
|
|
204
|
+
event["metadata"].pop("source_sequence", None)
|
|
205
|
+
final_metadata.pop("source_sequence", None)
|
|
206
|
+
if events[-1]["metadata"].get("message_id"):
|
|
207
|
+
final_metadata["previous_message_id"] = events[-1]["metadata"]["message_id"]
|
|
208
|
+
events.append({"role": "assistant", "content": _capture_assistant_text(assistant_content),
|
|
209
|
+
"metadata": final_metadata})
|
|
210
|
+
return events
|
|
211
|
+
|
|
212
|
+
|
|
104
213
|
def _capture_policy_status(config: Mapping[str, Any], vault: Path) -> dict[str, Any]:
|
|
105
214
|
"""Read the effective policy through the public Core MCP stats result.
|
|
106
215
|
|
|
@@ -196,6 +305,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
196
305
|
# bounded session -> job mapping so a later turn can poll the prior
|
|
197
306
|
# result and request a rerun on the same job while it is active.
|
|
198
307
|
self._process_jobs_by_session: "OrderedDict[str, str]" = OrderedDict()
|
|
308
|
+
self._bound_retrieval_calls: "OrderedDict[Tuple[str, str, str, str], str]" = OrderedDict()
|
|
199
309
|
self._last_retrieval_observation = "unknown"
|
|
200
310
|
self._last_retrieval_audit = "SEARCH_UNKNOWN"
|
|
201
311
|
|
|
@@ -842,14 +952,14 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
842
952
|
)
|
|
843
953
|
self._version_warning_emitted = True
|
|
844
954
|
|
|
845
|
-
def _queue_turn_number(self, turn_number: Any, message: Any) -> None:
|
|
955
|
+
def _queue_turn_number(self, turn_number: Any, message: Any, *, session_id: Optional[str] = None) -> None:
|
|
846
956
|
if isinstance(turn_number, bool) or not isinstance(turn_number, int) or turn_number <= 0:
|
|
847
957
|
return
|
|
848
958
|
visible_user = _visible_message_text(message)
|
|
849
959
|
if not visible_user.strip():
|
|
850
960
|
return
|
|
851
961
|
fingerprint = _visible_fingerprint(visible_user)
|
|
852
|
-
session_id = self._canonical_session_id(self._session_id)
|
|
962
|
+
session_id = self._canonical_session_id(session_id or self._session_id)
|
|
853
963
|
with self._sync_lock:
|
|
854
964
|
queue = self._pending_turn_numbers.get(fingerprint)
|
|
855
965
|
if queue is None:
|
|
@@ -950,7 +1060,11 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
950
1060
|
def on_turn_start(self, turn_number: Any, message: Any = None, **kwargs: Any) -> None:
|
|
951
1061
|
"""Remember only a bounded user-text fingerprint for later ``sync_turn``."""
|
|
952
1062
|
|
|
953
|
-
|
|
1063
|
+
host_context = kwargs.get("turn_context")
|
|
1064
|
+
host_session = host_context.get("session_id") if isinstance(host_context, Mapping) else None
|
|
1065
|
+
turn_session = self._canonical_session_id(
|
|
1066
|
+
host_session if _host_turn_id(host_context) and isinstance(host_session, str) and host_session
|
|
1067
|
+
else self._session_id)
|
|
954
1068
|
if not self._write_enabled:
|
|
955
1069
|
return
|
|
956
1070
|
if self._gate_enabled:
|
|
@@ -958,15 +1072,15 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
958
1072
|
# The token is created by the MCP server during this turn's
|
|
959
1073
|
# scope_catalog call. Clearing the active value here prevents
|
|
960
1074
|
# a skipped prefetch from reusing the previous turn's token.
|
|
961
|
-
self._active_turn_numbers[
|
|
962
|
-
self._active_turn_numbers.move_to_end(
|
|
963
|
-
self._active_retrieval_ids[
|
|
964
|
-
self._active_retrieval_ids.move_to_end(
|
|
1075
|
+
self._active_turn_numbers[turn_session] = turn_number
|
|
1076
|
+
self._active_turn_numbers.move_to_end(turn_session)
|
|
1077
|
+
self._active_retrieval_ids[turn_session] = None
|
|
1078
|
+
self._active_retrieval_ids.move_to_end(turn_session)
|
|
965
1079
|
visible_user = _visible_message_text(message)
|
|
966
|
-
self._gate_turn_ids[(
|
|
967
|
-
f"turn-{turn_number:06d}-{_visible_fingerprint(visible_user)}"
|
|
1080
|
+
self._gate_turn_ids[(turn_session, turn_number)] = (
|
|
1081
|
+
_host_turn_id(host_context) or f"turn-{turn_number:06d}-{_visible_fingerprint(visible_user)}"
|
|
968
1082
|
)
|
|
969
|
-
self._gate_turn_ids.move_to_end((
|
|
1083
|
+
self._gate_turn_ids.move_to_end((turn_session, turn_number))
|
|
970
1084
|
while len(self._active_turn_numbers) > _MAX_PENDING_TURN_NUMBERS:
|
|
971
1085
|
self._active_turn_numbers.popitem(last=False)
|
|
972
1086
|
while len(self._active_retrieval_ids) > _MAX_PENDING_TURN_NUMBERS:
|
|
@@ -974,7 +1088,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
974
1088
|
while len(self._gate_turn_ids) > _MAX_PENDING_TURN_NUMBERS:
|
|
975
1089
|
self._gate_turn_ids.popitem(last=False)
|
|
976
1090
|
self._last_retrieval_observation = "not_observed"
|
|
977
|
-
self._queue_turn_number(turn_number, message)
|
|
1091
|
+
self._queue_turn_number(turn_number, message, session_id=turn_session)
|
|
978
1092
|
|
|
979
1093
|
def on_session_switch(
|
|
980
1094
|
self,
|
|
@@ -1413,6 +1527,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1413
1527
|
vault_root: Optional[Path] = None,
|
|
1414
1528
|
seen_call_keys: Any = None,
|
|
1415
1529
|
audit_state: Optional[dict[str, Any]] = None,
|
|
1530
|
+
bound_retrieval_calls: Optional[Mapping[Tuple[str, str, str, str], str]] = None,
|
|
1416
1531
|
) -> str:
|
|
1417
1532
|
"""Observe explicit host MCP calls in public messages.
|
|
1418
1533
|
|
|
@@ -1433,6 +1548,9 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1433
1548
|
continue
|
|
1434
1549
|
search_ordinal += 1
|
|
1435
1550
|
arguments = call.get("arguments")
|
|
1551
|
+
bound_token = (bound_retrieval_calls or {}).get((session_id, turn_id, call["name"], call.get("call_id", "")))
|
|
1552
|
+
if bound_token == retrieval_id and isinstance(retrieval_id, str):
|
|
1553
|
+
arguments = {"retrieval_id": bound_token}
|
|
1436
1554
|
if not isinstance(arguments, Mapping) or arguments.get("retrieval_id") != retrieval_id:
|
|
1437
1555
|
continue
|
|
1438
1556
|
payload = _tool_result_for_call(call, calls, results, search_results_used)
|
|
@@ -1455,6 +1573,9 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1455
1573
|
continue
|
|
1456
1574
|
read_ordinal += 1
|
|
1457
1575
|
arguments = call.get("arguments")
|
|
1576
|
+
bound_token = (bound_retrieval_calls or {}).get((session_id, turn_id, call["name"], call.get("call_id", "")))
|
|
1577
|
+
if bound_token == retrieval_id and isinstance(retrieval_id, str):
|
|
1578
|
+
arguments = {"retrieval_id": bound_token}
|
|
1458
1579
|
retrieval_present = isinstance(arguments, Mapping) and "retrieval_id" in arguments
|
|
1459
1580
|
retrieval_match = bool(
|
|
1460
1581
|
retrieval_present
|
|
@@ -1611,6 +1732,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1611
1732
|
catalog,
|
|
1612
1733
|
retrieval_id=retrieval_id,
|
|
1613
1734
|
scope_hint=_unique_query_scope(query, catalog),
|
|
1735
|
+
host_bound=bool((self._gate_turn_id(safe_session, turn_number) or "").startswith("host-")),
|
|
1614
1736
|
)
|
|
1615
1737
|
if not context:
|
|
1616
1738
|
notices = [
|
|
@@ -1642,13 +1764,13 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1642
1764
|
session_id: str = "",
|
|
1643
1765
|
messages: Optional[List[Dict[str, Any]]] = None,
|
|
1644
1766
|
turn_number: Optional[int] = None,
|
|
1767
|
+
turn_context: Optional[Mapping[str, Any]] = None,
|
|
1645
1768
|
) -> None:
|
|
1646
1769
|
# ``messages`` may contain system prompts, tool calls/results, and
|
|
1647
|
-
# attachment parts.
|
|
1648
|
-
#
|
|
1649
|
-
#
|
|
1650
|
-
#
|
|
1651
|
-
# Never capture the raw message list as business conversation content.
|
|
1770
|
+
# attachment parts. Host-v1 captures clean admission snapshots after
|
|
1771
|
+
# checking message membership; legacy input uses the supplied visible
|
|
1772
|
+
# strings and exact tail metadata. Tool results supply search status,
|
|
1773
|
+
# never user facts. Never capture the raw API message list as content.
|
|
1652
1774
|
if not self._write_enabled or self._client is None:
|
|
1653
1775
|
return
|
|
1654
1776
|
raw_assistant_content = assistant_content
|
|
@@ -1666,26 +1788,41 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1666
1788
|
# captured turn within this provider instance.
|
|
1667
1789
|
with self._sync_lock:
|
|
1668
1790
|
try:
|
|
1791
|
+
capture_context = turn_context
|
|
1792
|
+
if (isinstance(turn_context, Mapping) and isinstance(turn_context.get("session_id"), str)
|
|
1793
|
+
and self._canonical_session_id(turn_context["session_id"]) == effective_session):
|
|
1794
|
+
capture_context = {**turn_context, "session_id": effective_session}
|
|
1795
|
+
host_events = _host_visible_events(messages, capture_context, effective_session, raw_assistant_content)
|
|
1796
|
+
bound_turn_id = (_host_turn_id(capture_context) if isinstance(capture_context, Mapping)
|
|
1797
|
+
and capture_context.get("session_id") == effective_session else None)
|
|
1798
|
+
if turn_context is not None and host_events is None:
|
|
1799
|
+
logger.info("memleaf source-metadata reason=host_event_mismatch")
|
|
1800
|
+
return
|
|
1669
1801
|
resolved_turn_number = turn_number
|
|
1802
|
+
if bound_turn_id and type(turn_context.get("turn_number")) is int:
|
|
1803
|
+
resolved_turn_number = turn_context["turn_number"]
|
|
1670
1804
|
if resolved_turn_number is None:
|
|
1671
1805
|
resolved_turn_number = self._take_turn_number(effective_session, user_content)
|
|
1672
1806
|
elif isinstance(resolved_turn_number, int) and not isinstance(resolved_turn_number, bool):
|
|
1673
|
-
|
|
1807
|
+
queued_content = host_events[0]["content"] if host_events else user_content
|
|
1808
|
+
self._discard_turn_number(effective_session, queued_content, resolved_turn_number)
|
|
1674
1809
|
turn_id = self._resolve_turn_id(
|
|
1675
1810
|
effective_session,
|
|
1676
1811
|
resolved_turn_number,
|
|
1677
1812
|
user_content,
|
|
1678
1813
|
captured_assistant_content,
|
|
1679
1814
|
)
|
|
1680
|
-
source_metadata =
|
|
1815
|
+
source_metadata = ({} if host_events is not None else
|
|
1816
|
+
_sync_source_metadata(messages, user_content, raw_assistant_content))
|
|
1681
1817
|
user_source_id = source_metadata.get("user", {}).get("message_id")
|
|
1682
|
-
if
|
|
1818
|
+
if bound_turn_id:
|
|
1819
|
+
turn_id = bound_turn_id
|
|
1820
|
+
elif user_source_id:
|
|
1683
1821
|
# Stable host identity survives a revised body/final reply.
|
|
1684
1822
|
turn_id = "source-" + sha256(user_source_id.encode("utf-8")).hexdigest()[:24]
|
|
1685
1823
|
source_metadata["assistant"].setdefault("previous_message_id", user_source_id)
|
|
1686
1824
|
retrieval_id = self._gate_id_for_turn(effective_session, resolved_turn_number)
|
|
1687
|
-
|
|
1688
|
-
retrieval_id = self._current_gate_id(effective_session)
|
|
1825
|
+
# A background callback must never attach itself to a newer turn.
|
|
1689
1826
|
if self._gate_enabled:
|
|
1690
1827
|
audit_state: dict[str, Any] = {}
|
|
1691
1828
|
observation = self._observe_search_messages(
|
|
@@ -1696,22 +1833,31 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1696
1833
|
vault_root=_resolve_vault(self._config()),
|
|
1697
1834
|
seen_call_keys=self._observed_tool_call_keys,
|
|
1698
1835
|
audit_state=audit_state,
|
|
1836
|
+
bound_retrieval_calls=self._bound_retrieval_calls,
|
|
1699
1837
|
)
|
|
1700
1838
|
while len(self._observed_tool_call_keys) > _MAX_OBSERVED_TOOL_CALL_KEYS:
|
|
1701
1839
|
self._observed_tool_call_keys.popitem(last=False)
|
|
1702
1840
|
self._last_retrieval_observation = observation
|
|
1703
1841
|
self._last_retrieval_audit = str(audit_state.get("status") or "SEARCH_UNKNOWN")
|
|
1704
1842
|
lineage_ready = self._retry_pending_lineage(effective_session)
|
|
1705
|
-
|
|
1706
|
-
|
|
1843
|
+
if host_events is not None:
|
|
1844
|
+
logger.info("memleaf source-metadata contract=host-v1 user_events=%d source=hermes session=%s turn=%s",
|
|
1845
|
+
len(host_events) - 1, effective_session, turn_id)
|
|
1846
|
+
capture_events = host_events
|
|
1847
|
+
sequence_base = None
|
|
1848
|
+
else:
|
|
1849
|
+
capture_events = [{"role": role, "content": content, "metadata": source_metadata.get(role)}
|
|
1850
|
+
for role, content in visible_events]
|
|
1851
|
+
sequence_base = (resolved_turn_number * 2
|
|
1852
|
+
if not bound_turn_id and isinstance(resolved_turn_number, int) else None)
|
|
1853
|
+
for offset, event in enumerate(capture_events):
|
|
1854
|
+
role = event["role"]
|
|
1707
1855
|
if not self._capture_visible(
|
|
1708
|
-
session_id=effective_session,
|
|
1709
|
-
|
|
1710
|
-
role=role,
|
|
1711
|
-
content=content,
|
|
1856
|
+
session_id=effective_session, turn_id=turn_id, role=role,
|
|
1857
|
+
content=event["content"],
|
|
1712
1858
|
source_sequence=(sequence_base + offset if sequence_base is not None else None),
|
|
1713
1859
|
final=True if role == "assistant" else None,
|
|
1714
|
-
source_metadata=
|
|
1860
|
+
source_metadata=event["metadata"],
|
|
1715
1861
|
):
|
|
1716
1862
|
return
|
|
1717
1863
|
if not self._auto_process:
|
|
@@ -1734,6 +1880,50 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1734
1880
|
_error_type(error),
|
|
1735
1881
|
)
|
|
1736
1882
|
|
|
1883
|
+
def bind_external_tool_call(self, tool_name: str, args: Dict[str, Any], *,
|
|
1884
|
+
turn_context: Any = None, tool_call_id: str = "") -> Optional[Dict[str, Any]]:
|
|
1885
|
+
"""Bind the existing MCP surface at the trusted host dispatch boundary."""
|
|
1886
|
+
prefix = "mcp__memleaf__"
|
|
1887
|
+
if not tool_name.startswith(prefix):
|
|
1888
|
+
return None
|
|
1889
|
+
name = tool_name[len(prefix):]
|
|
1890
|
+
if name not in {"search", "read", "list_todos", "scope_catalog", "remember"}:
|
|
1891
|
+
return None
|
|
1892
|
+
turn_id = _host_turn_id(turn_context)
|
|
1893
|
+
session = turn_context.get("session_id") if isinstance(turn_context, Mapping) else None
|
|
1894
|
+
ordinal = turn_context.get("turn_number") if isinstance(turn_context, Mapping) else None
|
|
1895
|
+
if (not turn_id or not isinstance(session, str) or not session
|
|
1896
|
+
or type(ordinal) is not int or ordinal <= 0):
|
|
1897
|
+
raise ValueError("memory_host_identity_missing")
|
|
1898
|
+
session = self._canonical_session_id(session)
|
|
1899
|
+
if self._gate_turn_id(session, ordinal) != turn_id:
|
|
1900
|
+
raise ValueError("memory_host_turn_mismatch")
|
|
1901
|
+
bound = dict(args)
|
|
1902
|
+
if name == "remember":
|
|
1903
|
+
if not tool_call_id or tool_call_id.endswith("/"):
|
|
1904
|
+
raise ValueError("memory_host_operation_missing")
|
|
1905
|
+
bound.update(source="hermes", session_id=session, turn_id=turn_id,
|
|
1906
|
+
intent_id="host-op-" + sha256((turn_id + "/" + tool_call_id + "/" + name).encode()).hexdigest())
|
|
1907
|
+
bound.pop("event_id", None)
|
|
1908
|
+
# No source timestamp is assigned to an assistant-written composite
|
|
1909
|
+
# explicit request; only the submitted text itself is its evidence.
|
|
1910
|
+
bound.pop("source_time", None)
|
|
1911
|
+
elif name == "scope_catalog":
|
|
1912
|
+
bound.update(source="hermes", session_id=session, turn_id=turn_id)
|
|
1913
|
+
else:
|
|
1914
|
+
token = self._gate_id_for_turn(session, ordinal)
|
|
1915
|
+
if token is None:
|
|
1916
|
+
raise ValueError("memory_host_retrieval_unavailable")
|
|
1917
|
+
bound["retrieval_id"] = token
|
|
1918
|
+
call_id = tool_call_id.rsplit("/", 1)[-1]
|
|
1919
|
+
if call_id:
|
|
1920
|
+
key = (session, turn_id, tool_name, call_id)
|
|
1921
|
+
self._bound_retrieval_calls[key] = token
|
|
1922
|
+
self._bound_retrieval_calls.move_to_end(key)
|
|
1923
|
+
while len(self._bound_retrieval_calls) > _MAX_OBSERVED_TOOL_CALL_KEYS:
|
|
1924
|
+
self._bound_retrieval_calls.popitem(last=False)
|
|
1925
|
+
return bound
|
|
1926
|
+
|
|
1737
1927
|
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
|
1738
1928
|
# memleaf-mcp remains configured separately for deliberate search,
|
|
1739
1929
|
# remember, and forget operations. The native provider owns automatic
|
|
@@ -285,6 +285,7 @@ def _scope_context(
|
|
|
285
285
|
*,
|
|
286
286
|
retrieval_id: Optional[str] = None,
|
|
287
287
|
scope_hint: Optional[str] = None,
|
|
288
|
+
host_bound: bool = False,
|
|
288
289
|
) -> tuple[str, int]:
|
|
289
290
|
"""Render a bounded Scope Map without per-memory identifiers or text."""
|
|
290
291
|
|
|
@@ -313,7 +314,11 @@ def _scope_context(
|
|
|
313
314
|
"Search/list_todos return directories; read only the selected memory when needed for ordinary "
|
|
314
315
|
"relevance queries; for global todo queries, read every matching todo item. A no-match result is valid.\n"
|
|
315
316
|
)
|
|
316
|
-
if
|
|
317
|
+
if host_bound:
|
|
318
|
+
prefix += ("Hermes binds retrieval and retention identity at tool dispatch. "
|
|
319
|
+
"Supply business parameters only; omit retrieval_id, source, session_id, "
|
|
320
|
+
"turn_id, event_id and intent_id.\n")
|
|
321
|
+
elif isinstance(retrieval_id, str) and retrieval_id:
|
|
317
322
|
prefix = (
|
|
318
323
|
prefix
|
|
319
324
|
+ f"For this turn, pass retrieval_id={retrieval_id} to memleaf "
|
|
@@ -151,6 +151,9 @@ def _source_valid(service, processed, work):
|
|
|
151
151
|
return False
|
|
152
152
|
try:
|
|
153
153
|
turn, window = _window(service, work["source"], work["session_id"], work["turn_key"])
|
|
154
|
+
from .incremental_explicit import bindings_current
|
|
155
|
+
if not bindings_current(service, processed, work):
|
|
156
|
+
return False
|
|
154
157
|
except (OSError, ValueError):
|
|
155
158
|
return False
|
|
156
159
|
return window == work["source_window"] and input_digest(turn) == work["source_digest"]
|
|
@@ -365,6 +368,10 @@ def apply_incremental(service: Any, *, response: str, expected_snapshot: str, in
|
|
|
365
368
|
"selected_fragments": sum("native" in t for t in snapshot.state()["targets"].values()),
|
|
366
369
|
"selection": "bounded_candidates", "read_only": True},
|
|
367
370
|
"index_status": "dirty" if any(op["action"] in {"CREATE", "UPDATE"} and op["state"] == "prepared" for op in operations) else "current"}
|
|
371
|
+
if snapshot.state().get("explicit_writes"):
|
|
372
|
+
from .incremental_explicit import freeze_bindings
|
|
373
|
+
work["explicit_write_bindings"] = freeze_bindings(
|
|
374
|
+
snapshot.state()["explicit_writes"], snapshot.state()["targets"])
|
|
368
375
|
if recovery_run is not None:
|
|
369
376
|
work["recovery_parent"] = parent["work_id"]
|
|
370
377
|
save_work(service, processed, work)
|
|
@@ -78,8 +78,8 @@ def remove_source_time_provenance_date(body: str, events: list[Mapping[str, Any]
|
|
|
78
78
|
|
|
79
79
|
The source chain is stored by Core. A message timestamp is not evidence for
|
|
80
80
|
a date in the memory's facts or deadline, even when a model copies that day
|
|
81
|
-
into a decorative ``来源:`` line. Leave every other date
|
|
82
|
-
|
|
81
|
+
into a decorative ``来源:`` line. Leave every other date unchanged; provenance matching does not
|
|
82
|
+
authorize or reject business dates.
|
|
83
83
|
"""
|
|
84
84
|
source_days = {
|
|
85
85
|
anchor.date().isoformat()
|
|
@@ -109,73 +109,6 @@ def remove_source_time_provenance_date(body: str, events: list[Mapping[str, Any]
|
|
|
109
109
|
return "".join(lines)
|
|
110
110
|
|
|
111
111
|
|
|
112
|
-
def content_date_violations(fields: Mapping[str, Any], events: list[Mapping[str, Any]],
|
|
113
|
-
preserved: Mapping[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
114
|
-
"""Locate unsupported dates without weakening the existing grounding guard.
|
|
115
|
-
|
|
116
|
-
Only cited sources and the selected update target may ground new wording.
|
|
117
|
-
Existing memory wording is preserved without a new source claim.
|
|
118
|
-
"""
|
|
119
|
-
from .process_common import _summary_date_grounding_violations
|
|
120
|
-
|
|
121
|
-
source_texts = [reading_text(str(event["text"])) for event in events]
|
|
122
|
-
grounded: set[str] = set()
|
|
123
|
-
clock_dates: dict[str, set[str]] = {}
|
|
124
|
-
for event, source in zip(events, source_texts):
|
|
125
|
-
anchor = parse_source_time(event.get("source_time"))
|
|
126
|
-
local_day = anchor.date() if anchor is not None else None
|
|
127
|
-
normalized = normalize_relative_calendar_text(source, local_day) if local_day else None
|
|
128
|
-
grounded.update(token.canonical for token in calendar_tokens(source, local_day) if token.canonical)
|
|
129
|
-
if normalized is not None:
|
|
130
|
-
grounded.update(token.canonical for token in calendar_tokens(normalized, local_day) if token.canonical)
|
|
131
|
-
for match in _RELATIVE_CALENDAR_EXPRESSION.finditer(source):
|
|
132
|
-
clock = _CLOCK_AFTER_DATE.match(source, match.end())
|
|
133
|
-
if clock:
|
|
134
|
-
resolved = normalize_relative_calendar_text(match.group(), local_day)
|
|
135
|
-
if resolved:
|
|
136
|
-
clock_dates.setdefault(clock.group(1), set()).add(resolved)
|
|
137
|
-
# An explicit source date at the same clock makes a relative binding
|
|
138
|
-
# ambiguous; in that case the guard must not claim which event it is.
|
|
139
|
-
for date_value, clock_value in _dated_clocks(source):
|
|
140
|
-
clock_dates.setdefault(clock_value, set()).add(date_value)
|
|
141
|
-
prior = preserved or {}
|
|
142
|
-
texts = {key: fields[key] for key in ("title", "body") if key in fields}
|
|
143
|
-
violations = []
|
|
144
|
-
anchors = [{key: event[key] for key in ("ref", "source_time") if key in event}
|
|
145
|
-
for event in events]
|
|
146
|
-
for field, value in texts.items():
|
|
147
|
-
unsupported = set(_summary_date_grounding_violations(
|
|
148
|
-
{field: value}, grounded_dates=grounded, source_texts=source_texts,
|
|
149
|
-
preserved_texts=[prior.get("title"), prior.get("body")]))
|
|
150
|
-
for token in calendar_tokens(value):
|
|
151
|
-
# A yearless literal cannot borrow a year merely for display.
|
|
152
|
-
identity = (token.canonical if token.has_year else None) or token.raw
|
|
153
|
-
if identity in unsupported:
|
|
154
|
-
violations.append({"field": field, "start": token.start, "end": token.end,
|
|
155
|
-
"text": token.raw, "normalized": token.canonical or token.monthday,
|
|
156
|
-
"reason": "not_in_cited_evidence_or_target", "anchors": anchors})
|
|
157
|
-
preserved_pairs = set().union(*(_dated_clocks(text) for text in (prior.get("title"), prior.get("body"))
|
|
158
|
-
if isinstance(text, str)))
|
|
159
|
-
for field, value in texts.items():
|
|
160
|
-
for token in calendar_tokens(value):
|
|
161
|
-
clock = _CLOCK_AFTER_DATE.match(value, token.end)
|
|
162
|
-
if not token.canonical or clock is None:
|
|
163
|
-
continue
|
|
164
|
-
date_value, clock_value = token.canonical, clock.group(1)
|
|
165
|
-
expected = clock_dates.get(clock_value, set())
|
|
166
|
-
if len(expected) == 1 and date_value not in expected and (date_value, clock_value) not in preserved_pairs:
|
|
167
|
-
violations.append({"field": field, "start": token.start, "end": token.end,
|
|
168
|
-
"text": token.raw, "normalized": date_value,
|
|
169
|
-
"reason": "relative_clock_date_mismatch",
|
|
170
|
-
"expected_dates": sorted(expected), "anchors": anchors})
|
|
171
|
-
return violations
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def invalid_content_dates(fields: Mapping[str, Any], events: list[Mapping[str, Any]],
|
|
175
|
-
preserved: Mapping[str, Any] | None = None) -> bool:
|
|
176
|
-
return bool(content_date_violations(fields, events, preserved))
|
|
177
|
-
|
|
178
|
-
|
|
179
112
|
def selected_calendar(text: str, evidence: Mapping[str, Any]) -> dict[str, Any]:
|
|
180
113
|
"""Return a lossless text/anchor projection plus an optional calendar date.
|
|
181
114
|
|