memleaf 0.2.46__tar.gz → 0.2.48__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.46 → memleaf-0.2.48}/CHANGELOG.md +22 -0
- {memleaf-0.2.46/src/memleaf.egg-info → memleaf-0.2.48}/PKG-INFO +2 -2
- {memleaf-0.2.46 → memleaf-0.2.48}/README.en.md +1 -1
- {memleaf-0.2.46 → memleaf-0.2.48}/README.md +1 -1
- {memleaf-0.2.46 → memleaf-0.2.48}/pyproject.toml +1 -1
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/cli.py +6 -1
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/extraction_budget.py +3 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/installer.py +55 -12
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/llm/router.py +16 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/mcp_server.py +16 -4
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/model_execution.py +3 -1
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/planning_context.py +26 -9
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/process_common.py +34 -19
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/process_journal.py +58 -2
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/prompts.py +24 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/retrieval.py +22 -2
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/single_pass_memory_planner.py +5 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/single_pass_plan.py +265 -14
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/validation.py +19 -2
- {memleaf-0.2.46 → memleaf-0.2.48/src/memleaf.egg-info}/PKG-INFO +2 -2
- {memleaf-0.2.46 → memleaf-0.2.48}/LICENSE +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/MANIFEST.in +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/docs/capture-budget-design.md +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/docs/config-migrations.md +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/docs/core-refactor.md +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/docs/evidence-retention.md +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/docs/extraction-latency.md +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/docs/gate-evidence-boundary.md +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/docs/general-processing.md +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/docs/hermes-mcp-runtime.md +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/docs/processing-quality-acceptance.md +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/docs/v0.2.26-processing-status.md +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/examples/README.md +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/examples/basic_usage.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/install.ps1 +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/install.sh +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/setup.cfg +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/admission.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/batch_review.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/config.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/create_coordinator.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/evidence_budget.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/evidence_policy.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/evidence_structure.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/evidence_syntax.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/extraction_capability.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/extraction_work_state.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/hermes_provider/_provider.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/hermes_provider/_shared.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/hermes_runtime.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/index.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/inspection.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/llm/thinking.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/memory_commit.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/memory_planner.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/model_capabilities.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/models.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/native_registration.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/parallel_model.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/process_jobs.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/process_owner.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/processing.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/provenance.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/recording_policy.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/retention.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/service.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/source_policy.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/state_layout.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/summary_batch.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/target_reconciliation.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/turn_audit.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/turn_plan.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/update_coordinator.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/update_review.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf.egg-info/SOURCES.txt +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.46 → memleaf-0.2.48}/src/memleaf.egg-info/top_level.txt +0 -0
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.48 — 2026-09-13
|
|
6
|
+
|
|
7
|
+
- Restore same-target reconciliation in the B3 single-pass path. A real turn was discarded with `B3 target referenced more than once` whenever the model admitted two changes for one existing memory. One memory can only receive one terminal disposition per turn, because the writer archives the previous version and overwrites the same file, so the colliding items are now reconciled with one bounded call into a single current state and an unresolvable group is deferred as `target_ambiguous` instead of guessed. This is the capability the legacy staged path already had; it is not a new retry class, and the compact contract now tells the model to merge such changes into one UPDATE in the first place. The call is charged against the same two-request turn budget as the primary extraction and its structural repair, so a collision can never lengthen a turn: when a repair has already spent the budget, the group defers and every other item still commits. The strict "a target may be used once" rejection remains in the parser for direct callers.
|
|
8
|
+
- Give the B3 planner the temporal anchor it was always assumed to have. The prompt contract promised that "an admitted visible-message timestamp may anchor a supported relative date" and Core grounded `due_date` against exactly that timestamp, but the evidence projection never showed it to the model. A yearless deadline such as "9 月 30 号" was therefore unanswerable: the model guessed a year, and the grounded-date gate correctly rejected the entire turn. Each projected evidence unit now carries its own event timestamp, a `DATES` section states that the anchor resolves only dates the unit's own text expresses, and a timestamp's own date is never content.
|
|
9
|
+
- Stop treating a reworded yearless date as an invented one. Grounding compared raw surface text, so a faithful restatement of a source date that only changed its suffix, such as 号 -> 日, was rejected as ungrounded and cost the whole turn. A yearless date is now recognised by its month and day, which is the property the guard actually protects: no year is ever inferred, and a different month, day or full date is still refused.
|
|
10
|
+
- State that `status`, `completed_at` and `due_date` are todo-only in the compact contract. The validator has always rejected a `status` on any other type, but the model was never told, so a correct-looking extraction failed with `invalid todo status`; the contract now also states that a todo UPDATE restates its status and that `completed` and `completed_at` require each other.
|
|
11
|
+
- Resolve one bookkeeping contradiction instead of discarding the turn. A unit listed both as an item's evidence and as `no_memory` is self-contradictory, and the resolution is forced: an item already bound the unit, so the unit is not a no-memory unit. The redundant row is dropped, counted as `b3_normalization_count`, and no memory content, scope or evidence binding changes. Genuine omissions still fail closed as incomplete evidence coverage.
|
|
12
|
+
- Stop the single-pass budget wrapper from erasing protocol capability. `SinglePassBudgetBackend` delegates by attribute name, and `ModelRouter` had gained `single_pass_safe` without `single_pass_protocol`, so a wrapped route that does speak `b3-single-pass-v1` read as incapable and every automatic turn failed in 0.000s with zero model calls. The router now answers both properties from the same resolved route.
|
|
13
|
+
- Keep a clipped native memory from making the lookup look incomplete. Native sources are comparison context and can never be an UPDATE or NO_CHANGE target, but a native body shortened for the prompt budget was recorded as a dropped item and marked `lookup_complete=false`, which blocked every terminal decision in the turn. Only an item that is actually discarded for the budget can affect completeness now, and the prompt-length budget itself is unchanged.
|
|
14
|
+
|
|
15
|
+
Verification for this release combined focused synthetic tests with one real-model replay. 84 tests pass with no model call, covering same-target reconciliation (merge, no-change, deferral, re-validation, evidence union, and a route-level assertion that a third provider request is never issued), the date anchor projection, yearless-date identity, the todo-field contract, redundant-row normalization, the wrapper capability properties and native clipping completeness. The real replay ran the previously failing production session against `deepseek-flash`: before these fixes every attempt failed in the planner, and after them five consecutive preview runs completed with `execution_status: ok` (four `coverage_status: complete`, one partial with a single deferred candidate), and the committed run wrote five memories into the live Vault with a todo carrying `status: active` and the correct grounded `due_date`. A single-candidate semantic rejection from the model still fails its turn closed; restoring per-candidate deferral or semantic retries for that case would be a deliberate design change and was not made here.
|
|
16
|
+
|
|
17
|
+
## 0.2.47 — 2026-09-12
|
|
18
|
+
|
|
19
|
+
- Fix two satisfiability deadlocks in the public candidate filter that made every compound query carrying a punctuation-rich identifier return no_match. Word splitting turns `vX.Y.Z` into `vX` / `Y` / `Z`; those one-character fragments were collected as *required* ASCII components while the matching rule refused to treat anything shorter than three characters as a strong match, so the requirement could never be met. A fixed three-character bar had the same effect on a two-character CJK component whenever an ASCII component was present. A component that cannot reach its own bar no longer creates a requirement, and a CJK component's bar never exceeds its own length. Identifier strictness is unchanged: a short fragment still cannot stand in for a longer topic.
|
|
20
|
+
- State the `sources` entry shape in the summarize contract instead of leaving the model to infer it, and generate that sentence from the validator's own field list so the instruction and the enforced shape cannot drift apart. Production `remember` calls writing a todo failed four times with `schema_violation` / `source_shape`: the prompt required `sources` and mentioned event keys but never described the object the validator accepts. A bare admitted event key is now additionally normalized to `{"event_key": ...}` losslessly, and `source_shape` gained a targeted repair instruction so the bounded retry corrects the shape instead of re-deriving the whole summary.
|
|
21
|
+
- Make failed automatic extraction visible. `_state/processed.json` already recorded which sessions failed, at which stage and with which code, but nothing surfaced it: a Vault whose every turn failed extraction still looked idle from outside. `process_status` now accepts an omitted `job_id` and reports Vault-level extraction health — failed and running sessions, failure code and stage, and retryable turn counts. The report is strictly read-only and opens the Vault without creating it.
|
|
22
|
+
- Report an incompatible model route instead of a misleading `ready`. The installer answered `processing_status: ready` for any configured route, including one whose adapter cannot speak the single-pass protocol. `install --host hermes` now distinguishes `ready`, `model_route_required` and `model_route_incompatible`. Capability is resolved through the same `ModelRouter` the extraction path uses, so the adapter decides and no rule is restated; no request is sent and no service is probed.
|
|
23
|
+
- Document that the Claude and Gemini adapters implement no verified structured-output boundary, so the single-pass protocol stays fail-closed for them by design: a Vault routed through `llm.protocol: claude` cannot run automatic extraction, while capture, retrieval and explicit `remember`/`forget` are unaffected. Automatic extraction therefore requires an OpenAI-compatible route. No tool was added, removed or renamed; the public MCP surface remains the same 13 tools.
|
|
24
|
+
|
|
25
|
+
Verification for this release used synthetic memories, temporary Vaults, synthetic routes and no model call: 51 focused tests cover the retrieval deadlocks and the strictness they preserve, the `sources` contract and its consistency with the validator, the targeted repair selection, the route capability decision across Claude, OpenAI/DeepSeek and unknown OpenAI-compatible routes, and the read-only processing-health report. Real-model first-pass success rate, repair success rate, reasoning reduction and latency improvement remain unmeasured, and the two reported production sessions were not replayed.
|
|
26
|
+
|
|
5
27
|
## 0.2.46 — 2026-09-12
|
|
6
28
|
|
|
7
29
|
- Restore the host protocol literals that v0.2.45 dropped from the API-route protocol table. `anthropic_messages`, `chat_completions`, `chat`, `google` and `generate_content` resolve again. v0.2.45 replaced the previous tolerant mapping with an exact table that omitted every canonical Hermes `api_mode` value, so a Hermes custom provider that used to resolve to a protocol began resolving to none and was reported as `missing provider, base URL, protocol, or model`. Substring and model-name matching stay removed: only exact host-contract literals are accepted, and an unknown protocol still fails closed.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.48
|
|
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.48。**
|
|
27
27
|
> 记忆只从用户与 Agent 的可见对话提炼。Agent 已在回复中整理的事实、项目进展和明确待办可作为来源;邮件、附件、网页和终端等工具原文不进入记忆提炼。模型负责保留原话中的不确定性,现有记忆仅用于比较、去重和更新。Markdown 仍是唯一事实源。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
@@ -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.48.**
|
|
8
8
|
> Automatic extraction now uses only the current turn's visible user input and final assistant reply. Raw tool output, attachments, web/file/terminal payloads and legacy tool-evidence bodies are not new source evidence; existing or retrieved memory remains comparison context rather than source authority. Background processing is persisted as a local job and can be checked through the read-only `process_status` MCP tool; failed work remains retryable and fail closed. The release also tightens source/date grounding, target reconciliation, duplicate/no-op handling and semantic review before writes. Markdown remains the sole source of truth with no SQLite runtime dependency. Acceptance covers deterministic regression suites and synthetic inputs; it does not claim real-mail or customer-business acceptance.
|
|
9
9
|
> **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
|
|
10
10
|
|
|
@@ -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.48。**
|
|
8
8
|
> 记忆只从用户与 Agent 的可见对话提炼。Agent 已在回复中整理的事实、项目进展和明确待办可作为来源;邮件、附件、网页和终端等工具原文不进入记忆提炼。模型负责保留原话中的不确定性,现有记忆仅用于比较、去重和更新。Markdown 仍是唯一事实源。
|
|
9
9
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
10
10
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Local-first Markdown memory core for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
3
|
+
__version__ = "0.2.48"
|
|
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
|
|
@@ -312,12 +312,17 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
312
312
|
print("Restart Hermes to use memleaf.")
|
|
313
313
|
elif output.get("user_action_required"):
|
|
314
314
|
print(f"Codex action required: {output.get('user_action')}")
|
|
315
|
-
|
|
315
|
+
processing = output.get("processing_status")
|
|
316
|
+
if processing == "model_route_required":
|
|
316
317
|
print(
|
|
317
318
|
"Automatic memory extraction needs a memleaf model route before it can run. "
|
|
318
319
|
"Set llm.provider/family/protocol/base_url/model/key in the Vault config.yaml, "
|
|
319
320
|
"or rerun `memleaf init` from an interactive terminal."
|
|
320
321
|
)
|
|
322
|
+
elif processing == "model_route_incompatible":
|
|
323
|
+
print("Automatic memory extraction cannot run on this route.")
|
|
324
|
+
if output.get("user_action"):
|
|
325
|
+
print(f" {output['user_action']}")
|
|
321
326
|
else:
|
|
322
327
|
_print_install_failure(output, host=args.host)
|
|
323
328
|
return 0 if output.get("status") in {"configured", "already_configured"} else 2
|
|
@@ -24,6 +24,9 @@ class SinglePassBudgetBackend:
|
|
|
24
24
|
hidden host-to-API fallback. The optional durable reservation preserves
|
|
25
25
|
consumed attempts across worker restarts. Neither the first request nor
|
|
26
26
|
its repair overrides the transport's configured ``llm.request_timeout``.
|
|
27
|
+
The second request is spent on whichever bounded follow-up the planner
|
|
28
|
+
actually needs -- a structural repair or a same-target reconciliation --
|
|
29
|
+
so a collision between two admitted items can never extend the turn.
|
|
27
30
|
"""
|
|
28
31
|
|
|
29
32
|
def __init__(
|
|
@@ -754,7 +754,46 @@ def _failure_result(
|
|
|
754
754
|
return result
|
|
755
755
|
|
|
756
756
|
|
|
757
|
-
|
|
757
|
+
_MODEL_ROUTE_REQUIRED_ACTION = (
|
|
758
|
+
"Configure an independent memleaf Model Route for this Vault before relying on "
|
|
759
|
+
"automatic memory extraction. Host model/provider settings are intentionally "
|
|
760
|
+
"not used or modified."
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
_MODEL_ROUTE_INCOMPATIBLE_ACTION = (
|
|
764
|
+
"This Vault's model route cannot run automatic memory extraction. The Claude and "
|
|
765
|
+
"Gemini adapters implement no verified structured-output boundary, so the "
|
|
766
|
+
"single-pass protocol stays fail-closed for them; v0.2.x automatic extraction "
|
|
767
|
+
"requires an OpenAI-compatible route (set llm.protocol to openai). Capture, "
|
|
768
|
+
"retrieval, and explicit remember/forget keep working unchanged."
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _route_can_extract(vault: Any) -> bool:
|
|
773
|
+
"""Ask the production router whether this Vault's route can speak B3.
|
|
774
|
+
|
|
775
|
+
This deliberately reuses the same decision the extraction path makes instead
|
|
776
|
+
of restating the capability rules: the installer must never advertise
|
|
777
|
+
``ready`` for a route whose adapter leaves ``single_pass_protocol`` False.
|
|
778
|
+
No request is sent and no service is probed.
|
|
779
|
+
"""
|
|
780
|
+
|
|
781
|
+
try:
|
|
782
|
+
from .extraction_capability import supports_single_pass_protocol
|
|
783
|
+
from .llm import ModelRouter
|
|
784
|
+
|
|
785
|
+
router = ModelRouter.from_config(vault.config())
|
|
786
|
+
except Exception:
|
|
787
|
+
return False
|
|
788
|
+
try:
|
|
789
|
+
return bool(supports_single_pass_protocol(router))
|
|
790
|
+
except Exception:
|
|
791
|
+
return False
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _model_route_outcome(
|
|
795
|
+
model: Any, *, can_extract: bool | None = None
|
|
796
|
+
) -> tuple[bool, str, str | None]:
|
|
758
797
|
"""Decide how one host installation reports its model route.
|
|
759
798
|
|
|
760
799
|
A missing model route must never block installing the host integration.
|
|
@@ -763,17 +802,17 @@ def _model_route_outcome(model: Any) -> tuple[bool, str, str | None]:
|
|
|
763
802
|
route is configured. This is the documented Codex policy, and applying it
|
|
764
803
|
to Hermes keeps the two hosts consistent instead of aborting the install
|
|
765
804
|
and leaving core and provider versions out of step.
|
|
805
|
+
|
|
806
|
+
``can_extract`` carries the resolved capability of a *configured* route. A
|
|
807
|
+
route can be present and still be unable to run automatic extraction, so
|
|
808
|
+
reporting plain ``ready`` for it would be a false promise.
|
|
766
809
|
"""
|
|
767
810
|
|
|
768
|
-
if isinstance(model, Mapping) and model.get("status") in {"configured", "already_configured"}:
|
|
769
|
-
return
|
|
770
|
-
|
|
771
|
-
False,
|
|
772
|
-
|
|
773
|
-
"Configure an independent memleaf Model Route for this Vault before relying on "
|
|
774
|
-
"automatic memory extraction. Host model/provider settings are intentionally "
|
|
775
|
-
"not used or modified.",
|
|
776
|
-
)
|
|
811
|
+
if not (isinstance(model, Mapping) and model.get("status") in {"configured", "already_configured"}):
|
|
812
|
+
return False, "model_route_required", _MODEL_ROUTE_REQUIRED_ACTION
|
|
813
|
+
if can_extract is False:
|
|
814
|
+
return False, "model_route_incompatible", _MODEL_ROUTE_INCOMPATIBLE_ACTION
|
|
815
|
+
return True, "ready", None
|
|
777
816
|
|
|
778
817
|
|
|
779
818
|
def install_hermes(
|
|
@@ -897,8 +936,12 @@ def install_hermes(
|
|
|
897
936
|
)
|
|
898
937
|
# A missing route degrades the installation instead of aborting it: the
|
|
899
938
|
# Hermes provider and MCP surface do not need a model route, and stopping
|
|
900
|
-
# here used to leave core and provider versions out of step.
|
|
901
|
-
|
|
939
|
+
# here used to leave core and provider versions out of step. A *present*
|
|
940
|
+
# route that cannot speak B3 is reported separately, because advertising
|
|
941
|
+
# ``ready`` for it would promise extraction that can never run.
|
|
942
|
+
model_ready, processing_status, model_action = _model_route_outcome(
|
|
943
|
+
model, can_extract=_route_can_extract(vault)
|
|
944
|
+
)
|
|
902
945
|
# ``model_ready`` is reported through ``processing_status`` below; the host
|
|
903
946
|
# integration itself is configured either way.
|
|
904
947
|
|
|
@@ -84,6 +84,22 @@ class ModelRouter:
|
|
|
84
84
|
return getattr(self.api, "single_pass_safe", False) is True
|
|
85
85
|
return False
|
|
86
86
|
|
|
87
|
+
@property
|
|
88
|
+
def single_pass_protocol(self) -> bool:
|
|
89
|
+
"""Expose B3 protocol capability symmetrically with ``single_pass_safe``.
|
|
90
|
+
|
|
91
|
+
Request-budget wrappers forward capability by attribute name. A router
|
|
92
|
+
that exposed only ``single_pass_safe`` therefore read as protocol
|
|
93
|
+
incapable the moment it was wrapped, which made automatic extraction
|
|
94
|
+
fail on exactly the routes that were safe enough to be wrapped.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
if self.mode == "api":
|
|
98
|
+
return getattr(self.api, "single_pass_protocol", False) is True
|
|
99
|
+
if self.mode == "auto" and self.host is None:
|
|
100
|
+
return getattr(self.api, "single_pass_protocol", False) is True
|
|
101
|
+
return False
|
|
102
|
+
|
|
87
103
|
@staticmethod
|
|
88
104
|
def _coerce_host(value: Any) -> Optional[ModelBackend]:
|
|
89
105
|
if value is None:
|
|
@@ -286,8 +286,14 @@ _TOOLS: tuple[dict[str, Any], ...] = (
|
|
|
286
286
|
},
|
|
287
287
|
{
|
|
288
288
|
"name": "process_status",
|
|
289
|
-
"description":
|
|
290
|
-
|
|
289
|
+
"description": (
|
|
290
|
+
"Read the status of an accepted background process job, or, when job_id is "
|
|
291
|
+
"omitted, the Vault's automatic-extraction health: failed and running "
|
|
292
|
+
"sessions, the failure code and stage, and whether the work is still "
|
|
293
|
+
"retryable. Failed extraction is never silent; check this when expected "
|
|
294
|
+
"memories do not appear."
|
|
295
|
+
),
|
|
296
|
+
"inputSchema": _object_schema({"job_id": {"type": "string"}}),
|
|
291
297
|
},
|
|
292
298
|
{
|
|
293
299
|
"name": "remember",
|
|
@@ -930,9 +936,15 @@ def _invoke_tool(
|
|
|
930
936
|
else:
|
|
931
937
|
value = service.process(**args)
|
|
932
938
|
elif name == "process_status":
|
|
933
|
-
|
|
939
|
+
job_id = args.get("job_id")
|
|
940
|
+
if isinstance(job_id, str) and job_id:
|
|
941
|
+
from .process_jobs import status
|
|
942
|
+
|
|
943
|
+
value = status(service.vault.root, job_id=job_id)
|
|
944
|
+
else:
|
|
945
|
+
from .process_journal import processing_health
|
|
934
946
|
|
|
935
|
-
|
|
947
|
+
value = processing_health(service.vault.root)
|
|
936
948
|
elif name == "remember":
|
|
937
949
|
value = service.remember(**args)
|
|
938
950
|
elif name == "forget_memory":
|
|
@@ -9,7 +9,7 @@ from typing import Any, Callable, Mapping, Optional
|
|
|
9
9
|
from .config import DEFAULT_MODEL_CONCURRENCY, MAX_MODEL_CONCURRENCY, MIN_MODEL_CONCURRENCY
|
|
10
10
|
from .llm import MODEL_VALIDATION_REASONS, CallableBackend, ModelError, ModelUnavailable, ModelRouter
|
|
11
11
|
from .models import utc_now
|
|
12
|
-
from .prompts import COVERAGE_ALREADY_COMPLETED_CORRECTION, COVERAGE_CANDIDATE_CORRECTION, COVERAGE_CORRECTION, COVERAGE_SHAPE_CORRECTION, DUPLICATE_TARGET_CORRECTION, EVIDENCE_EVENT_MAPPING_CORRECTION, EVIDENCE_SPAN_CORRECTION, GATE_STRUCTURE_REPAIR_SYSTEM, GATE_TYPE_CORRECTION, JSON_CORRECTION, MIXED_FUTURE_USE_CORRECTION, MIXED_PROJECT_SCOPES_CORRECTION, RELATIVE_TIME_CORRECTION, SCOPE_GROUNDING_CORRECTION, SUMMARY_SCOPE_CORRECTION, SUMMARY_TARGET_CORRECTION, SUMMARY_TYPE_CORRECTION, TARGET_RELEVANCE_CORRECTION, UPDATE_TARGET_TYPE_CORRECTION, gate_structure_repair_prompt
|
|
12
|
+
from .prompts import COVERAGE_ALREADY_COMPLETED_CORRECTION, COVERAGE_CANDIDATE_CORRECTION, COVERAGE_CORRECTION, COVERAGE_SHAPE_CORRECTION, DUPLICATE_TARGET_CORRECTION, EVIDENCE_EVENT_MAPPING_CORRECTION, EVIDENCE_SPAN_CORRECTION, GATE_STRUCTURE_REPAIR_SYSTEM, GATE_TYPE_CORRECTION, JSON_CORRECTION, MIXED_FUTURE_USE_CORRECTION, MIXED_PROJECT_SCOPES_CORRECTION, RELATIVE_TIME_CORRECTION, SCOPE_GROUNDING_CORRECTION, SOURCES_SHAPE_CORRECTION, SUMMARY_SCOPE_CORRECTION, SUMMARY_TARGET_CORRECTION, SUMMARY_TYPE_CORRECTION, TARGET_RELEVANCE_CORRECTION, UPDATE_TARGET_TYPE_CORRECTION, gate_structure_repair_prompt
|
|
13
13
|
from .validation import MODEL_VALIDATION_DETAILS, ModelOutputError, parse_strict_json
|
|
14
14
|
from .process_common import _DIAGNOSTIC_FILENAME, _DIAGNOSTIC_MAX_BYTES, _failure_metadata, _model_output_statistics, _safe_evidence_check, _safe_evidence_diagnostics
|
|
15
15
|
|
|
@@ -686,6 +686,8 @@ class ModelExecutor:
|
|
|
686
686
|
return COVERAGE_ALREADY_COMPLETED_CORRECTION
|
|
687
687
|
context = ModelExecutor._evidence_correction_context(error)
|
|
688
688
|
return COVERAGE_CORRECTION if context is None else COVERAGE_CORRECTION + "\n" + context
|
|
689
|
+
if stage == "summarize" and hint == "source_shape":
|
|
690
|
+
return SOURCES_SHAPE_CORRECTION
|
|
689
691
|
if stage == "summarize" and hint == "scope_drift":
|
|
690
692
|
return SUMMARY_SCOPE_CORRECTION
|
|
691
693
|
if hint in {"relative_time", "due_date_not_grounded"}:
|
|
@@ -315,17 +315,36 @@ class PlanningContext:
|
|
|
315
315
|
selected: list[dict[str, Any]] = []
|
|
316
316
|
used = 2
|
|
317
317
|
complete = True
|
|
318
|
-
|
|
319
|
-
|
|
318
|
+
|
|
319
|
+
def _drop(item: Mapping[str, Any]) -> None:
|
|
320
|
+
"""Record one related record that could not be projected verbatim.
|
|
321
|
+
|
|
322
|
+
``complete`` answers one question only: can Core still prove that no
|
|
323
|
+
**local** record was withheld, so that an absent UPDATE target and a
|
|
324
|
+
create-safe lookup remain provable? Native sources are read-only
|
|
325
|
+
host files that are never an UPDATE or NO_CHANGE target and are only
|
|
326
|
+
ever referenced through ``shadow_native_ids``, so clipping one must
|
|
327
|
+
not make the whole lookup unprovable. Treating a long host memory
|
|
328
|
+
file as a failed proof would block every automatic extraction with no
|
|
329
|
+
way for the user to recover except shrinking a file they own.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
nonlocal complete
|
|
333
|
+
if item.get("native") is not True:
|
|
320
334
|
complete = False
|
|
335
|
+
|
|
336
|
+
for index, value in enumerate(values):
|
|
337
|
+
if len(selected) >= _RELATED_MAX_ITEMS:
|
|
338
|
+
for item in values[index:]:
|
|
339
|
+
_drop(item)
|
|
321
340
|
break
|
|
322
341
|
body = value.get("body")
|
|
323
342
|
if isinstance(body, str) and len(body) > _RELATED_MAX_BODY_CHARS:
|
|
324
343
|
value["body"] = body[: _RELATED_MAX_BODY_CHARS - 1].rstrip() + "…"
|
|
325
|
-
|
|
344
|
+
_drop(value)
|
|
326
345
|
size = cls._related_payload_size(value)
|
|
327
346
|
if size < 0:
|
|
328
|
-
|
|
347
|
+
_drop(value)
|
|
329
348
|
continue
|
|
330
349
|
additional = size + (1 if selected else 0)
|
|
331
350
|
if used + additional > _RELATED_MAX_CHARS:
|
|
@@ -334,7 +353,7 @@ class PlanningContext:
|
|
|
334
353
|
isinstance(memory_id, str)
|
|
335
354
|
and memory_id.casefold() in priority
|
|
336
355
|
):
|
|
337
|
-
|
|
356
|
+
_drop(value)
|
|
338
357
|
continue
|
|
339
358
|
minimal = {
|
|
340
359
|
key: value[key]
|
|
@@ -343,15 +362,13 @@ class PlanningContext:
|
|
|
343
362
|
}
|
|
344
363
|
size = cls._related_payload_size(minimal)
|
|
345
364
|
if size < 0 or used + size + (1 if selected else 0) > _RELATED_MAX_CHARS:
|
|
346
|
-
|
|
365
|
+
_drop(value)
|
|
347
366
|
continue
|
|
348
367
|
value = minimal
|
|
349
368
|
additional = size + (1 if selected else 0)
|
|
350
|
-
|
|
369
|
+
_drop(value)
|
|
351
370
|
selected.append(value)
|
|
352
371
|
used += additional
|
|
353
|
-
if len(selected) != len(values):
|
|
354
|
-
complete = False
|
|
355
372
|
return selected, complete
|
|
356
373
|
|
|
357
374
|
@classmethod
|
|
@@ -683,16 +683,19 @@ def _add_external_due_dates(result: set[str], content: Any) -> None:
|
|
|
683
683
|
result.add(parsed.isoformat())
|
|
684
684
|
|
|
685
685
|
|
|
686
|
-
def _summary_calendar_tokens(content: Any) -> tuple[tuple[str, str | None], ...]:
|
|
687
|
-
"""Return source-neutral calendar literals
|
|
686
|
+
def _summary_calendar_tokens(content: Any) -> tuple[tuple[str, str | None, str | None], ...]:
|
|
687
|
+
"""Return source-neutral calendar literals, canonical dates and month/day.
|
|
688
688
|
|
|
689
689
|
A ``None`` canonical value is intentional for yearless or invalid text;
|
|
690
|
-
callers must not fill its year from a retrieval timestamp.
|
|
690
|
+
callers must not fill its year from a retrieval timestamp. The third item
|
|
691
|
+
is the yearless ``MM-DD`` identity of a token that carries no year, so a
|
|
692
|
+
faithful restatement of the same month and day can be recognized without
|
|
693
|
+
ever granting that token a year.
|
|
691
694
|
"""
|
|
692
695
|
|
|
693
696
|
if not isinstance(content, str):
|
|
694
697
|
return ()
|
|
695
|
-
tokens: list[tuple[str, str | None]] = []
|
|
698
|
+
tokens: list[tuple[str, str | None, str | None]] = []
|
|
696
699
|
seen: set[str] = set()
|
|
697
700
|
for raw in _ISO_CALENDAR_DATE_RE.findall(content):
|
|
698
701
|
if raw in seen:
|
|
@@ -702,27 +705,25 @@ def _summary_calendar_tokens(content: Any) -> tuple[tuple[str, str | None], ...]
|
|
|
702
705
|
canonical = datetime.strptime(raw, "%Y-%m-%d").date().isoformat()
|
|
703
706
|
except ValueError:
|
|
704
707
|
canonical = None
|
|
705
|
-
tokens.append((raw, canonical))
|
|
708
|
+
tokens.append((raw, canonical, None))
|
|
706
709
|
for match in _SUMMARY_CHINESE_CALENDAR_DATE_RE.finditer(content):
|
|
707
710
|
raw = match.group("raw")
|
|
708
711
|
if raw in seen:
|
|
709
712
|
continue
|
|
710
713
|
seen.add(raw)
|
|
711
714
|
year = match.group("year")
|
|
715
|
+
month = int(match.group("month"))
|
|
716
|
+
day = int(match.group("day"))
|
|
712
717
|
try:
|
|
713
718
|
canonical = (
|
|
714
|
-
datetime(
|
|
715
|
-
int(year),
|
|
716
|
-
int(match.group("month")),
|
|
717
|
-
int(match.group("day")),
|
|
718
|
-
tzinfo=timezone.utc,
|
|
719
|
-
).date().isoformat()
|
|
719
|
+
datetime(year and int(year), month, day, tzinfo=timezone.utc).date().isoformat()
|
|
720
720
|
if year
|
|
721
721
|
else None
|
|
722
722
|
)
|
|
723
723
|
except ValueError:
|
|
724
724
|
canonical = None
|
|
725
|
-
|
|
725
|
+
monthday = f"{month:02d}-{day:02d}" if 1 <= month <= 12 and 1 <= day <= 31 else None
|
|
726
|
+
tokens.append((raw, canonical, monthday))
|
|
726
727
|
return tuple(tokens)
|
|
727
728
|
|
|
728
729
|
|
|
@@ -738,7 +739,9 @@ def _summary_date_grounding_violations(
|
|
|
738
739
|
``grounded_dates`` is the canonical date set from the current admitted
|
|
739
740
|
evidence. ``source_texts`` must contain only the current candidate's
|
|
740
741
|
admitted evidence projection text. A source date without a year may be
|
|
741
|
-
retained verbatim, but it never authorizes a year to be inferred
|
|
742
|
+
retained verbatim, but it never authorizes a year to be inferred; the same
|
|
743
|
+
month and day restated in another surface form is still that source date,
|
|
744
|
+
because the guard forbids inventing a year, not rewording a suffix.
|
|
742
745
|
``preserved_texts`` must contain only the selected update target's existing
|
|
743
746
|
title/body/due-date text. Existing target literals may be retained
|
|
744
747
|
verbatim; dates from unrelated memories are never accepted.
|
|
@@ -761,33 +764,45 @@ def _summary_date_grounding_violations(
|
|
|
761
764
|
allowed_dates.add(value)
|
|
762
765
|
|
|
763
766
|
source_tokens: set[str] = set()
|
|
767
|
+
source_monthdays: set[str] = set()
|
|
764
768
|
source_values = (source_texts,) if isinstance(source_texts, str) else source_texts
|
|
765
769
|
for text in source_values:
|
|
766
|
-
for raw, canonical in _summary_calendar_tokens(text):
|
|
770
|
+
for raw, canonical, monthday in _summary_calendar_tokens(text):
|
|
767
771
|
source_tokens.add(raw)
|
|
772
|
+
if monthday is not None and canonical is None:
|
|
773
|
+
source_monthdays.add(monthday)
|
|
768
774
|
if canonical is not None:
|
|
769
775
|
allowed_dates.add(canonical)
|
|
770
776
|
|
|
771
777
|
preserved_tokens: set[str] = set()
|
|
778
|
+
preserved_monthdays: set[str] = set()
|
|
772
779
|
preserved_values = (preserved_texts,) if isinstance(preserved_texts, str) else preserved_texts
|
|
773
780
|
for text in preserved_values:
|
|
774
|
-
for raw, canonical in _summary_calendar_tokens(text):
|
|
781
|
+
for raw, canonical, monthday in _summary_calendar_tokens(text):
|
|
775
782
|
preserved_tokens.add(raw)
|
|
783
|
+
if monthday is not None and canonical is None:
|
|
784
|
+
preserved_monthdays.add(monthday)
|
|
776
785
|
if canonical is not None:
|
|
777
786
|
allowed_dates.add(canonical)
|
|
778
787
|
|
|
788
|
+
yearless_monthdays = source_monthdays | preserved_monthdays
|
|
779
789
|
violations: list[str] = []
|
|
780
790
|
seen: set[str] = set()
|
|
781
791
|
for field in ("title", "body"):
|
|
782
|
-
for raw, canonical in _summary_calendar_tokens(summary.get(field)):
|
|
792
|
+
for raw, canonical, monthday in _summary_calendar_tokens(summary.get(field)):
|
|
783
793
|
if raw in source_tokens or raw in preserved_tokens:
|
|
784
794
|
continue
|
|
785
795
|
value = canonical or raw
|
|
786
796
|
if value in seen:
|
|
787
797
|
continue
|
|
788
|
-
if canonical is None
|
|
789
|
-
|
|
790
|
-
|
|
798
|
+
if canonical is not None and canonical in allowed_dates:
|
|
799
|
+
continue
|
|
800
|
+
if canonical is None and monthday is not None and monthday in yearless_monthdays:
|
|
801
|
+
# Same month and day as a yearless source date: the wording
|
|
802
|
+
# changed, the date did not, and no year was invented.
|
|
803
|
+
continue
|
|
804
|
+
seen.add(value)
|
|
805
|
+
violations.append(value)
|
|
791
806
|
return tuple(violations)
|
|
792
807
|
|
|
793
808
|
|
|
@@ -16,14 +16,70 @@ from .inbox import InboxEvent, InboxTurn, parse_inbox
|
|
|
16
16
|
from .locking import atomic_write_json, atomic_write_text
|
|
17
17
|
from .turn_plan import turn_identity_key
|
|
18
18
|
from .redaction import redact_text
|
|
19
|
-
from .vault import safe_component
|
|
19
|
+
from .vault import Vault, safe_component
|
|
20
20
|
from .process_common import ProcessingError, _FAILED_STATUS, _LEGACY_PROCESSING_GRACE_SECONDS, _MAX_SESSION_LINEAGE_DEPTH, _PROCESSING_LEASE_SECONDS, _PROCESSING_STATUS, _Snapshot, _as_int, _event_payload, _failure_metadata, _now_value, _parse_time, _read_processed, _safe_evidence_check, _safe_evidence_diagnostics, _safe_scope_background, _session_key
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def processing_health(vault_path: Path | str) -> dict[str, Any]:
|
|
24
|
+
"""Report whether automatic extraction is keeping up, and why not.
|
|
25
|
+
|
|
26
|
+
Strictly read-only: the Vault is opened without ``create`` and nothing is
|
|
27
|
+
written. Failures were previously visible only inside
|
|
28
|
+
``_state/processed.json``, so a Vault whose every turn failed extraction
|
|
29
|
+
still looked idle from the outside and the user had no way to notice.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
vault = Vault(vault_path, create=False)
|
|
33
|
+
processed = _read_processed(vault.processed_state_path)
|
|
34
|
+
sessions = processed.get("sessions")
|
|
35
|
+
failures: list[dict[str, Any]] = []
|
|
36
|
+
running = 0
|
|
37
|
+
idle = 0
|
|
38
|
+
failed_turns = 0
|
|
39
|
+
last_processed: str | None = None
|
|
40
|
+
if isinstance(sessions, Mapping):
|
|
41
|
+
for key, state in sessions.items():
|
|
42
|
+
if not isinstance(state, Mapping):
|
|
43
|
+
continue
|
|
44
|
+
marker = state.get("processing")
|
|
45
|
+
marker = marker if isinstance(marker, Mapping) else {}
|
|
46
|
+
observed = marker.get("last_processed_at")
|
|
47
|
+
if isinstance(observed, str) and (last_processed is None or observed > last_processed):
|
|
48
|
+
last_processed = observed
|
|
49
|
+
status = marker.get("status")
|
|
50
|
+
if status == _FAILED_STATUS:
|
|
51
|
+
turn_indices = marker.get("turn_indices")
|
|
52
|
+
count = len(turn_indices) if isinstance(turn_indices, list) else 0
|
|
53
|
+
failed_turns += count
|
|
54
|
+
failures.append(
|
|
55
|
+
{
|
|
56
|
+
"session_id": key,
|
|
57
|
+
"failure_code": marker.get("failure_code"),
|
|
58
|
+
"failure_stage": marker.get("failure_stage"),
|
|
59
|
+
"attempt_count": marker.get("attempt_count"),
|
|
60
|
+
"turn_count": count,
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
elif status == _PROCESSING_STATUS:
|
|
64
|
+
running += 1
|
|
65
|
+
else:
|
|
66
|
+
idle += 1
|
|
67
|
+
failures.sort(key=lambda item: str(item.get("session_id")))
|
|
68
|
+
return {
|
|
69
|
+
"status": "failed" if failures else ("running" if running else "idle"),
|
|
70
|
+
"failed_sessions": len(failures),
|
|
71
|
+
"failed_turns": failed_turns,
|
|
72
|
+
"running_sessions": running,
|
|
73
|
+
"idle_sessions": idle,
|
|
74
|
+
"last_processed_at": last_processed,
|
|
75
|
+
"retryable": bool(failures or running),
|
|
76
|
+
"failures": failures,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
23
80
|
class ProcessJournal:
|
|
24
81
|
def __init__(self, service: Any):
|
|
25
82
|
self.service = service
|
|
26
|
-
|
|
27
83
|
def _write_processed_unlocked(self, processed: Mapping[str, Any]) -> None:
|
|
28
84
|
atomic_write_json(self.service.vault.processed_state_path, dict(processed))
|
|
29
85
|
|
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from .validation import SOURCE_FIELDS
|
|
6
|
+
|
|
7
|
+
_SOURCE_FIELDS_TEXT = ", ".join(SOURCE_FIELDS)
|
|
8
|
+
|
|
9
|
+
# The model is never asked to guess the ``sources`` entry shape: this sentence
|
|
10
|
+
# is generated from the validator's own field list, so the instruction and the
|
|
11
|
+
# enforced contract stay in step.
|
|
12
|
+
_SOURCES_CONTRACT = (
|
|
13
|
+
"sources is a non-empty array of objects, never an array of bare strings. Each "
|
|
14
|
+
f"object may contain only these keys: {_SOURCE_FIELDS_TEXT}. Every value is a "
|
|
15
|
+
"string, except evidence_event_ids, which is a non-empty array of strings. Write "
|
|
16
|
+
'[{"event_key":"<admitted event key>"}], not ["<admitted event key>"]. Use only '
|
|
17
|
+
"event keys admitted for the current turn."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
SOURCES_SHAPE_CORRECTION = (
|
|
21
|
+
"Previous output violated: source_shape. "
|
|
22
|
+
+ _SOURCES_CONTRACT
|
|
23
|
+
+ " Keep every other field of the summary unchanged."
|
|
24
|
+
)
|
|
25
|
+
|
|
5
26
|
|
|
6
27
|
GATE_SYSTEM = """You are memleaf's strict, source-neutral memory admission Gate. Return exactly one strict JSON object with top-level fields candidates, coverage, and evidence_bindings.
|
|
7
28
|
|
|
@@ -73,6 +94,9 @@ Semantic completeness is required: keep the smallest complete confirmed content
|
|
|
73
94
|
OUTPUT CONTRACT
|
|
74
95
|
A normal summary requires title, body, tags, type, scopes, and sources. Optional existing-schema fields are memory_id, update_memory_id, aliases, keywords, scope_source, evidence_event_ids, shadow_native_ids, scope_operations, status, completed_at, and due_date. Use only admitted current event keys in sources/evidence references. Copy the Gate candidate's type and scopes exactly; if scope_source is present, it must match the Gate value.
|
|
75
96
|
|
|
97
|
+
SOURCES SHAPE
|
|
98
|
+
""" + _SOURCES_CONTRACT + """
|
|
99
|
+
|
|
76
100
|
TODO AND DATES
|
|
77
101
|
Evidence events may include an ISO-8601 UTC timestamp. For a new todo, include status and due_date; use due_date=null when no deadline is established. For an updated todo, include current status. completed requires completed_at grounded in the admitted event timestamp. Do not invent dates. Preserve only date meaning supported by admitted Evidence; an admitted visible-message timestamp may anchor a supported relative date. Core normalizes supported relative calendar dates to YYYY-MM-DD and validates grounding; recurring schedules may remain recurring.
|
|
78
102
|
|