memleaf 0.2.91__tar.gz → 0.2.92__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.91 → memleaf-0.2.92}/CHANGELOG.md +29 -0
- {memleaf-0.2.91/src/memleaf.egg-info → memleaf-0.2.92}/PKG-INFO +5 -4
- {memleaf-0.2.91 → memleaf-0.2.92}/README.en.md +4 -3
- {memleaf-0.2.91 → memleaf-0.2.92}/README.md +4 -3
- {memleaf-0.2.91 → memleaf-0.2.92}/pyproject.toml +1 -1
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/compaction.py +15 -3
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/extraction_work_state.py +48 -1
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_semantics.py +26 -0
- memleaf-0.2.92/src/memleaf/lifecycle_maintenance.py +195 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/process_jobs.py +15 -3
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/processing_route.py +4 -1
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/remember_route.py +4 -1
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/retention.py +22 -18
- {memleaf-0.2.91 → memleaf-0.2.92/src/memleaf.egg-info}/PKG-INFO +5 -4
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf.egg-info/SOURCES.txt +1 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/LICENSE +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/MANIFEST.in +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/acceptance-evidence-guide.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/automatic-processing-route.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/body-compaction.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/candidate-reconciliation.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/capture-budget-design.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/config-migrations.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/core-refactor.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/development-closeout.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/evidence-retention.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/explicit-memory-update.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/extraction-latency.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/failed-run-recovery.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/gate-evidence-boundary.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/general-processing.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/hermes-mcp-runtime.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/hermes-provider-compatibility.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/hermes-source-comparison-context.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/host-acceptance-runbook.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/immutable-release-assets.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/incremental-commit-contract.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/incremental-execution.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/incremental-native-comparison.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/incremental-partial-recovery.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/incremental-planner-preview.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/incremental-runtime-contract.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/incremental-scope-registration.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/incremental-selected-retention.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/installed-artifact-verification.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/memory-lifecycle-tools.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/memory-state-contract.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/migration-preflight.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/processing-quality-acceptance.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/public-query-integrity.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/query-performance.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/remember-incremental-route.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/runtime-state-retention.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/semantic-extraction-protocol.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/source-work-contract.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/docs/v0.2.26-processing-status.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/examples/README.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/examples/basic_usage.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/examples/incremental_acceptance.json +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/examples/query_benchmark.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/install.ps1 +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/install.sh +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/setup.cfg +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/acceptance.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/admission.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/batch_review.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/config.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/create_coordinator.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/evidence_budget.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/evidence_policy.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/evidence_structure.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/evidence_syntax.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/explicit_text_source.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/extraction_budget.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/extraction_capability.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/hermes_provider/_provider.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/hermes_provider/_shared.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/hermes_provider/provider_compatibility.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/hermes_runtime.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/historical_context.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/host_retention.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/host_turn_identity.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_commit.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_dates.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_execution.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_explicit.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_failed_recovery.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_journal.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_merge.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_native.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_partial.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_preview.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_prompts.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_protocol.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_recovery.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_run_state.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_runtime.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_scopes.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/incremental_selection.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/index.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/inspection.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/legacy_update_journal.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/llm/thinking.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/memory_commit.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/memory_planner.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/memory_retraction.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/memory_update.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/migration.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/model_capabilities.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/model_execution.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/models.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/native_registration.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/parallel_model.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/planning_candidates.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/planning_context.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/process_common.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/process_journal.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/process_owner.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/processing.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/prompts.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/provenance.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/provider_compatibility.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/query_clock.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/query_progress.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/query_scan.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/receipt_codec.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/recording_policy.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/retrieval_lifecycle.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/runtime_retention.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/semantic_maintenance.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/semantic_protocol.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/service.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/single_pass_memory_planner.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/single_pass_plan.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/source_policy.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/state_layout.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/subprocess_flags.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/summary_batch.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/target_reconciliation.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/turn_audit.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/turn_plan.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/update_coordinator.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/update_review.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/validation.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.91 → memleaf-0.2.92}/src/memleaf.egg-info/top_level.txt +0 -0
|
@@ -4,6 +4,35 @@ All notable changes to memleaf are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
|
|
8
|
+
## 0.2.92 — 2026-10-05
|
|
9
|
+
|
|
10
|
+
### Preserve unresolved review outcomes and schedule bounded lifecycle maintenance
|
|
11
|
+
|
|
12
|
+
- Retain source-bound DEFERRED when review rejects UPDATE, MERGE, or explicitly
|
|
13
|
+
requested CREATE, even if other NO_CHANGE candidates survive. Only a validated
|
|
14
|
+
same-target correction covering the evidence and fields can resolve a rejected
|
|
15
|
+
UPDATE. Unrelated candidates cannot consume an unresolved host retention intent.
|
|
16
|
+
- Run deterministic history/provenance maintenance after normal process and
|
|
17
|
+
remember operations. Apply bounded version/age retention or keep_all, and
|
|
18
|
+
protect pending mutation and recovery dependencies without replaying old work.
|
|
19
|
+
- Schedule at most one optional body-compaction request per processing batch
|
|
20
|
+
after the configured threshold is reached. Use a freshly completed turn's
|
|
21
|
+
remaining durable allowance, with extraction and maintenance sharing its
|
|
22
|
+
five-request limit; never reopen completed budgets or allocate a new allowance.
|
|
23
|
+
- Persist recent input fingerprints and reservations before dispatch, including
|
|
24
|
+
no-op, failed and interrupted requests. Preserve compaction identity and all
|
|
25
|
+
metadata, serialize model ownership, recheck dependencies at commit, and report
|
|
26
|
+
maintenance status and observed token usage independently of committed facts.
|
|
27
|
+
- Ordinary turns below the threshold and cached replays add no model calls.
|
|
28
|
+
No new user-facing tool or model-output protocol field is introduced.
|
|
29
|
+
- Validation: 175 focused checks and seven isolated replay scenarios passed,
|
|
30
|
+
including the frozen real draft under approval and veto, host-intent retention,
|
|
31
|
+
process exit/restart, budget exhaustion, concurrent mutation protection, and
|
|
32
|
+
41 public-process updates retaining one identity, 32 historical versions and
|
|
33
|
+
cumulative source accounting. External model calls were zero; new live-model
|
|
34
|
+
acceptance remains separate.
|
|
35
|
+
|
|
7
36
|
## 0.2.91 — 2026-10-05
|
|
8
37
|
|
|
9
38
|
### Preserve historical reads in incremental comparison
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.92
|
|
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.92。**
|
|
27
27
|
> 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
|
|
28
28
|
> 提炼先比较旧事项中的编号、目标、进展、责任和期限;补全未知信息也应维护原事项,另建独立待办不能替代项目事实更新。确认重复的记忆在现有处理请求中合并,保留历史与来源;项目和独立任务保持各自生命周期,Core 不按标题强行合并。
|
|
29
29
|
> 新请求兼容等价的字段表示;未知执行人保持 `null`,新增或变更负责人和期限需要真实来源;等待条件在正文维护,不再提炼 `waiting_on`。行动及相关字段经过有界语义复核,提炼与复核共用每份工作的最多五次模型请求额度,成功即停止;复核失败不写入。来源时间用于解析相对日期,不补造事实日期。
|
|
@@ -462,8 +462,9 @@ history:
|
|
|
462
462
|
- active Markdown 每条最多保留 16 个来源明细,同时记录累计 `source_count`、`source_digest` 和省略数量,避免 UPDATE 让 `sources` 无限膨胀;
|
|
463
463
|
- 已完成/已取消 todo 保留在 `knowledge/` 中作为稳定当前身份,默认 active 清单不展示,但 `list_todos(status=completed|cancelled|all)` 和后续维护仍可按原 ID 定位;`closed_todo_retention_days` 仅作为旧配置兼容项保留;
|
|
464
464
|
- `history.policy: bounded` 默认每个稳定记忆身份最多保留 32 个完整历史版本,且超过 3650 天的版本可被维护流程淘汰;需要永久审计时可显式设置 `history.policy: keep_all`;
|
|
465
|
-
- compaction
|
|
466
|
-
-
|
|
465
|
+
- 正文 compaction 逐条精简,保持原 `memory_id` 和来源、责任、状态、期限,不合并独立身份;同一事项的语义合并由提炼协议的 `MERGE` 处理。
|
|
466
|
+
- 正常 `process()` / `remember()` 结束后执行确定性历史维护;有未完成写入或恢复依赖时推迟维护,保留恢复输入。普通只读检索不触发维护写入。
|
|
467
|
+
- 新完成的轮次达到正文压缩阈值时,每批最多一次压缩调用,与该轮提炼共用不超过 5 次的持久额度。低于阈值、缓存重放、近期相同输入已尝试时不增加调用;持久保留最近 64 个输入指纹,压缩无收益、失败或进程中断也计入去重。维护结果独立报告,不将已提交的事实回滚。显式 `compact()` 仍只执行正文压缩,不隐式清理历史;无需 daemon。
|
|
467
468
|
|
|
468
469
|
默认目录尽量使用 `0700`,文件默认明文保存。memleaf 没有内置加密层,请自行保护 Vault、备份和模型调用凭证。
|
|
469
470
|
|
|
@@ -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.92.**
|
|
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. Confirmed duplicates can be merged in the existing processing requests with their sources and history preserved. Independent project and task lifecycles remain separate; Core does not merge matching titles.
|
|
10
10
|
> New requests accept equivalent field representations. Unknown executors remain `null`; new or changed responsibility and deadlines require actual source evidence. Dependencies belong in the body; `waiting_on` is no longer extracted. Proposed actions and related fields undergo bounded semantic verification within a shared five-request limit per work item, stopping on success; failed verification blocks writes. Source time resolves relative dates without inventing factual dates.
|
|
@@ -442,8 +442,9 @@ history:
|
|
|
442
442
|
- Each active Markdown memory retains at most 16 detailed provenance rows while tracking cumulative `source_count`, `source_digest`, and omitted rows, so repeated UPDATEs cannot grow `sources` without bound.
|
|
443
443
|
- Completed/cancelled todos remain in `knowledge/` as stable current identities. The default active view hides them, while `list_todos(status=completed|cancelled|all)` and later maintenance can still resolve the original ID. `closed_todo_retention_days` remains only as a legacy configuration key.
|
|
444
444
|
- With `history.policy: bounded`, each stable memory identity keeps at most 32 full historical versions, and versions older than 3650 days are eligible for pruning. Set `history.policy: keep_all` explicitly when permanent audit retention is required.
|
|
445
|
-
-
|
|
446
|
-
-
|
|
445
|
+
- Body compaction shortens each memory independently, preserving its ID, provenance, responsibility, status and deadline. Semantic consolidation of the same matter uses the extraction protocol's `MERGE` operation.
|
|
446
|
+
- Normal `process()` / `remember()` calls perform deterministic history maintenance after processing. Pending mutations and recovery dependencies postpone maintenance and preserve recovery inputs. Read-only retrieval does not trigger maintenance writes.
|
|
447
|
+
- Freshly completed turns can schedule at most one compaction request per batch when the configured threshold is reached, sharing that turn's durable five-request allowance with extraction. Below-threshold turns, cached replays and recently attempted identical inputs add no calls. The latest 64 input fingerprints are persisted, including no-op, failed and interrupted attempts. Maintenance results are reported separately without undoing committed facts. Explicit `compact()` remains body-only and does not implicitly prune history. No daemon is required.
|
|
447
448
|
|
|
448
449
|
Directories are normally created with mode `0700`, and files are stored as plaintext by default. memleaf has no built-in encryption layer; protect the Vault, backups, and model credentials yourself.
|
|
449
450
|
|
|
@@ -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.92。**
|
|
8
8
|
> 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
|
|
9
9
|
> 提炼先比较旧事项中的编号、目标、进展、责任和期限;补全未知信息也应维护原事项,另建独立待办不能替代项目事实更新。确认重复的记忆在现有处理请求中合并,保留历史与来源;项目和独立任务保持各自生命周期,Core 不按标题强行合并。
|
|
10
10
|
> 新请求兼容等价的字段表示;未知执行人保持 `null`,新增或变更负责人和期限需要真实来源;等待条件在正文维护,不再提炼 `waiting_on`。行动及相关字段经过有界语义复核,提炼与复核共用每份工作的最多五次模型请求额度,成功即停止;复核失败不写入。来源时间用于解析相对日期,不补造事实日期。
|
|
@@ -443,8 +443,9 @@ history:
|
|
|
443
443
|
- active Markdown 每条最多保留 16 个来源明细,同时记录累计 `source_count`、`source_digest` 和省略数量,避免 UPDATE 让 `sources` 无限膨胀;
|
|
444
444
|
- 已完成/已取消 todo 保留在 `knowledge/` 中作为稳定当前身份,默认 active 清单不展示,但 `list_todos(status=completed|cancelled|all)` 和后续维护仍可按原 ID 定位;`closed_todo_retention_days` 仅作为旧配置兼容项保留;
|
|
445
445
|
- `history.policy: bounded` 默认每个稳定记忆身份最多保留 32 个完整历史版本,且超过 3650 天的版本可被维护流程淘汰;需要永久审计时可显式设置 `history.policy: keep_all`;
|
|
446
|
-
- compaction
|
|
447
|
-
-
|
|
446
|
+
- 正文 compaction 逐条精简,保持原 `memory_id` 和来源、责任、状态、期限,不合并独立身份;同一事项的语义合并由提炼协议的 `MERGE` 处理。
|
|
447
|
+
- 正常 `process()` / `remember()` 结束后执行确定性历史维护;有未完成写入或恢复依赖时推迟维护,保留恢复输入。普通只读检索不触发维护写入。
|
|
448
|
+
- 新完成的轮次达到正文压缩阈值时,每批最多一次压缩调用,与该轮提炼共用不超过 5 次的持久额度。低于阈值、缓存重放、近期相同输入已尝试时不增加调用;持久保留最近 64 个输入指纹,压缩无收益、失败或进程中断也计入去重。维护结果独立报告,不将已提交的事实回滚。显式 `compact()` 仍只执行正文压缩,不隐式清理历史;无需 daemon。
|
|
448
449
|
|
|
449
450
|
默认目录尽量使用 `0700`,文件默认明文保存。memleaf 没有内置加密层,请自行保护 Vault、备份和模型调用凭证。
|
|
450
451
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Local-first Markdown memory core for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
3
|
+
__version__ = "0.2.92"
|
|
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
|
|
@@ -605,10 +605,13 @@ class Compactor:
|
|
|
605
605
|
replacements: list[_Replacement],
|
|
606
606
|
*,
|
|
607
607
|
now: str,
|
|
608
|
+
before_commit: Any = None,
|
|
608
609
|
) -> tuple[list[str], list[str]]:
|
|
609
610
|
replacement_ids = [replacement.memory.memory_id for replacement in replacements]
|
|
610
611
|
selected_by_id = {candidate.memory.memory_id: candidate for candidate in selected}
|
|
611
612
|
with self.service._mutation_boundary():
|
|
613
|
+
if before_commit is not None:
|
|
614
|
+
before_commit()
|
|
612
615
|
current_scan = scan_memories(self.service.vault)
|
|
613
616
|
if current_scan.issues:
|
|
614
617
|
raise CompactionError("current-memory scan changed during compaction")
|
|
@@ -737,7 +740,8 @@ class Compactor:
|
|
|
737
740
|
"history_written": [],
|
|
738
741
|
}
|
|
739
742
|
|
|
740
|
-
def _run(self, *, model: Any = None, router: Any = None, explicit: bool
|
|
743
|
+
def _run(self, *, model: Any = None, router: Any = None, explicit: bool,
|
|
744
|
+
admit: Any = None, before_commit: Any = None) -> dict[str, Any]:
|
|
741
745
|
threshold, ratio = self._config()
|
|
742
746
|
now = _clock_now(getattr(self.service, "clock", None))
|
|
743
747
|
selected, all_active, active_tokens = self._snapshot(threshold, ratio)
|
|
@@ -758,6 +762,11 @@ class Compactor:
|
|
|
758
762
|
raise CompactionError("compaction input exceeds size limit")
|
|
759
763
|
result["request_bytes"] = request_bytes
|
|
760
764
|
backend = self._resolve_backend(model=model, router=router)
|
|
765
|
+
if admit is not None:
|
|
766
|
+
reason = admit(selected, threshold, ratio)
|
|
767
|
+
if reason is not None:
|
|
768
|
+
result.update(status="deferred", code=reason)
|
|
769
|
+
return result
|
|
761
770
|
try:
|
|
762
771
|
result["backend_calls"] += 1
|
|
763
772
|
raw = backend.complete(
|
|
@@ -788,6 +797,7 @@ class Compactor:
|
|
|
788
797
|
all_active,
|
|
789
798
|
replacements,
|
|
790
799
|
now=now,
|
|
800
|
+
before_commit=before_commit,
|
|
791
801
|
)
|
|
792
802
|
with self.service.vault.lock():
|
|
793
803
|
active_after = [record.memory for record in self.service._read_memories_unlocked("knowledge")]
|
|
@@ -826,9 +836,11 @@ class Compactor:
|
|
|
826
836
|
def compact(self, *, model: Any = None, router: Any = None) -> dict[str, Any]:
|
|
827
837
|
return self._run(model=model, router=router, explicit=True)
|
|
828
838
|
|
|
829
|
-
def auto(self, *, model: Any = None, router: Any = None
|
|
839
|
+
def auto(self, *, model: Any = None, router: Any = None,
|
|
840
|
+
admit: Any = None, before_commit: Any = None) -> dict[str, Any]:
|
|
830
841
|
try:
|
|
831
|
-
return self._run(model=model, router=router, explicit=False
|
|
842
|
+
return self._run(model=model, router=router, explicit=False,
|
|
843
|
+
admit=admit, before_commit=before_commit)
|
|
832
844
|
except Exception as error:
|
|
833
845
|
status = _error_kind(error)
|
|
834
846
|
return {
|
|
@@ -28,6 +28,7 @@ _MAX_BUDGET_BYTES = 8 * 1024 * 1024
|
|
|
28
28
|
_PROCESS_JOB_VERSION = 1
|
|
29
29
|
_MAX_WORKS = 128
|
|
30
30
|
_MAX_TURNS_PER_WORK = 64
|
|
31
|
+
_MAX_MAINTENANCE_SNAPSHOTS = 64
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
class ExtractionWorkStateError(RuntimeError):
|
|
@@ -95,11 +96,15 @@ def _normalize_turn_state(value: Any) -> dict[str, Any]:
|
|
|
95
96
|
completed = value.get("completed", False)
|
|
96
97
|
if not isinstance(completed, bool):
|
|
97
98
|
raise ExtractionWorkStateError("invalid extraction request budget completion")
|
|
99
|
+
maintenance_reserved = value.get("maintenance_reserved", False)
|
|
100
|
+
if type(maintenance_reserved) is not bool or (maintenance_reserved and not completed):
|
|
101
|
+
raise ExtractionWorkStateError("invalid maintenance request reservation")
|
|
98
102
|
return {
|
|
99
103
|
"requests": requests,
|
|
100
104
|
"started_at_epoch": float(started) if started is not None else None,
|
|
101
105
|
"request_limit_at_creation": stored_limit,
|
|
102
106
|
"completed": completed,
|
|
107
|
+
**({"maintenance_reserved": True} if maintenance_reserved else {}),
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
|
|
@@ -164,7 +169,13 @@ def _read_budget_state_unlocked(vault: Any) -> dict[str, Any]:
|
|
|
164
169
|
retired_count = sum(w.get("retired", False) for w in normalized_works.values())
|
|
165
170
|
if len(normalized_works) - retired_count > _MAX_WORKS or retired_count > _MAX_RETIRED_WORKS:
|
|
166
171
|
raise ExtractionWorkStateError("extraction request budget state exceeds bound")
|
|
167
|
-
|
|
172
|
+
snapshots = value.get("maintenance_snapshots", [])
|
|
173
|
+
if (not isinstance(snapshots, list) or len(snapshots) > _MAX_MAINTENANCE_SNAPSHOTS
|
|
174
|
+
or any(not isinstance(s, str) or re.fullmatch(r"[0-9a-f]{64}", s) is None for s in snapshots)
|
|
175
|
+
or len(set(snapshots)) != len(snapshots)):
|
|
176
|
+
raise ExtractionWorkStateError("invalid maintenance snapshots")
|
|
177
|
+
return {"version": value["version"], "works": normalized_works, "order": normalized_order,
|
|
178
|
+
**({"maintenance_snapshots": snapshots} if snapshots else {})}
|
|
168
179
|
|
|
169
180
|
|
|
170
181
|
def _save_budget_state_unlocked(vault: Any, state: dict[str, Any]) -> None:
|
|
@@ -401,6 +412,42 @@ def complete_turn_budget(vault: Any, *, work_id: str, turn_id: str) -> bool:
|
|
|
401
412
|
return False
|
|
402
413
|
|
|
403
414
|
|
|
415
|
+
def _remember_maintenance_snapshot_unlocked(vault: Any, state: dict[str, Any], snapshot: str) -> None:
|
|
416
|
+
if not isinstance(snapshot, str) or re.fullmatch(r"[0-9a-f]{64}", snapshot) is None:
|
|
417
|
+
raise ExtractionWorkStateError("invalid maintenance snapshot")
|
|
418
|
+
snapshots = [s for s in state.get("maintenance_snapshots", []) if s != snapshot]
|
|
419
|
+
state["maintenance_snapshots"] = (snapshots + [snapshot])[-_MAX_MAINTENANCE_SNAPSHOTS:]
|
|
420
|
+
_save_budget_state_unlocked(vault, state)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _reserve_maintenance_unlocked(vault: Any, *, work_id: str, turn_id: str,
|
|
424
|
+
snapshot: str, minimum_requests: int = 0) -> tuple[int | None, str | None]:
|
|
425
|
+
"""Caller holds the lock and proves the linked run fully committed.
|
|
426
|
+
|
|
427
|
+
Only one optional maintenance call can use the remaining allowance of a
|
|
428
|
+
completed turn. Completion stays sealed; extraction can never resume from
|
|
429
|
+
this reservation. Count and input deduplication precede outbound IO in the
|
|
430
|
+
same atomic file write, including uncertain process exits.
|
|
431
|
+
"""
|
|
432
|
+
state = _read_budget_state_unlocked(vault)
|
|
433
|
+
work = state["works"].get(work_id)
|
|
434
|
+
turn = work.get("turns", {}).get(turn_id) if work else None
|
|
435
|
+
if not turn or not turn["completed"] or work.get("retired"):
|
|
436
|
+
return None, "maintenance_budget_unavailable"
|
|
437
|
+
if turn["requests"] < minimum_requests:
|
|
438
|
+
raise ExtractionWorkStateError("maintenance budget evidence incomplete")
|
|
439
|
+
if snapshot in state.get("maintenance_snapshots", []):
|
|
440
|
+
return None, "maintenance_snapshot_already_attempted"
|
|
441
|
+
if turn.get("maintenance_reserved"):
|
|
442
|
+
return None, "maintenance_already_reserved"
|
|
443
|
+
if turn["requests"] >= min(turn["request_limit_at_creation"], MAX_INCREMENTAL_REQUESTS):
|
|
444
|
+
return None, "request_budget_exhausted"
|
|
445
|
+
turn["requests"] += 1
|
|
446
|
+
turn["maintenance_reserved"] = True
|
|
447
|
+
_remember_maintenance_snapshot_unlocked(vault, state, snapshot)
|
|
448
|
+
return turn["requests"], None
|
|
449
|
+
|
|
450
|
+
|
|
404
451
|
__all__ = [
|
|
405
452
|
"ExtractionWorkStateError",
|
|
406
453
|
"active_background_work_id",
|
|
@@ -153,6 +153,15 @@ def apply_review(request, response):
|
|
|
153
153
|
if set(review) - {"decisions", "updates"} or not isinstance(decisions, list) or len(decisions) != len(rows):
|
|
154
154
|
raise ValueError
|
|
155
155
|
positions = set()
|
|
156
|
+
rejected = []
|
|
157
|
+
def discard(row):
|
|
158
|
+
# A rejected maintenance proposal is not proof that the source
|
|
159
|
+
# needs no change. Keep its unresolved disposition even when other
|
|
160
|
+
# candidates survive the review. Ordinary rejected automatic
|
|
161
|
+
# CREATEs can still mean there was no durable fact to retain.
|
|
162
|
+
if (row.get("action") in {"UPDATE", "MERGE"}
|
|
163
|
+
or data["original_input"].get("request_kind") == "explicit_remember"):
|
|
164
|
+
rejected.append(deepcopy(row))
|
|
156
165
|
for decision in decisions:
|
|
157
166
|
if (not isinstance(decision, dict) or not {"item", "keep", "field_support"} <= set(decision)
|
|
158
167
|
or set(decision) - {"item", "keep", "field_support", "replace"}):
|
|
@@ -206,6 +215,7 @@ def apply_review(request, response):
|
|
|
206
215
|
if isinstance(row, dict) and row.get("action") in {"NO_CHANGE", "NO_MEMORY", "DEFERRED"}:
|
|
207
216
|
continue # Review cannot erase an unresolved control disposition.
|
|
208
217
|
if not decision["keep"] or any(not support.get(k, True) for k in _REQUIRED.get(row["action"].upper(), set())):
|
|
218
|
+
discard(row)
|
|
209
219
|
rows[i] = None
|
|
210
220
|
continue
|
|
211
221
|
payload = row.get("memory" if row["action"].upper() == "CREATE" else "patch", {})
|
|
@@ -241,6 +251,7 @@ def apply_review(request, response):
|
|
|
241
251
|
if name == "deadline":
|
|
242
252
|
row.pop("deadline_decision", None)
|
|
243
253
|
if row["action"].upper() == "UPDATE" and not payload:
|
|
254
|
+
discard(row)
|
|
244
255
|
rows[i] = None
|
|
245
256
|
rows = [r for r in rows if r is not None]
|
|
246
257
|
additions = review.get("updates", [])
|
|
@@ -256,6 +267,21 @@ def apply_review(request, response):
|
|
|
256
267
|
raise ValueError
|
|
257
268
|
rows.append(row)
|
|
258
269
|
used.add(row["target"])
|
|
270
|
+
for rejected_row in rejected:
|
|
271
|
+
# The same review can supply a complete, independently validated
|
|
272
|
+
# correction for this target. An unrelated NO_CHANGE cannot cover
|
|
273
|
+
# a rejected proposal, nor can UPDATE stand in for a rejected MERGE.
|
|
274
|
+
if rejected_row.get("action") == "UPDATE" and any(
|
|
275
|
+
row.get("action") == "UPDATE" and row.get("target") == rejected_row.get("target")
|
|
276
|
+
and set(rejected_row.get("evidence", [])) <= set(row.get("evidence", []))
|
|
277
|
+
and fields(rejected_row) <= fields(row)
|
|
278
|
+
for row in rows):
|
|
279
|
+
continue
|
|
280
|
+
rows.append({"action": "DEFERRED", "evidence": rejected_row.get("evidence", []),
|
|
281
|
+
"reason": "missing_context",
|
|
282
|
+
"need": "The proposed retention or maintenance was rejected by source review."})
|
|
283
|
+
if len(rows) > MAX_ITEMS:
|
|
284
|
+
raise ValueError
|
|
259
285
|
if not rows:
|
|
260
286
|
original = data["original_input"]
|
|
261
287
|
if original.get("request_kind") == "explicit_remember":
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Optional maintenance after extraction, sharing its sealed request allowance.
|
|
2
|
+
|
|
3
|
+
No model call below the configured threshold. At most one batch request can
|
|
4
|
+
use a freshly completed turn's remaining allowance; neither polling nor a
|
|
5
|
+
process restart grants another budget. Only hashes and counters are persisted.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from .compaction import Compactor, CompactionError, _clock_now
|
|
16
|
+
from .extraction_work_state import (_read_budget_state_unlocked, _reserve_maintenance_unlocked,
|
|
17
|
+
_remember_maintenance_snapshot_unlocked)
|
|
18
|
+
from .incremental_run_state import (OWNER, load_run, owner_live, register_owner,
|
|
19
|
+
unregister_owner, safe_call_metrics)
|
|
20
|
+
from .locking import atomic_write_json
|
|
21
|
+
from .models import Memory
|
|
22
|
+
from .process_common import _read_processed
|
|
23
|
+
from .prompts import COMPACT_SYSTEM
|
|
24
|
+
from .retention import RetentionManager
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _signature(selected):
|
|
28
|
+
values = sorted((Compactor._prompt_memory(c) for c in selected), key=lambda m: m["memory_id"])
|
|
29
|
+
raw = json.dumps([COMPACT_SYSTEM, values], ensure_ascii=False,
|
|
30
|
+
sort_keys=True, separators=(",", ":"))
|
|
31
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _MaintenanceBackend:
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.backend = None
|
|
37
|
+
self.calls = 0
|
|
38
|
+
self.failed = False
|
|
39
|
+
self.usage = {}
|
|
40
|
+
|
|
41
|
+
def complete(self, prompt, **kwargs):
|
|
42
|
+
consume = getattr(self.backend, "consume_call_metrics", None)
|
|
43
|
+
if callable(consume):
|
|
44
|
+
try:
|
|
45
|
+
consume()
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
try:
|
|
49
|
+
self.calls += 1
|
|
50
|
+
return self.backend.complete(prompt, **kwargs)
|
|
51
|
+
except Exception:
|
|
52
|
+
self.failed = True
|
|
53
|
+
raise
|
|
54
|
+
finally:
|
|
55
|
+
if callable(consume):
|
|
56
|
+
try:
|
|
57
|
+
self.usage = safe_call_metrics(consume())
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def metrics(self, status):
|
|
62
|
+
if not self.calls:
|
|
63
|
+
return {}
|
|
64
|
+
bucket = {"call_count": self.calls, "failed_calls": int(self.failed),
|
|
65
|
+
"invalid_output_count": int(status == "invalid_output"), **self.usage}
|
|
66
|
+
return {"total": bucket, "stages": {"compact": bucket},
|
|
67
|
+
"calls": [{"stage": "compact", "operation": "compact_primary",
|
|
68
|
+
"call_index": 1, "failed": self.failed,
|
|
69
|
+
"invalid_output": status == "invalid_output", **self.usage}]}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def maintain_lifecycle(service, result, *, run_ids=(), model=None, router=None):
|
|
73
|
+
"""Attach independent maintenance outcomes without undoing committed facts."""
|
|
74
|
+
result["maintenance_model_calls"] = 0
|
|
75
|
+
manager = RetentionManager(service)
|
|
76
|
+
try:
|
|
77
|
+
result["retention"] = manager.maintain(_clock_now(getattr(service, "clock", None)))
|
|
78
|
+
except (OSError, ValueError, RuntimeError):
|
|
79
|
+
result["retention"] = {"maintenance_status": "deferred", "code": "maintenance_state_unavailable"}
|
|
80
|
+
retention = result["retention"]
|
|
81
|
+
retention.setdefault("maintenance_status", "completed")
|
|
82
|
+
if retention.get("maintenance_status") in {"deferred", "partial"}:
|
|
83
|
+
result["compaction"] = {"status": "deferred", "code": retention.get("code"), "backend_calls": 0}
|
|
84
|
+
return result
|
|
85
|
+
if not run_ids or result.get("execution_status") != "completed":
|
|
86
|
+
result["compaction"] = {"status": "not_run", "reason": "no_fresh_completed_turn", "backend_calls": 0}
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
backend = _MaintenanceBackend()
|
|
90
|
+
token = uuid.uuid4().hex
|
|
91
|
+
claimed = False
|
|
92
|
+
selected_input = []
|
|
93
|
+
ordinal = None
|
|
94
|
+
|
|
95
|
+
def safe_dependencies(processed):
|
|
96
|
+
_, protected, unresolved = manager._dependencies_unlocked(processed)
|
|
97
|
+
if protected or unresolved:
|
|
98
|
+
raise CompactionError("pending mutation dependencies")
|
|
99
|
+
|
|
100
|
+
def admit(selected, threshold, ratio):
|
|
101
|
+
nonlocal claimed, selected_input, ordinal
|
|
102
|
+
fingerprint = _signature(selected)
|
|
103
|
+
with service._mutation_boundary():
|
|
104
|
+
processed = _read_processed(service.vault.processed_state_path)
|
|
105
|
+
if owner_live(processed):
|
|
106
|
+
return "processing_busy"
|
|
107
|
+
safe_dependencies(processed)
|
|
108
|
+
# One successful source allowance, never a newly allocated budget.
|
|
109
|
+
run = next((r for rid in reversed(run_ids) if (r := load_run(processed, rid)) is not None
|
|
110
|
+
and r["status"] == "completed" and r.get("budget_finalized")), None)
|
|
111
|
+
if run is None:
|
|
112
|
+
return "maintenance_budget_unavailable"
|
|
113
|
+
from .incremental_runtime import _resolve
|
|
114
|
+
backend.backend = _resolve(service, model, router)
|
|
115
|
+
ordinal, reason = _reserve_maintenance_unlocked(service.vault,
|
|
116
|
+
work_id=run["budget_id"], turn_id=run["turn_budget_id"], snapshot=fingerprint,
|
|
117
|
+
minimum_requests=run["reserved_requests"])
|
|
118
|
+
if reason:
|
|
119
|
+
return reason
|
|
120
|
+
register_owner(token)
|
|
121
|
+
claimed = True
|
|
122
|
+
processed[OWNER] = {"run_id": run["run_id"], "pid": os.getpid(), "token": token}
|
|
123
|
+
atomic_write_json(service.vault.processed_state_path, processed)
|
|
124
|
+
selected_input = selected
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def before_commit():
|
|
128
|
+
# Called inside the compactor's mutation boundary. New explicit work,
|
|
129
|
+
# forget/cancel, or an ownership change during IO postpones the write.
|
|
130
|
+
processed = _read_processed(service.vault.processed_state_path)
|
|
131
|
+
owner = processed.get(OWNER, {})
|
|
132
|
+
run = load_run(processed, owner.get("run_id")) if owner.get("token") == token else None
|
|
133
|
+
if run is None or run["status"] != "completed":
|
|
134
|
+
raise CompactionError("maintenance ownership changed")
|
|
135
|
+
safe_dependencies(processed)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
compaction = Compactor(service).auto(model=backend, admit=admit, before_commit=before_commit)
|
|
139
|
+
if compaction.get("status") == "compacted":
|
|
140
|
+
# Remember the resulting input too: another turn must not pay to
|
|
141
|
+
# compress these same heads again merely because total size is high.
|
|
142
|
+
try:
|
|
143
|
+
with service.vault.lock():
|
|
144
|
+
from .query_scan import MAX_FILE_BYTES
|
|
145
|
+
current = []
|
|
146
|
+
for candidate in selected_input:
|
|
147
|
+
if candidate.path.is_symlink():
|
|
148
|
+
raise CompactionError("unsafe maintenance target")
|
|
149
|
+
with candidate.path.open("rb") as stream:
|
|
150
|
+
raw = stream.read(MAX_FILE_BYTES + 1)
|
|
151
|
+
if len(raw) > MAX_FILE_BYTES:
|
|
152
|
+
raise CompactionError("unsafe maintenance target")
|
|
153
|
+
current.append(replace(candidate, memory=Memory.from_markdown(raw.decode("utf-8"), candidate.path)))
|
|
154
|
+
state = _read_budget_state_unlocked(service.vault)
|
|
155
|
+
_remember_maintenance_snapshot_unlocked(service.vault, state, _signature(current))
|
|
156
|
+
except (OSError, ValueError, RuntimeError):
|
|
157
|
+
compaction["deduplication_status"] = "deferred"
|
|
158
|
+
compaction["code"] = "maintenance_state_unavailable"
|
|
159
|
+
except (OSError, ValueError, RuntimeError):
|
|
160
|
+
compaction = {"status": "deferred", "code": "maintenance_state_unavailable"}
|
|
161
|
+
finally:
|
|
162
|
+
if claimed:
|
|
163
|
+
try:
|
|
164
|
+
with service.vault.lock():
|
|
165
|
+
processed = _read_processed(service.vault.processed_state_path)
|
|
166
|
+
if processed.get(OWNER, {}).get("token") == token:
|
|
167
|
+
processed.pop(OWNER)
|
|
168
|
+
atomic_write_json(service.vault.processed_state_path, processed)
|
|
169
|
+
except (OSError, ValueError, RuntimeError):
|
|
170
|
+
pass # A stale local/dead owner cannot grant another request.
|
|
171
|
+
finally:
|
|
172
|
+
unregister_owner(token)
|
|
173
|
+
|
|
174
|
+
compaction["backend_calls"] = backend.calls
|
|
175
|
+
if ordinal is not None:
|
|
176
|
+
compaction["request_ordinal"] = ordinal
|
|
177
|
+
metrics = backend.metrics(compaction.get("status"))
|
|
178
|
+
if metrics:
|
|
179
|
+
compaction["model_metrics"] = metrics
|
|
180
|
+
from .process_jobs import _aggregate_model_metrics
|
|
181
|
+
result["model_metrics"] = _aggregate_model_metrics([result.get("model_metrics", {}), metrics])
|
|
182
|
+
result["compaction"] = compaction
|
|
183
|
+
result["maintenance_model_calls"] = backend.calls
|
|
184
|
+
count_key = "model_calls" if "model_calls" in result else "model_calls_this_invocation"
|
|
185
|
+
result[count_key] = result.get(count_key, 0) + backend.calls
|
|
186
|
+
if compaction.get("status") == "compacted":
|
|
187
|
+
try:
|
|
188
|
+
after = manager.maintain(_clock_now(getattr(service, "clock", None)))
|
|
189
|
+
for key in ("history_pruned", "closed_todos_retired", "provenance_rewritten"):
|
|
190
|
+
after[key] = after.get(key, 0) + retention.get(key, 0)
|
|
191
|
+
result["retention"] = after
|
|
192
|
+
except (OSError, ValueError, RuntimeError):
|
|
193
|
+
result["retention"]["maintenance_status"] = "deferred"
|
|
194
|
+
result["retention"]["code"] = "maintenance_state_unavailable"
|
|
195
|
+
return result
|
|
@@ -71,6 +71,7 @@ _MODEL_METRIC_STAGES = frozenset({
|
|
|
71
71
|
"coordination",
|
|
72
72
|
"target_reconciliation",
|
|
73
73
|
"single_pass",
|
|
74
|
+
"compact",
|
|
74
75
|
"other",
|
|
75
76
|
})
|
|
76
77
|
_MODEL_METRIC_OPERATIONS = frozenset(
|
|
@@ -415,11 +416,22 @@ def _safe_result(value: Any) -> dict[str, Any]:
|
|
|
415
416
|
result["memory_ids"] = [item[:200] for item in ids if isinstance(item, str)][:100]
|
|
416
417
|
if isinstance(value.get("pipeline"), str) and value["pipeline"] in {"legacy", "incremental"}:
|
|
417
418
|
result["pipeline"] = value["pipeline"]
|
|
418
|
-
for key in ("model_calls", "attempted_turns", "unlocated_issue_count"):
|
|
419
|
+
for key in ("model_calls", "maintenance_model_calls", "attempted_turns", "unlocated_issue_count"):
|
|
419
420
|
if type(value.get(key)) is int and value[key] >= 0:
|
|
420
421
|
result[key] = value[key]
|
|
421
422
|
if value.get("cleanup_status") == "recovery_required":
|
|
422
423
|
result["cleanup_status"] = "recovery_required"
|
|
424
|
+
for key in ("retention", "compaction"):
|
|
425
|
+
detail = value.get(key)
|
|
426
|
+
if isinstance(detail, Mapping):
|
|
427
|
+
safe = {name: detail[name][:100] for name in ("status", "maintenance_status", "code", "reason", "history_policy", "deduplication_status")
|
|
428
|
+
if isinstance(detail.get(name), str)}
|
|
429
|
+
for name in ("history_pruned", "provenance_rewritten", "closed_todos_retired", "protected_history_groups",
|
|
430
|
+
"backend_calls", "request_ordinal", "compacted", "active_tokens_before", "active_tokens_after"):
|
|
431
|
+
if type(detail.get(name)) is int and detail[name] >= 0:
|
|
432
|
+
safe[name] = detail[name]
|
|
433
|
+
if safe:
|
|
434
|
+
result[key] = safe
|
|
423
435
|
if isinstance(value.get("execution_status"), str):
|
|
424
436
|
result["execution_status"] = value["execution_status"][:80]
|
|
425
437
|
rows = value.get("results")
|
|
@@ -530,7 +542,7 @@ def _aggregate_attempt_results(attempts: list[Any]) -> dict[str, Any]:
|
|
|
530
542
|
value = attempt.get("result", {}) if isinstance(attempt, Mapping) else {}
|
|
531
543
|
if not isinstance(value, Mapping):
|
|
532
544
|
continue
|
|
533
|
-
for key in ("pipeline", "execution_status", "results", "results_truncated", "cleanup_status"):
|
|
545
|
+
for key in ("pipeline", "execution_status", "results", "results_truncated", "cleanup_status", "retention", "compaction"):
|
|
534
546
|
if key in value:
|
|
535
547
|
aggregate[key] = value[key]
|
|
536
548
|
break
|
|
@@ -542,7 +554,7 @@ def _aggregate_attempt_results(attempts: list[Any]) -> dict[str, Any]:
|
|
|
542
554
|
"deferred_inbox_turns", "unresolved_evidence_count", "unlocated_issue_count", "retryable_deferred_turns"):
|
|
543
555
|
if key in last:
|
|
544
556
|
aggregate[key] = last[key]
|
|
545
|
-
for key in ("model_calls", "attempted_turns", "unlocated_issue_count"):
|
|
557
|
+
for key in ("model_calls", "maintenance_model_calls", "attempted_turns", "unlocated_issue_count"):
|
|
546
558
|
aggregate[key] = sum(v.get(key, 0) for v in incremental if type(v.get(key, 0)) is int)
|
|
547
559
|
if "cleanup_status" in last:
|
|
548
560
|
aggregate["cleanup_status"] = last["cleanup_status"]
|
|
@@ -332,7 +332,10 @@ def process_inbox(service: Any, *, source: str | None = None, session_id: str |
|
|
|
332
332
|
result["cleanup_status"] = "recovery_required"
|
|
333
333
|
result["execution_status"] = "partial"
|
|
334
334
|
# Do not replace known per-source coverage or successful writes.
|
|
335
|
-
|
|
335
|
+
from .lifecycle_maintenance import maintain_lifecycle
|
|
336
|
+
return maintain_lifecycle(service, result, model=model, router=router,
|
|
337
|
+
run_ids=[r["run_id"] for r in rows if r.get("run_id") and r["execution_status"] == "completed"
|
|
338
|
+
and r.get("model_calls", 0) > 0])
|
|
336
339
|
|
|
337
340
|
|
|
338
341
|
def _model_metrics(rows):
|
|
@@ -128,8 +128,11 @@ def remember_text(service: Any, *, content: str | None = None, text: str | None
|
|
|
128
128
|
commit = result.get("commit") or {}
|
|
129
129
|
ids = sorted({op["memory_id"] for op in commit.get("operations", [])
|
|
130
130
|
if op.get("memory_id") and not op.get("native") and op["state"] in {"applied", "settled"}})
|
|
131
|
-
|
|
131
|
+
summary = {**result, "pipeline": "incremental", "intent_id": intent, "memory_ids": ids,
|
|
132
132
|
"memories_written": len(after - before), "metadata_merged": 0,
|
|
133
133
|
"processed_turns": int(result["execution_status"] == "completed" and not was_completed),
|
|
134
134
|
"cleaned_turns": 0,
|
|
135
135
|
"compaction": {"status": "not_run", "reason": "outside_extraction_critical_path"}}
|
|
136
|
+
from .lifecycle_maintenance import maintain_lifecycle
|
|
137
|
+
return maintain_lifecycle(service, summary, model=model, router=router,
|
|
138
|
+
run_ids=[rid] if result.get("model_calls_this_invocation", 0) > 0 else [])
|