memleaf 0.2.83__tar.gz → 0.2.84__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.83 → memleaf-0.2.84}/CHANGELOG.md +22 -0
- {memleaf-0.2.83/src/memleaf.egg-info → memleaf-0.2.84}/PKG-INFO +7 -4
- {memleaf-0.2.83 → memleaf-0.2.84}/README.en.md +6 -3
- {memleaf-0.2.83 → memleaf-0.2.84}/README.md +6 -3
- memleaf-0.2.84/docs/hermes-source-comparison-context.md +60 -0
- memleaf-0.2.84/docs/memory-lifecycle-tools.md +26 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/pyproject.toml +1 -1
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/capture.py +39 -1
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/_provider.py +176 -9
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/inbox.py +3 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_preview.py +13 -1
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_prompts.py +1 -1
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_protocol.py +1 -1
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/mcp_server.py +71 -8
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/retrieval_gate.py +19 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/service.py +4 -0
- {memleaf-0.2.83 → memleaf-0.2.84/src/memleaf.egg-info}/PKG-INFO +7 -4
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf.egg-info/SOURCES.txt +2 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/LICENSE +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/MANIFEST.in +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/acceptance-evidence-guide.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/automatic-processing-route.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/body-compaction.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/candidate-reconciliation.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/capture-budget-design.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/config-migrations.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/core-refactor.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/development-closeout.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/evidence-retention.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/explicit-memory-update.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/extraction-latency.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/failed-run-recovery.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/gate-evidence-boundary.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/general-processing.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/hermes-mcp-runtime.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/hermes-provider-compatibility.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/host-acceptance-runbook.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/immutable-release-assets.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-commit-contract.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-execution.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-native-comparison.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-partial-recovery.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-planner-preview.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-runtime-contract.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-scope-registration.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/incremental-selected-retention.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/installed-artifact-verification.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/memory-state-contract.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/migration-preflight.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/processing-quality-acceptance.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/public-query-integrity.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/query-performance.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/remember-incremental-route.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/runtime-state-retention.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/semantic-extraction-protocol.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/source-work-contract.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/docs/v0.2.26-processing-status.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/examples/README.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/examples/basic_usage.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/examples/incremental_acceptance.json +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/examples/query_benchmark.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/install.ps1 +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/install.sh +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/setup.cfg +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/acceptance.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/admission.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/batch_review.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/config.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/create_coordinator.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/evidence_budget.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/evidence_policy.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/evidence_structure.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/evidence_syntax.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/explicit_text_source.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/extraction_budget.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/extraction_capability.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/extraction_work_state.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/_shared.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_provider/provider_compatibility.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/hermes_runtime.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_commit.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_dates.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_execution.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_explicit.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_failed_recovery.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_journal.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_native.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_partial.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_recovery.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_run_state.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_runtime.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_scopes.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/incremental_selection.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/index.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/inspection.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/legacy_update_journal.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/llm/thinking.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/memory_commit.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/memory_planner.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/memory_retraction.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/memory_update.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/migration.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/model_capabilities.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/model_execution.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/models.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/native_registration.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/parallel_model.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/planning_context.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/process_common.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/process_jobs.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/process_journal.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/process_owner.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/processing.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/processing_route.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/prompts.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/provenance.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/provider_compatibility.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/query_clock.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/query_progress.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/query_scan.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/receipt_codec.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/recording_policy.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/remember_route.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/retention.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/runtime_retention.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/semantic_maintenance.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/semantic_protocol.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/single_pass_memory_planner.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/single_pass_plan.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/source_policy.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/state_layout.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/subprocess_flags.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/summary_batch.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/target_reconciliation.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/turn_audit.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/turn_plan.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/update_coordinator.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/update_review.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/validation.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.83 → memleaf-0.2.84}/src/memleaf.egg-info/top_level.txt +0 -0
|
@@ -4,6 +4,28 @@ All notable changes to memleaf are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.2.84 — 2026-09-30
|
|
8
|
+
|
|
9
|
+
### Task lifecycle, Hermes source times and read-linked comparison
|
|
10
|
+
|
|
11
|
+
- Expose revision-bound MCP `update_memory` for completion, cancellation and
|
|
12
|
+
reopening; retain the memory body, other fields and version history. Both MCP
|
|
13
|
+
permanent-forgetting tools now require strict `confirm_delete=true`.
|
|
14
|
+
- Verify Hermes' native file-mutation footer as an exact display transformation
|
|
15
|
+
instead of losing both message times. Verify legacy correction rows against
|
|
16
|
+
their native scaffold and full merge format; capture each source event with
|
|
17
|
+
its own timestamp. Unknown inputs remain unknown.
|
|
18
|
+
- Recognize a repeated source delivery before consuming another same-text turn;
|
|
19
|
+
copy messages at callback invocation and retain the queued retrieval identity.
|
|
20
|
+
- Freeze successful current-turn read IDs in assistant capture metadata and
|
|
21
|
+
immutable receipts. Prioritize those targets for automatic extraction; reject
|
|
22
|
+
mismatched source/session/turn context and block missing or over-budget targets.
|
|
23
|
+
Recover captured context after Inbox/receipt interruption even if the gate expires.
|
|
24
|
+
- Require structured deadline maintenance before NO_CHANGE when current evidence
|
|
25
|
+
verifies an existing textual deadline whose date is absent or unresolved.
|
|
26
|
+
Advance semantic protocol to v11 without changing legacy snapshot field contracts.
|
|
27
|
+
- No production memory migration or automatic retry of historical runs is performed.
|
|
28
|
+
|
|
7
29
|
## 0.2.83 — 2026-09-30
|
|
8
30
|
|
|
9
31
|
### Reviewed extraction fields and existing-item maintenance
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.84
|
|
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.84。**
|
|
27
27
|
> 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
|
|
28
28
|
> 提炼先比较旧事项中的编号、目标、进展、责任和期限;补全未知信息也应维护原事项,另建独立待办不能替代项目事实更新。Core 不按标题强行合并。
|
|
29
29
|
> 新请求使用 `field-reviewed-v1`:CREATE/UPDATE 显式判断结构化期限;未知执行人填 `null`,新增或变更非空负责人必须引用来源原文。Core 校验字段和来源,模型判断业务含义;非法行进入现有 partial 处理。来源时间用于解析相对日期,不补造事实日期。
|
|
@@ -283,11 +283,14 @@ python -m memleaf.mcp_server --vault "$HOME/.memleaf"
|
|
|
283
283
|
| `process_status` | 只读查询异步处理 job 状态 |
|
|
284
284
|
| `recover_failed_run` | 预览并显式恢复终态传输失败,保留原请求预算 |
|
|
285
285
|
| `remember` | 用户明确要求时创建或更新记忆 |
|
|
286
|
-
| `
|
|
287
|
-
| `
|
|
286
|
+
| `update_memory` | 携带当前 revision 办结、取消或重新激活任务,保留正文与历史 |
|
|
287
|
+
| `forget_memory` | 用户明确要求遗忘时,携带 `confirm_delete=true` 按精确 ID 永久删除 |
|
|
288
|
+
| `forget_about` | 携带 `confirm_delete=true` 遗忘明确主题;有歧义时只返回候选 |
|
|
288
289
|
| `rebuild_index` | 重建可重建的本地派生索引 |
|
|
289
290
|
| `stats` | 返回 Vault 计数和诊断统计 |
|
|
290
291
|
|
|
292
|
+
任务办结与永久遗忘的接口和兼容性见 [生命周期工具](docs/memory-lifecycle-tools.md);来源时间及本轮比较目标的捕获边界见 [Hermes 来源与比较上下文](docs/hermes-source-comparison-context.md)。
|
|
293
|
+
|
|
291
294
|
`search` 的候选只是线索,标题不能单独作为事实依据。受管理检索必须使用同一条检索链的 `retrieval_id` 完成 `search → read`。Hermes/Codex Hook 可直接提供该 token;只有 MCP 的客户端可在第一条 `search` / `list_todos` 省略 token,并使用返回值继续分页和读取。带 cursor 的后续页以及所有 `read` 都不能省略 `retrieval_id`。工具错误、Scope 冲突或读取失败都应如实处理。
|
|
292
295
|
|
|
293
296
|
## Python API
|
|
@@ -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.84.**
|
|
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
|
> Extraction compares existing identifiers, objectives, progress, responsibility and deadlines first. Filling an unknown also maintains the original item; a separate task does not replace updating shared project facts. Core does not merge matching titles.
|
|
10
10
|
> New requests use `field-reviewed-v1`: CREATE/UPDATE explicitly decide structural deadlines; unknown executors use `null`, and new or changed non-null responsibility requires a source quote. Core validates fields and provenance while the model judges business meaning; invalid rows use existing partial handling. Source time resolves relative dates without inventing factual dates.
|
|
@@ -264,11 +264,14 @@ The server exposes the following tools:
|
|
|
264
264
|
| `process_status` | Read the status of a background processing job |
|
|
265
265
|
| `recover_failed_run` | Preview and explicitly recover terminal transport failures within the original request budget |
|
|
266
266
|
| `remember` | Create or update memory after an explicit request |
|
|
267
|
-
| `
|
|
268
|
-
| `
|
|
267
|
+
| `update_memory` | Complete, cancel or reopen a task using its current revision; retain body and history |
|
|
268
|
+
| `forget_memory` | Permanently delete an exact ID after explicit user forgetting, with `confirm_delete=true` |
|
|
269
|
+
| `forget_about` | Forget an unambiguous topic with `confirm_delete=true`; return candidates when ambiguous |
|
|
269
270
|
| `rebuild_index` | Rebuild local derived indexes |
|
|
270
271
|
| `stats` | Return Vault counts and diagnostic statistics |
|
|
271
272
|
|
|
273
|
+
See [Lifecycle tools](docs/memory-lifecycle-tools.md) and [Hermes source and comparison context](docs/hermes-source-comparison-context.md) for operation and capture boundaries.
|
|
274
|
+
|
|
272
275
|
Search results are clues; a title alone is not a business fact. Managed retrieval must use the same `retrieval_id` for `search → read`. Tool errors, Scope conflicts, retrieval-turn violations, and read page/version errors must be handled as such.
|
|
273
276
|
|
|
274
277
|
## Python API
|
|
@@ -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.84。**
|
|
8
8
|
> 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
|
|
9
9
|
> 提炼先比较旧事项中的编号、目标、进展、责任和期限;补全未知信息也应维护原事项,另建独立待办不能替代项目事实更新。Core 不按标题强行合并。
|
|
10
10
|
> 新请求使用 `field-reviewed-v1`:CREATE/UPDATE 显式判断结构化期限;未知执行人填 `null`,新增或变更非空负责人必须引用来源原文。Core 校验字段和来源,模型判断业务含义;非法行进入现有 partial 处理。来源时间用于解析相对日期,不补造事实日期。
|
|
@@ -264,11 +264,14 @@ python -m memleaf.mcp_server --vault "$HOME/.memleaf"
|
|
|
264
264
|
| `process_status` | 只读查询异步处理 job 状态 |
|
|
265
265
|
| `recover_failed_run` | 预览并显式恢复终态传输失败,保留原请求预算 |
|
|
266
266
|
| `remember` | 用户明确要求时创建或更新记忆 |
|
|
267
|
-
| `
|
|
268
|
-
| `
|
|
267
|
+
| `update_memory` | 携带当前 revision 办结、取消或重新激活任务,保留正文与历史 |
|
|
268
|
+
| `forget_memory` | 用户明确要求遗忘时,携带 `confirm_delete=true` 按精确 ID 永久删除 |
|
|
269
|
+
| `forget_about` | 携带 `confirm_delete=true` 遗忘明确主题;有歧义时只返回候选 |
|
|
269
270
|
| `rebuild_index` | 重建可重建的本地派生索引 |
|
|
270
271
|
| `stats` | 返回 Vault 计数和诊断统计 |
|
|
271
272
|
|
|
273
|
+
任务办结与永久遗忘的接口和兼容性见 [生命周期工具](docs/memory-lifecycle-tools.md);来源时间及本轮比较目标的捕获边界见 [Hermes 来源与比较上下文](docs/hermes-source-comparison-context.md)。
|
|
274
|
+
|
|
272
275
|
`search` 的候选只是线索,标题不能单独作为事实依据。受管理检索必须使用同一条检索链的 `retrieval_id` 完成 `search → read`。Hermes/Codex Hook 可直接提供该 token;只有 MCP 的客户端可在第一条 `search` / `list_todos` 省略 token,并使用返回值继续分页和读取。带 cursor 的后续页以及所有 `read` 都不能省略 `retrieval_id`。工具错误、Scope 冲突或读取失败都应如实处理。
|
|
273
276
|
|
|
274
277
|
## Python API
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Hermes source and comparison context
|
|
2
|
+
|
|
3
|
+
## Message times
|
|
4
|
+
|
|
5
|
+
`source_time` comes from the corresponding message's `source_time` or
|
|
6
|
+
`timestamp`. Missing or invalid values stay unknown. Capturing or processing
|
|
7
|
+
an event does not create a source time.
|
|
8
|
+
|
|
9
|
+
The legacy callback checks the final assistant and the nearest user. It has no
|
|
10
|
+
32-message lookback limit. It does not borrow a matching user from an older turn.
|
|
11
|
+
|
|
12
|
+
Some Hermes versions persist a reply before adding their file-mutation verifier
|
|
13
|
+
footer. The Provider accepts that display transformation only when the installed
|
|
14
|
+
Hermes formatter reconstructs the entire footer from uniquely associated,
|
|
15
|
+
current-turn file-tool receipts and the full resulting reply matches. Arbitrary
|
|
16
|
+
prefixes, suffixes, unsupported formatters and ambiguous receipts remain unknown.
|
|
17
|
+
Tool receipts supply no times or memory facts. The displayed reply remains the
|
|
18
|
+
captured assistant content.
|
|
19
|
+
|
|
20
|
+
For legacy corrections, the Provider verifies the real user rows, native replay
|
|
21
|
+
scaffold and the exact full merged callback string. It never splits arbitrary
|
|
22
|
+
user text by a separator. A bounded chain must have a queued original admission;
|
|
23
|
+
then each source row is captured separately with its own time. It does not assign
|
|
24
|
+
the final correction's time to the combined user text. A repeated source delivery
|
|
25
|
+
is recognized before consuming another same-text admission. Host-v1 admission
|
|
26
|
+
snapshots remain the preferred interface for compaction and transformations that
|
|
27
|
+
legacy callbacks cannot verify. The list is copied at callback invocation; this
|
|
28
|
+
cannot recover a host snapshot already lost before the callback arrives.
|
|
29
|
+
|
|
30
|
+
## Successful reads as comparison targets
|
|
31
|
+
|
|
32
|
+
Hermes uses the retrieval identity frozen at `on_turn_start` for final capture.
|
|
33
|
+
The assistant capture includes `retrieval_id` and `retrieval_turn_id`; the latter
|
|
34
|
+
must equal its `turn_id`. Core checks that the existing gate belongs to exactly
|
|
35
|
+
the declared source, session and turn. It never resolves a session's latest gate
|
|
36
|
+
on behalf of an older callback. Live search and read still reject stale tokens.
|
|
37
|
+
|
|
38
|
+
Core freezes only successfully read memory IDs into `comparison_context` on the
|
|
39
|
+
assistant event, including the immutable capture receipt and payload digest.
|
|
40
|
+
It accepts no caller-supplied list of IDs through this interface. The capsule is
|
|
41
|
+
comparison metadata, not factual evidence, and its token is not projected into
|
|
42
|
+
the model request. Automatic preparation gives these required targets priority
|
|
43
|
+
alongside explicit-write receipts before filling the bounded candidate budget.
|
|
44
|
+
A missing target or insufficient budget blocks preparation instead of silently
|
|
45
|
+
replacing or dropping the required target. No model call is added to capture.
|
|
46
|
+
|
|
47
|
+
A delayed callback can use its own unexpired gate even after a later turn starts.
|
|
48
|
+
Once the capsule is captured, gate expiry cannot erase it or break a duplicate
|
|
49
|
+
capture. If Inbox was saved but receipt persistence failed, retries first recover
|
|
50
|
+
the capsule from Inbox before consulting a live gate. An uncaptured, expired or
|
|
51
|
+
mismatched gate cannot supply comparison context.
|
|
52
|
+
|
|
53
|
+
The extraction prompt also requires structured deadline maintenance before
|
|
54
|
+
NO_CHANGE when current evidence makes an existing unstructured deadline
|
|
55
|
+
verifiable. Unchanged wording does not mean a missing due_date is complete.
|
|
56
|
+
This rule advances the semantic protocol to v11.
|
|
57
|
+
|
|
58
|
+
This supplies the model with the old targets. It does not force semantic UPDATE,
|
|
59
|
+
merge similar titles, or automatically alter historical memories. Recovery of
|
|
60
|
+
existing production runs and deleted records is a separate operation.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# 任务办结与永久遗忘
|
|
2
|
+
|
|
3
|
+
MCP `update_memory` 用于完成、取消或重新打开现有事项。它调用 Core 的 revision-bound 更新接口,保留原正文、未修改字段及历史版本,不调用模型。任务办结不等于删除记忆。
|
|
4
|
+
|
|
5
|
+
先通过 `search` / `list_todos` 和 `read` 确认目标。使用 `read` 返回的 `revision`,而不是分页用的 `version`:
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"memory_id": "mem-example",
|
|
10
|
+
"expected_revision": "<read 返回的 revision>",
|
|
11
|
+
"patch": {"status": "completed"}
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- 已完成或关闭的事项使用 `completed`,放弃的事项使用 `cancelled`。
|
|
16
|
+
- 本 MCP 接口仅接受 `patch.status`,不修改正文、类型或范围。
|
|
17
|
+
- 重新打开事项使用 `patch.status=active` 和 `reopen=true`,应有用户明确重新打开的要求。
|
|
18
|
+
- 只有明确知道事项的完成事件时间时才传 `source_time`(带时区的 ISO 时间);完成时会保存为 `completed_at`。未知时省略,不使用当前执行时间补造完成时间。
|
|
19
|
+
- 相同请求可以安全重试,不重复创建历史。旧 revision 的不同修改会被拒绝,应重新读取目标。
|
|
20
|
+
- 混合了项目事实和待办的记忆,办结只改变状态,仍保留项目事实。
|
|
21
|
+
|
|
22
|
+
`forget_memory` 与 `forget_about` 永久删除选中的记忆及其历史,没有“删除后收档”的历史副本。两个 MCP 工具均要求 `confirm_delete=true`,缺失或不是布尔值 `true` 时服务器拒绝执行。
|
|
23
|
+
|
|
24
|
+
这个参数只能表示调用方确认用户明确要求永久遗忘。完成、关闭、取消或归档任务不能作为设置它的理由。布尔参数本身不是用户自然语言授权的证明,也不能保证模型永远正确选择工具。需要撤销任务完成状态时,应通过 `update_memory` 重新打开;已永久删除的内容不能用此接口恢复。
|
|
25
|
+
|
|
26
|
+
这是 MCP 删除接口的兼容性变更:旧客户端应只在已有明确永久遗忘要求时补传 `confirm_delete=true`。Python Core 的 `forget_memory` / `forget_about` 原有删除语义及参数保持不变。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Local-first Markdown memory core for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
3
|
+
__version__ = "0.2.84"
|
|
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
|
|
@@ -199,6 +199,7 @@ def _append_event(
|
|
|
199
199
|
final: bool | None,
|
|
200
200
|
tool_evidence: list[dict[str, str]] | None = None,
|
|
201
201
|
explicit_input: dict[str, Any] | None = None,
|
|
202
|
+
comparison_context: dict[str, Any] | None = None,
|
|
202
203
|
) -> str:
|
|
203
204
|
if not existing:
|
|
204
205
|
existing = _new_session_text(source, session_id, captured_at)
|
|
@@ -232,6 +233,8 @@ def _append_event(
|
|
|
232
233
|
metadata["final"] = final
|
|
233
234
|
if explicit_input is not None:
|
|
234
235
|
metadata["explicit_input"] = explicit_input
|
|
236
|
+
if comparison_context is not None:
|
|
237
|
+
metadata["comparison_context"] = comparison_context
|
|
235
238
|
if tool_evidence:
|
|
236
239
|
metadata["tool_evidence"] = [dict(item) for item in tool_evidence]
|
|
237
240
|
metadata_line = json.dumps(metadata, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
@@ -359,6 +362,8 @@ def _payload_digest(metadata: Mapping[str, Any], content: str) -> str:
|
|
|
359
362
|
if metadata.get("explicit_input") is not None:
|
|
360
363
|
from .explicit_text_source import validate_origin
|
|
361
364
|
value["explicit_input"] = validate_origin(metadata["explicit_input"])
|
|
365
|
+
if metadata.get("comparison_context") is not None:
|
|
366
|
+
value["comparison_context"] = metadata["comparison_context"]
|
|
362
367
|
value["content"] = content.rstrip("\n")
|
|
363
368
|
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True,
|
|
364
369
|
separators=(",", ":")).encode("utf-8")).hexdigest()
|
|
@@ -414,7 +419,7 @@ def recover_capture_receipts_unlocked(vault: Vault, processed: dict, path: Path)
|
|
|
414
419
|
turn_key_value=metadata["turn_key"], turn_index=metadata["turn_index"],
|
|
415
420
|
message_id=metadata["message_id"], message_revision=metadata["message_revision"],
|
|
416
421
|
)
|
|
417
|
-
entry = {field: metadata[field] for field in (*_IMMUTABLE_EVENT_FIELDS, "turn_id", "turn_index", "captured_at", "explicit_input")
|
|
422
|
+
entry = {field: metadata[field] for field in (*_IMMUTABLE_EVENT_FIELDS, "turn_id", "turn_index", "captured_at", "explicit_input", "comparison_context")
|
|
418
423
|
if field in metadata}
|
|
419
424
|
entry.update(event_key=key, payload_digest=digest)
|
|
420
425
|
events[key] = entry
|
|
@@ -447,6 +452,8 @@ def capture_event(
|
|
|
447
452
|
previous_message_id: Optional[str] = None,
|
|
448
453
|
final: Optional[bool] = None,
|
|
449
454
|
_explicit_input: dict[str, Any] | None = None,
|
|
455
|
+
retrieval_id: Optional[str] = None,
|
|
456
|
+
retrieval_turn_id: Optional[str] = None,
|
|
450
457
|
) -> CaptureResult:
|
|
451
458
|
"""Capture one visible event; all persisted text is redacted first."""
|
|
452
459
|
|
|
@@ -478,6 +485,9 @@ def capture_event(
|
|
|
478
485
|
# Opting into source envelopes does not implicitly certify completion.
|
|
479
486
|
# Old callers with no source metadata retain their legacy boundary.
|
|
480
487
|
final = False
|
|
488
|
+
if (retrieval_id is None) != (retrieval_turn_id is None) or (
|
|
489
|
+
retrieval_id is not None and (role != "assistant" or retrieval_turn_id != turn_id)):
|
|
490
|
+
raise ValueError("invalid_comparison_binding")
|
|
481
491
|
resolved_event_id, resolved_message_id, resolved_event_key = _event_identity(
|
|
482
492
|
source, session_id, turn_id, role, event_id, message_id, message_revision
|
|
483
493
|
)
|
|
@@ -488,6 +498,31 @@ def capture_event(
|
|
|
488
498
|
if not visible or role not in ("user", "assistant"):
|
|
489
499
|
return CaptureResult(resolved_event_id, stored=False, duplicate=False, content=safe_content)
|
|
490
500
|
|
|
501
|
+
comparison_context = None
|
|
502
|
+
if retrieval_id is not None:
|
|
503
|
+
# Never hold the vault lock while acquiring the retrieval lock: gated
|
|
504
|
+
# reads acquire them in the opposite order. Durable retries reuse their
|
|
505
|
+
# immutable receipt even after the short-lived gate has expired.
|
|
506
|
+
with vault.lock():
|
|
507
|
+
recovered = _read_processed(vault.processed_state_path)
|
|
508
|
+
from .recording_policy import recording_allowed
|
|
509
|
+
comparison_permitted = record and recording_allowed(recovered, source, session_id, resolved_turn_key)
|
|
510
|
+
if comparison_permitted:
|
|
511
|
+
path = vault.session_path(source, session_id)
|
|
512
|
+
if path.is_symlink():
|
|
513
|
+
raise ValueError("unsafe inbox session path")
|
|
514
|
+
if recover_capture_receipts_unlocked(vault, recovered, path):
|
|
515
|
+
atomic_write_json(vault.processed_state_path, recovered)
|
|
516
|
+
receipt = recovered.get("events", {}).get(resolved_event_key, {})
|
|
517
|
+
saved = receipt.get("comparison_context") if isinstance(receipt, Mapping) else None
|
|
518
|
+
if (isinstance(saved, dict) and saved.get("retrieval_id") == retrieval_id
|
|
519
|
+
and saved.get("turn_id") == retrieval_turn_id):
|
|
520
|
+
comparison_context = saved
|
|
521
|
+
elif comparison_permitted:
|
|
522
|
+
from .retrieval_gate import snapshot_read_context
|
|
523
|
+
comparison_context = snapshot_read_context(vault, retrieval_id,
|
|
524
|
+
source=source, session_id=session_id, turn_id=retrieval_turn_id)
|
|
525
|
+
|
|
491
526
|
with vault.lock():
|
|
492
527
|
processed = _read_processed(vault.processed_state_path)
|
|
493
528
|
if _explicit_input is not None:
|
|
@@ -526,6 +561,8 @@ def capture_event(
|
|
|
526
561
|
"previous_message_revision": previous_message_revision, "source_sequence": source_sequence,
|
|
527
562
|
"previous_message_id": previous_message_id, "source_time": source_time, "final": final,
|
|
528
563
|
}
|
|
564
|
+
if retrieval_id is not None:
|
|
565
|
+
incoming["comparison_context"] = comparison_context
|
|
529
566
|
if _explicit_input is not None:
|
|
530
567
|
incoming["explicit_input"] = _explicit_input
|
|
531
568
|
digest = _payload_digest(incoming, safe_content)
|
|
@@ -584,6 +621,7 @@ def capture_event(
|
|
|
584
621
|
final=final,
|
|
585
622
|
tool_evidence=safe_tool_evidence,
|
|
586
623
|
explicit_input=_explicit_input,
|
|
624
|
+
comparison_context=comparison_context,
|
|
587
625
|
)
|
|
588
626
|
atomic_write_text(path, updated)
|
|
589
627
|
|
|
@@ -29,6 +29,118 @@ def _capture_assistant_text(value: str) -> str:
|
|
|
29
29
|
flags=re.IGNORECASE)
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _assistant_source_matches(messages: Any, content: str) -> bool:
|
|
33
|
+
"""Accept exact text or an exactly reconstructed native verifier footer.
|
|
34
|
+
|
|
35
|
+
No generic prefix/suffix matching. Tool receipts are used only to check
|
|
36
|
+
Hermes' display transformation, never as message times or memory facts.
|
|
37
|
+
Unsupported host formats fail closed.
|
|
38
|
+
"""
|
|
39
|
+
if not isinstance(messages, list) or not messages:
|
|
40
|
+
return False
|
|
41
|
+
final = messages[-1]
|
|
42
|
+
if not isinstance(final, Mapping) or final.get("role") != "assistant" or final.get("tool_calls"):
|
|
43
|
+
return False
|
|
44
|
+
raw = final.get("content")
|
|
45
|
+
if raw == content:
|
|
46
|
+
return True
|
|
47
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
48
|
+
return False
|
|
49
|
+
try:
|
|
50
|
+
from agent.turn_explainers import TurnExplainersMixin
|
|
51
|
+
from agent.tool_dispatch_helpers import _extract_error_preview, _extract_file_mutation_targets
|
|
52
|
+
from agent.tool_result_classification import file_mutation_result_landed
|
|
53
|
+
# The last real user bounds the supported single-message legacy turn.
|
|
54
|
+
anchor = max(i for i, row in enumerate(messages[:-1])
|
|
55
|
+
if isinstance(row, Mapping) and row.get("role") == "user")
|
|
56
|
+
tail = messages[anchor:-1]
|
|
57
|
+
calls, results = _visible_tool_calls(tail), _visible_tool_results(tail)
|
|
58
|
+
failed = {}
|
|
59
|
+
for call in calls:
|
|
60
|
+
name, args = call.get("name"), call.get("arguments")
|
|
61
|
+
if name not in {"write_file", "patch"} or not isinstance(args, Mapping):
|
|
62
|
+
continue
|
|
63
|
+
matches = [row for row in results if call.get("call_id")
|
|
64
|
+
and row.get("call_id") == call["call_id"]]
|
|
65
|
+
if not matches:
|
|
66
|
+
# Hermes' persisted transcript can omit tool_call_id. Accept
|
|
67
|
+
# only one call immediately followed by its named tool row.
|
|
68
|
+
for index, row in enumerate(tail[:-1]):
|
|
69
|
+
row_calls = _visible_tool_calls([row])
|
|
70
|
+
following = tail[index + 1]
|
|
71
|
+
if (len(row_calls) == 1 and row_calls[0] == call
|
|
72
|
+
and isinstance(following, Mapping) and following.get("role") == "tool"
|
|
73
|
+
and not following.get("tool_call_id")
|
|
74
|
+
and following.get("name", following.get("tool_name")) == name):
|
|
75
|
+
matches.append({"payload": following.get("content")})
|
|
76
|
+
if len(matches) != 1:
|
|
77
|
+
return False
|
|
78
|
+
payload = matches[0]["payload"]
|
|
79
|
+
decoded = _decode_tool_value(payload)
|
|
80
|
+
if not isinstance(decoded, Mapping):
|
|
81
|
+
return False
|
|
82
|
+
targets = _extract_file_mutation_targets(name, dict(args))
|
|
83
|
+
if decoded.get("error") and not file_mutation_result_landed(name, payload):
|
|
84
|
+
for path in targets:
|
|
85
|
+
failed.setdefault(path, {"tool": name, "error_preview": _extract_error_preview(payload)})
|
|
86
|
+
else:
|
|
87
|
+
for path in targets:
|
|
88
|
+
failed.pop(path, None)
|
|
89
|
+
footer = TurnExplainersMixin._format_file_mutation_failure_footer(failed)
|
|
90
|
+
return bool(footer) and content == raw.rstrip() + "\n\n" + footer
|
|
91
|
+
except (ImportError, AttributeError, TypeError, ValueError, KeyError):
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _legacy_correction_users(messages: Any, merged: str) -> Optional[list[Mapping[str, Any]]]:
|
|
96
|
+
"""Check the actual bounded correction rows against Hermes' merge format.
|
|
97
|
+
|
|
98
|
+
Never split user text by a delimiter. Admission rows and the native replay
|
|
99
|
+
scaffold identify corrections; full reconstruction must match uniquely.
|
|
100
|
+
"""
|
|
101
|
+
if not isinstance(messages, list) or not messages:
|
|
102
|
+
return None
|
|
103
|
+
users = []
|
|
104
|
+
for row in reversed(messages[:-1]):
|
|
105
|
+
if not isinstance(row, Mapping) or row.get("role") != "user":
|
|
106
|
+
continue
|
|
107
|
+
if not isinstance(row.get("content"), str) or not row["content"].strip():
|
|
108
|
+
return None
|
|
109
|
+
users.append(row)
|
|
110
|
+
if len(users) > 63:
|
|
111
|
+
return None
|
|
112
|
+
api = row.get("api_content")
|
|
113
|
+
correction = (isinstance(api, str) and api.startswith(
|
|
114
|
+
"[Context from the interrupted assistant response]\n[This response was interrupted by a user correction.]")
|
|
115
|
+
and api.endswith("\n\n" + row["content"]))
|
|
116
|
+
if not correction:
|
|
117
|
+
break
|
|
118
|
+
if len(users) < 2:
|
|
119
|
+
return None
|
|
120
|
+
users.reverse()
|
|
121
|
+
reconstructed = users[0]["content"]
|
|
122
|
+
for row in users[1:]:
|
|
123
|
+
reconstructed += "\n\nUser correction during the turn: " + row["content"]
|
|
124
|
+
return users if reconstructed == merged else None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _sync_delivery_digest(messages: Any, users: Any, user_content: str, assistant_content: str) -> str:
|
|
128
|
+
"""Disambiguate identical text deliveries using verified host source rows."""
|
|
129
|
+
identities = []
|
|
130
|
+
if _assistant_source_matches(messages, assistant_content):
|
|
131
|
+
for row in [*(users or []), messages[-1]]:
|
|
132
|
+
uid = row.get("message_uid") or row.get("message_id")
|
|
133
|
+
timestamp = _independent_assistant_time(row, "").get("assistant", {}).get("source_time")
|
|
134
|
+
if not uid and not timestamp:
|
|
135
|
+
identities = []
|
|
136
|
+
break
|
|
137
|
+
identities.append((uid, timestamp))
|
|
138
|
+
payload = f"{user_content}\x00{_capture_assistant_text(assistant_content)}"
|
|
139
|
+
if identities:
|
|
140
|
+
payload += "\x00" + json.dumps(identities, ensure_ascii=False, separators=(",", ":"))
|
|
141
|
+
return sha256(payload.encode()).hexdigest()[:16]
|
|
142
|
+
|
|
143
|
+
|
|
32
144
|
def _sync_source_metadata(messages: Any, user_content: str, assistant_content: str) -> dict[str, dict[str, Any]]:
|
|
33
145
|
"""Read optional host metadata only from the exact visible tail pair.
|
|
34
146
|
|
|
@@ -43,8 +155,7 @@ def _sync_source_metadata(messages: Any, user_content: str, assistant_content: s
|
|
|
43
155
|
logger.info("memleaf source-metadata reason=messages_missing")
|
|
44
156
|
return {}
|
|
45
157
|
assistant = messages[-1]
|
|
46
|
-
if
|
|
47
|
-
or assistant.get("content") != assistant_content or assistant.get("tool_calls")):
|
|
158
|
+
if not _assistant_source_matches(messages, assistant_content):
|
|
48
159
|
logger.info("memleaf source-metadata reason=assistant_mismatch")
|
|
49
160
|
return {}
|
|
50
161
|
# Tool rounds do not define a turn boundary. Stop at the nearest user,
|
|
@@ -149,7 +260,7 @@ def _host_visible_events(messages: Any, context: Any, session_id: str,
|
|
|
149
260
|
return None
|
|
150
261
|
final = messages[-1]
|
|
151
262
|
if (not isinstance(final, Mapping) or final.get("role") != "assistant"
|
|
152
|
-
or
|
|
263
|
+
or not _assistant_source_matches(messages, assistant_content)):
|
|
153
264
|
return None
|
|
154
265
|
completed_uid = context.get("completed_assistant_message_id")
|
|
155
266
|
completed = completed_uid is not None
|
|
@@ -195,7 +306,7 @@ def _host_visible_events(messages: Any, context: Any, session_id: str,
|
|
|
195
306
|
final_metadata = {}
|
|
196
307
|
for _, item in found:
|
|
197
308
|
user = dict(item, message_id=item["message_uid"])
|
|
198
|
-
pair = _sync_source_metadata([user, assistant], user["content"],
|
|
309
|
+
pair = _sync_source_metadata([user, assistant], user["content"], assistant["content"])
|
|
199
310
|
events.append({"role": "user", "content": user["content"], "metadata": pair.get("user", {})})
|
|
200
311
|
final_metadata = pair.get("assistant", {})
|
|
201
312
|
# Never manufacture a sequence by assuming a turn always has two events.
|
|
@@ -1040,8 +1151,9 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1040
1151
|
turn_number: Optional[int],
|
|
1041
1152
|
user_content: str,
|
|
1042
1153
|
assistant_content: str,
|
|
1154
|
+
delivery_digest: Optional[str] = None,
|
|
1043
1155
|
) -> str:
|
|
1044
|
-
pair_digest = sha256(f"{user_content}\x00{assistant_content}".encode("utf-8")).hexdigest()[:16]
|
|
1156
|
+
pair_digest = delivery_digest or sha256(f"{user_content}\x00{assistant_content}".encode("utf-8")).hexdigest()[:16]
|
|
1045
1157
|
pair_key = (session_id, pair_digest)
|
|
1046
1158
|
existing = self._turn_ids_by_pair.get(pair_key)
|
|
1047
1159
|
if existing is not None and turn_number is None:
|
|
@@ -1481,6 +1593,8 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1481
1593
|
source_sequence: int | None = None,
|
|
1482
1594
|
final: bool | None = None,
|
|
1483
1595
|
source_metadata: Mapping[str, Any] | None = None,
|
|
1596
|
+
retrieval_id: str | None = None,
|
|
1597
|
+
retrieval_turn_id: str | None = None,
|
|
1484
1598
|
) -> bool:
|
|
1485
1599
|
metadata = {
|
|
1486
1600
|
key: value for key, value in (source_metadata or {}).items()
|
|
@@ -1500,6 +1614,8 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1500
1614
|
**({"source_sequence": source_sequence} if source_sequence is not None else {}),
|
|
1501
1615
|
**({"final": final} if final is not None else {}),
|
|
1502
1616
|
**metadata,
|
|
1617
|
+
**({"retrieval_id": retrieval_id, "retrieval_turn_id": retrieval_turn_id}
|
|
1618
|
+
if retrieval_id and retrieval_turn_id and role == "assistant" else {}),
|
|
1503
1619
|
"record": True,
|
|
1504
1620
|
"visible": True,
|
|
1505
1621
|
},
|
|
@@ -1783,6 +1899,10 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1783
1899
|
return
|
|
1784
1900
|
|
|
1785
1901
|
effective_session = self._canonical_session_id(session_id or self._session_id)
|
|
1902
|
+
# Snapshot the list at invocation; subsequent tool/turn appends cannot
|
|
1903
|
+
# change which source rows this callback verifies.
|
|
1904
|
+
import copy
|
|
1905
|
+
messages = copy.deepcopy(messages)
|
|
1786
1906
|
# Hermes serializes provider sync work, but this lock also protects
|
|
1787
1907
|
# direct/plugin-level concurrent calls and makes process one-shot per
|
|
1788
1908
|
# captured turn within this provider instance.
|
|
@@ -1798,30 +1918,60 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1798
1918
|
if turn_context is not None and host_events is None:
|
|
1799
1919
|
logger.info("memleaf source-metadata reason=host_event_mismatch")
|
|
1800
1920
|
return
|
|
1921
|
+
correction_users = (_legacy_correction_users(messages, user_content)
|
|
1922
|
+
if host_events is None else None)
|
|
1923
|
+
queued_content = correction_users[0]["content"] if correction_users else user_content
|
|
1924
|
+
source_users = correction_users
|
|
1925
|
+
if source_users is None and isinstance(messages, list):
|
|
1926
|
+
nearest = next((row for row in reversed(messages[:-1])
|
|
1927
|
+
if isinstance(row, Mapping) and row.get("role") == "user"), None)
|
|
1928
|
+
source_users = [nearest] if nearest and nearest.get("content") == user_content else None
|
|
1929
|
+
delivery_digest = _sync_delivery_digest(messages, source_users, user_content, raw_assistant_content)
|
|
1801
1930
|
resolved_turn_number = turn_number
|
|
1802
1931
|
if bound_turn_id and type(turn_context.get("turn_number")) is int:
|
|
1803
1932
|
resolved_turn_number = turn_context["turn_number"]
|
|
1804
1933
|
if resolved_turn_number is None:
|
|
1805
|
-
|
|
1934
|
+
# Check this source delivery before consuming another same-text
|
|
1935
|
+
# admission. Old callbacks must not take the next turn's gate.
|
|
1936
|
+
import re
|
|
1937
|
+
cached = self._turn_ids_by_pair.get((effective_session, delivery_digest), "")
|
|
1938
|
+
if cached:
|
|
1939
|
+
numbers = [number for (session, number), identity in self._gate_turn_ids.items()
|
|
1940
|
+
if session == effective_session and identity == cached]
|
|
1941
|
+
pair = sha256(f"{user_content}\x00{captured_assistant_content}".encode()).hexdigest()[:16]
|
|
1942
|
+
match = re.fullmatch(r"turn-([0-9]+)-" + pair, cached)
|
|
1943
|
+
if len(numbers) == 1:
|
|
1944
|
+
resolved_turn_number = numbers[0]
|
|
1945
|
+
elif match:
|
|
1946
|
+
resolved_turn_number = int(match[1])
|
|
1947
|
+
elif (messages is None or source_users and _assistant_source_matches(messages, raw_assistant_content)):
|
|
1948
|
+
resolved_turn_number = self._take_turn_number(effective_session, queued_content)
|
|
1806
1949
|
elif isinstance(resolved_turn_number, int) and not isinstance(resolved_turn_number, bool):
|
|
1807
|
-
queued_content = host_events[0]["content"] if host_events else
|
|
1950
|
+
queued_content = host_events[0]["content"] if host_events else queued_content
|
|
1808
1951
|
self._discard_turn_number(effective_session, queued_content, resolved_turn_number)
|
|
1809
1952
|
turn_id = self._resolve_turn_id(
|
|
1810
1953
|
effective_session,
|
|
1811
1954
|
resolved_turn_number,
|
|
1812
1955
|
user_content,
|
|
1813
1956
|
captured_assistant_content,
|
|
1957
|
+
delivery_digest=delivery_digest,
|
|
1814
1958
|
)
|
|
1815
|
-
source_metadata = ({} if host_events is not None else
|
|
1959
|
+
source_metadata = ({} if host_events is not None or correction_users else
|
|
1816
1960
|
_sync_source_metadata(messages, user_content, raw_assistant_content))
|
|
1817
1961
|
user_source_id = source_metadata.get("user", {}).get("message_id")
|
|
1962
|
+
retrieval_id = self._gate_id_for_turn(effective_session, resolved_turn_number)
|
|
1963
|
+
retrieval_turn_id = self._gate_turn_id(effective_session, resolved_turn_number)
|
|
1818
1964
|
if bound_turn_id:
|
|
1819
1965
|
turn_id = bound_turn_id
|
|
1966
|
+
elif retrieval_id and retrieval_turn_id:
|
|
1967
|
+
# Reuse the identity frozen at on_turn_start. Comparison
|
|
1968
|
+
# reads and capture must refer to the identical host turn.
|
|
1969
|
+
turn_id = retrieval_turn_id
|
|
1820
1970
|
elif user_source_id:
|
|
1821
1971
|
# Stable host identity survives a revised body/final reply.
|
|
1822
1972
|
turn_id = "source-" + sha256(user_source_id.encode("utf-8")).hexdigest()[:24]
|
|
1823
1973
|
source_metadata["assistant"].setdefault("previous_message_id", user_source_id)
|
|
1824
|
-
|
|
1974
|
+
self._turn_ids_by_pair[(effective_session, delivery_digest)] = turn_id
|
|
1825
1975
|
# A background callback must never attach itself to a newer turn.
|
|
1826
1976
|
if self._gate_enabled:
|
|
1827
1977
|
audit_state: dict[str, Any] = {}
|
|
@@ -1850,6 +2000,21 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1850
2000
|
for role, content in visible_events]
|
|
1851
2001
|
sequence_base = (resolved_turn_number * 2
|
|
1852
2002
|
if not bound_turn_id and isinstance(resolved_turn_number, int) else None)
|
|
2003
|
+
if correction_users and resolved_turn_number is not None and _assistant_source_matches(messages, raw_assistant_content):
|
|
2004
|
+
# Capture each verified source row separately. A correction's
|
|
2005
|
+
# timestamp never becomes the timestamp of the merged text.
|
|
2006
|
+
capture_events = []
|
|
2007
|
+
for index, row in enumerate(correction_users):
|
|
2008
|
+
pair = _sync_source_metadata([row, messages[-1]], row["content"], messages[-1]["content"])
|
|
2009
|
+
metadata = pair.get("user", {})
|
|
2010
|
+
metadata["message_id"] = row.get("message_uid") or f"{turn_id}/user-{index}"
|
|
2011
|
+
capture_events.append({"role": "user", "content": row["content"], "metadata": metadata})
|
|
2012
|
+
assistant_metadata = pair.get("assistant", {})
|
|
2013
|
+
assistant_metadata["previous_message_id"] = capture_events[-1]["metadata"]["message_id"]
|
|
2014
|
+
capture_events.append({"role": "assistant", "content": captured_assistant_content,
|
|
2015
|
+
"metadata": assistant_metadata})
|
|
2016
|
+
sequence_base = None
|
|
2017
|
+
logger.info("memleaf source-metadata contract=legacy-corrections user_events=%d", len(correction_users))
|
|
1853
2018
|
for offset, event in enumerate(capture_events):
|
|
1854
2019
|
role = event["role"]
|
|
1855
2020
|
if not self._capture_visible(
|
|
@@ -1858,6 +2023,8 @@ class MemleafMemoryProvider(MemoryProvider):
|
|
|
1858
2023
|
source_sequence=(sequence_base + offset if sequence_base is not None else None),
|
|
1859
2024
|
final=True if role == "assistant" else None,
|
|
1860
2025
|
source_metadata=event["metadata"],
|
|
2026
|
+
retrieval_id=retrieval_id if role == "assistant" else None,
|
|
2027
|
+
retrieval_turn_id=retrieval_turn_id if role == "assistant" else None,
|
|
1861
2028
|
):
|
|
1862
2029
|
return
|
|
1863
2030
|
if not self._auto_process:
|
|
@@ -62,6 +62,7 @@ class InboxEvent:
|
|
|
62
62
|
tool_evidence: tuple[dict[str, str], ...] = ()
|
|
63
63
|
legacy: bool = False
|
|
64
64
|
explicit_input: dict[str, Any] | None = None
|
|
65
|
+
comparison_context: dict[str, Any] | None = None
|
|
65
66
|
|
|
66
67
|
@property
|
|
67
68
|
def processable(self) -> bool:
|
|
@@ -306,6 +307,8 @@ def parse_inbox_text(
|
|
|
306
307
|
timestamp=source_time if isinstance(source_time, str) and source_time else None,
|
|
307
308
|
tool_evidence=_bounded_tool_evidence(metadata.get("tool_evidence")),
|
|
308
309
|
explicit_input=explicit_input,
|
|
310
|
+
comparison_context=(metadata.get("comparison_context")
|
|
311
|
+
if isinstance(metadata.get("comparison_context"), dict) else None),
|
|
309
312
|
)
|
|
310
313
|
)
|
|
311
314
|
|