memleaf 0.2.76__tar.gz → 0.2.78__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.76 → memleaf-0.2.78}/CHANGELOG.md +23 -0
- {memleaf-0.2.76/src/memleaf.egg-info → memleaf-0.2.78}/PKG-INFO +2 -2
- {memleaf-0.2.76 → memleaf-0.2.78}/README.en.md +1 -1
- {memleaf-0.2.76 → memleaf-0.2.78}/README.md +1 -1
- {memleaf-0.2.76 → memleaf-0.2.78}/pyproject.toml +1 -1
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/hermes_provider/_provider.py +235 -32
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/hermes_provider/_shared.py +6 -1
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_commit.py +7 -0
- memleaf-0.2.78/src/memleaf/incremental_explicit.py +211 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_journal.py +4 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_preview.py +15 -1
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_prompts.py +1 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_protocol.py +12 -2
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_recovery.py +9 -1
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/mcp_server.py +2 -1
- {memleaf-0.2.76 → memleaf-0.2.78/src/memleaf.egg-info}/PKG-INFO +2 -2
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf.egg-info/SOURCES.txt +1 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/LICENSE +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/MANIFEST.in +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/acceptance-evidence-guide.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/automatic-processing-route.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/body-compaction.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/candidate-reconciliation.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/capture-budget-design.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/config-migrations.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/core-refactor.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/development-closeout.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/evidence-retention.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/explicit-memory-update.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/extraction-latency.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/gate-evidence-boundary.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/general-processing.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/hermes-mcp-runtime.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/hermes-provider-compatibility.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/host-acceptance-runbook.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/immutable-release-assets.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/incremental-commit-contract.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/incremental-execution.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/incremental-native-comparison.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/incremental-partial-recovery.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/incremental-planner-preview.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/incremental-runtime-contract.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/incremental-scope-registration.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/incremental-selected-retention.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/installed-artifact-verification.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/memory-state-contract.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/migration-preflight.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/processing-quality-acceptance.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/public-query-integrity.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/query-performance.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/remember-incremental-route.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/runtime-state-retention.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/semantic-extraction-protocol.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/source-work-contract.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/docs/v0.2.26-processing-status.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/examples/README.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/examples/basic_usage.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/examples/incremental_acceptance.json +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/examples/query_benchmark.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/install.ps1 +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/install.sh +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/setup.cfg +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/acceptance.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/admission.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/batch_review.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/config.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/create_coordinator.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/evidence_budget.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/evidence_policy.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/evidence_structure.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/evidence_syntax.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/explicit_text_source.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/extraction_budget.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/extraction_capability.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/extraction_work_state.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/hermes_provider/provider_compatibility.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/hermes_runtime.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_dates.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_execution.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_native.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_partial.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_run_state.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_runtime.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_scopes.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/incremental_selection.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/index.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/inspection.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/legacy_update_journal.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/llm/thinking.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/memory_commit.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/memory_planner.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/memory_retraction.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/memory_update.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/migration.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/model_capabilities.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/model_execution.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/models.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/native_registration.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/parallel_model.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/planning_context.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/process_common.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/process_jobs.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/process_journal.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/process_owner.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/processing.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/processing_route.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/prompts.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/provenance.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/provider_compatibility.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/query_clock.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/query_progress.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/query_scan.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/receipt_codec.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/recording_policy.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/remember_route.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/retention.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/runtime_retention.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/semantic_maintenance.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/semantic_protocol.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/service.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/single_pass_memory_planner.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/single_pass_plan.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/source_policy.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/state_layout.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/subprocess_flags.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/summary_batch.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/target_reconciliation.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/turn_audit.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/turn_plan.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/update_coordinator.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/update_review.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/validation.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.76 → memleaf-0.2.78}/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.78 — 2026-09-29
|
|
8
|
+
|
|
9
|
+
### Hermes host-turn provenance and explicit-write context
|
|
10
|
+
|
|
11
|
+
- Capture corrected user messages as separate events from host admission
|
|
12
|
+
snapshots; reject incomplete or mismatched turn context instead of inferring
|
|
13
|
+
event identity from composed text.
|
|
14
|
+
- Bind external tool calls to verified host/session/turn identity, and keep
|
|
15
|
+
explicit-save receipts tied to their source evidence and target revision
|
|
16
|
+
across planning and recovery.
|
|
17
|
+
- Keep MCP retrieval authorization enforced at execution while allowing hosts
|
|
18
|
+
and bare clients to supply the retrieval token through their own transport.
|
|
19
|
+
|
|
20
|
+
## 0.2.77 — 2026-09-29
|
|
21
|
+
|
|
22
|
+
### Hermes source metadata pairing
|
|
23
|
+
|
|
24
|
+
- Match the final assistant reply to the nearest preceding user message across
|
|
25
|
+
tool rounds, regardless of message count; never borrow metadata from an older
|
|
26
|
+
matching turn when the nearest user content differs.
|
|
27
|
+
- Log bounded reason codes for unavailable or mismatched message metadata and
|
|
28
|
+
missing or invalid timestamps, without logging message bodies.
|
|
29
|
+
|
|
7
30
|
## 0.2.76 — 2026-09-28
|
|
8
31
|
|
|
9
32
|
### Incremental turn maintenance and date diagnostics
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.78
|
|
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.78。**
|
|
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.78.**
|
|
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.78。**
|
|
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.78"
|
|
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,21 +34,31 @@ 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
|
|
41
41
|
|
|
42
42
|
if not isinstance(messages, list) or not messages:
|
|
43
|
+
logger.info("memleaf source-metadata reason=messages_missing")
|
|
43
44
|
return {}
|
|
44
45
|
assistant = messages[-1]
|
|
45
46
|
if (not isinstance(assistant, Mapping) or assistant.get("role") != "assistant"
|
|
46
47
|
or assistant.get("content") != assistant_content or assistant.get("tool_calls")):
|
|
48
|
+
logger.info("memleaf source-metadata reason=assistant_mismatch")
|
|
47
49
|
return {}
|
|
48
|
-
|
|
50
|
+
# Tool rounds do not define a turn boundary. Stop at the nearest user,
|
|
51
|
+
# even if its text mismatches; never borrow an older matching message.
|
|
52
|
+
preceding = reversed(messages)
|
|
53
|
+
next(preceding) # The final assistant was checked above.
|
|
54
|
+
user = next((item for item in preceding
|
|
49
55
|
if isinstance(item, Mapping) and item.get("role") == "user"), None)
|
|
50
|
-
if user is None
|
|
51
|
-
|
|
56
|
+
if user is None:
|
|
57
|
+
logger.info("memleaf source-metadata reason=user_missing")
|
|
58
|
+
return _independent_assistant_time(assistant, assistant_content)
|
|
59
|
+
if user.get("content") != user_content:
|
|
60
|
+
logger.info("memleaf source-metadata reason=user_mismatch")
|
|
61
|
+
return _independent_assistant_time(assistant, assistant_content)
|
|
52
62
|
result: dict[str, dict[str, Any]] = {}
|
|
53
63
|
for role, item in (("user", user), ("assistant", assistant)):
|
|
54
64
|
row: dict[str, Any] = {}
|
|
@@ -73,6 +83,9 @@ def _sync_source_metadata(messages: Any, user_content: str, assistant_content: s
|
|
|
73
83
|
row["source_time"] = parsed.isoformat()
|
|
74
84
|
except ValueError:
|
|
75
85
|
pass
|
|
86
|
+
if "source_time" not in row:
|
|
87
|
+
logger.info("memleaf source-metadata reason=%s role=%s",
|
|
88
|
+
"time_missing" if timestamp is None else "time_invalid", role)
|
|
76
89
|
sequence = item.get("source_sequence")
|
|
77
90
|
if type(sequence) is int and sequence >= 0:
|
|
78
91
|
row["source_sequence"] = sequence
|
|
@@ -88,6 +101,115 @@ def _sync_source_metadata(messages: Any, user_content: str, assistant_content: s
|
|
|
88
101
|
return result
|
|
89
102
|
|
|
90
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
|
+
|
|
91
213
|
def _capture_policy_status(config: Mapping[str, Any], vault: Path) -> dict[str, Any]:
|
|
92
214
|
"""Read the effective policy through the public Core MCP stats result.
|
|
93
215
|
|
|
@@ -183,6 +305,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
183
305
|
# bounded session -> job mapping so a later turn can poll the prior
|
|
184
306
|
# result and request a rerun on the same job while it is active.
|
|
185
307
|
self._process_jobs_by_session: "OrderedDict[str, str]" = OrderedDict()
|
|
308
|
+
self._bound_retrieval_calls: "OrderedDict[Tuple[str, str, str, str], str]" = OrderedDict()
|
|
186
309
|
self._last_retrieval_observation = "unknown"
|
|
187
310
|
self._last_retrieval_audit = "SEARCH_UNKNOWN"
|
|
188
311
|
|
|
@@ -829,14 +952,14 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
829
952
|
)
|
|
830
953
|
self._version_warning_emitted = True
|
|
831
954
|
|
|
832
|
-
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:
|
|
833
956
|
if isinstance(turn_number, bool) or not isinstance(turn_number, int) or turn_number <= 0:
|
|
834
957
|
return
|
|
835
958
|
visible_user = _visible_message_text(message)
|
|
836
959
|
if not visible_user.strip():
|
|
837
960
|
return
|
|
838
961
|
fingerprint = _visible_fingerprint(visible_user)
|
|
839
|
-
session_id = self._canonical_session_id(self._session_id)
|
|
962
|
+
session_id = self._canonical_session_id(session_id or self._session_id)
|
|
840
963
|
with self._sync_lock:
|
|
841
964
|
queue = self._pending_turn_numbers.get(fingerprint)
|
|
842
965
|
if queue is None:
|
|
@@ -937,7 +1060,11 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
937
1060
|
def on_turn_start(self, turn_number: Any, message: Any = None, **kwargs: Any) -> None:
|
|
938
1061
|
"""Remember only a bounded user-text fingerprint for later ``sync_turn``."""
|
|
939
1062
|
|
|
940
|
-
|
|
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)
|
|
941
1068
|
if not self._write_enabled:
|
|
942
1069
|
return
|
|
943
1070
|
if self._gate_enabled:
|
|
@@ -945,15 +1072,15 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
945
1072
|
# The token is created by the MCP server during this turn's
|
|
946
1073
|
# scope_catalog call. Clearing the active value here prevents
|
|
947
1074
|
# a skipped prefetch from reusing the previous turn's token.
|
|
948
|
-
self._active_turn_numbers[
|
|
949
|
-
self._active_turn_numbers.move_to_end(
|
|
950
|
-
self._active_retrieval_ids[
|
|
951
|
-
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)
|
|
952
1079
|
visible_user = _visible_message_text(message)
|
|
953
|
-
self._gate_turn_ids[(
|
|
954
|
-
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)}"
|
|
955
1082
|
)
|
|
956
|
-
self._gate_turn_ids.move_to_end((
|
|
1083
|
+
self._gate_turn_ids.move_to_end((turn_session, turn_number))
|
|
957
1084
|
while len(self._active_turn_numbers) > _MAX_PENDING_TURN_NUMBERS:
|
|
958
1085
|
self._active_turn_numbers.popitem(last=False)
|
|
959
1086
|
while len(self._active_retrieval_ids) > _MAX_PENDING_TURN_NUMBERS:
|
|
@@ -961,7 +1088,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
961
1088
|
while len(self._gate_turn_ids) > _MAX_PENDING_TURN_NUMBERS:
|
|
962
1089
|
self._gate_turn_ids.popitem(last=False)
|
|
963
1090
|
self._last_retrieval_observation = "not_observed"
|
|
964
|
-
self._queue_turn_number(turn_number, message)
|
|
1091
|
+
self._queue_turn_number(turn_number, message, session_id=turn_session)
|
|
965
1092
|
|
|
966
1093
|
def on_session_switch(
|
|
967
1094
|
self,
|
|
@@ -1400,6 +1527,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1400
1527
|
vault_root: Optional[Path] = None,
|
|
1401
1528
|
seen_call_keys: Any = None,
|
|
1402
1529
|
audit_state: Optional[dict[str, Any]] = None,
|
|
1530
|
+
bound_retrieval_calls: Optional[Mapping[Tuple[str, str, str, str], str]] = None,
|
|
1403
1531
|
) -> str:
|
|
1404
1532
|
"""Observe explicit host MCP calls in public messages.
|
|
1405
1533
|
|
|
@@ -1420,6 +1548,9 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1420
1548
|
continue
|
|
1421
1549
|
search_ordinal += 1
|
|
1422
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}
|
|
1423
1554
|
if not isinstance(arguments, Mapping) or arguments.get("retrieval_id") != retrieval_id:
|
|
1424
1555
|
continue
|
|
1425
1556
|
payload = _tool_result_for_call(call, calls, results, search_results_used)
|
|
@@ -1442,6 +1573,9 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1442
1573
|
continue
|
|
1443
1574
|
read_ordinal += 1
|
|
1444
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}
|
|
1445
1579
|
retrieval_present = isinstance(arguments, Mapping) and "retrieval_id" in arguments
|
|
1446
1580
|
retrieval_match = bool(
|
|
1447
1581
|
retrieval_present
|
|
@@ -1598,6 +1732,7 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1598
1732
|
catalog,
|
|
1599
1733
|
retrieval_id=retrieval_id,
|
|
1600
1734
|
scope_hint=_unique_query_scope(query, catalog),
|
|
1735
|
+
host_bound=bool((self._gate_turn_id(safe_session, turn_number) or "").startswith("host-")),
|
|
1601
1736
|
)
|
|
1602
1737
|
if not context:
|
|
1603
1738
|
notices = [
|
|
@@ -1629,13 +1764,13 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1629
1764
|
session_id: str = "",
|
|
1630
1765
|
messages: Optional[List[Dict[str, Any]]] = None,
|
|
1631
1766
|
turn_number: Optional[int] = None,
|
|
1767
|
+
turn_context: Optional[Mapping[str, Any]] = None,
|
|
1632
1768
|
) -> None:
|
|
1633
1769
|
# ``messages`` may contain system prompts, tool calls/results, and
|
|
1634
|
-
# attachment parts.
|
|
1635
|
-
#
|
|
1636
|
-
#
|
|
1637
|
-
#
|
|
1638
|
-
# 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.
|
|
1639
1774
|
if not self._write_enabled or self._client is None:
|
|
1640
1775
|
return
|
|
1641
1776
|
raw_assistant_content = assistant_content
|
|
@@ -1653,26 +1788,41 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1653
1788
|
# captured turn within this provider instance.
|
|
1654
1789
|
with self._sync_lock:
|
|
1655
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
|
|
1656
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"]
|
|
1657
1804
|
if resolved_turn_number is None:
|
|
1658
1805
|
resolved_turn_number = self._take_turn_number(effective_session, user_content)
|
|
1659
1806
|
elif isinstance(resolved_turn_number, int) and not isinstance(resolved_turn_number, bool):
|
|
1660
|
-
|
|
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)
|
|
1661
1809
|
turn_id = self._resolve_turn_id(
|
|
1662
1810
|
effective_session,
|
|
1663
1811
|
resolved_turn_number,
|
|
1664
1812
|
user_content,
|
|
1665
1813
|
captured_assistant_content,
|
|
1666
1814
|
)
|
|
1667
|
-
source_metadata =
|
|
1815
|
+
source_metadata = ({} if host_events is not None else
|
|
1816
|
+
_sync_source_metadata(messages, user_content, raw_assistant_content))
|
|
1668
1817
|
user_source_id = source_metadata.get("user", {}).get("message_id")
|
|
1669
|
-
if
|
|
1818
|
+
if bound_turn_id:
|
|
1819
|
+
turn_id = bound_turn_id
|
|
1820
|
+
elif user_source_id:
|
|
1670
1821
|
# Stable host identity survives a revised body/final reply.
|
|
1671
1822
|
turn_id = "source-" + sha256(user_source_id.encode("utf-8")).hexdigest()[:24]
|
|
1672
1823
|
source_metadata["assistant"].setdefault("previous_message_id", user_source_id)
|
|
1673
1824
|
retrieval_id = self._gate_id_for_turn(effective_session, resolved_turn_number)
|
|
1674
|
-
|
|
1675
|
-
retrieval_id = self._current_gate_id(effective_session)
|
|
1825
|
+
# A background callback must never attach itself to a newer turn.
|
|
1676
1826
|
if self._gate_enabled:
|
|
1677
1827
|
audit_state: dict[str, Any] = {}
|
|
1678
1828
|
observation = self._observe_search_messages(
|
|
@@ -1683,22 +1833,31 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1683
1833
|
vault_root=_resolve_vault(self._config()),
|
|
1684
1834
|
seen_call_keys=self._observed_tool_call_keys,
|
|
1685
1835
|
audit_state=audit_state,
|
|
1836
|
+
bound_retrieval_calls=self._bound_retrieval_calls,
|
|
1686
1837
|
)
|
|
1687
1838
|
while len(self._observed_tool_call_keys) > _MAX_OBSERVED_TOOL_CALL_KEYS:
|
|
1688
1839
|
self._observed_tool_call_keys.popitem(last=False)
|
|
1689
1840
|
self._last_retrieval_observation = observation
|
|
1690
1841
|
self._last_retrieval_audit = str(audit_state.get("status") or "SEARCH_UNKNOWN")
|
|
1691
1842
|
lineage_ready = self._retry_pending_lineage(effective_session)
|
|
1692
|
-
|
|
1693
|
-
|
|
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"]
|
|
1694
1855
|
if not self._capture_visible(
|
|
1695
|
-
session_id=effective_session,
|
|
1696
|
-
|
|
1697
|
-
role=role,
|
|
1698
|
-
content=content,
|
|
1856
|
+
session_id=effective_session, turn_id=turn_id, role=role,
|
|
1857
|
+
content=event["content"],
|
|
1699
1858
|
source_sequence=(sequence_base + offset if sequence_base is not None else None),
|
|
1700
1859
|
final=True if role == "assistant" else None,
|
|
1701
|
-
source_metadata=
|
|
1860
|
+
source_metadata=event["metadata"],
|
|
1702
1861
|
):
|
|
1703
1862
|
return
|
|
1704
1863
|
if not self._auto_process:
|
|
@@ -1721,6 +1880,50 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1721
1880
|
_error_type(error),
|
|
1722
1881
|
)
|
|
1723
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
|
+
|
|
1724
1927
|
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
|
1725
1928
|
# memleaf-mcp remains configured separately for deliberate search,
|
|
1726
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)
|