memleaf 0.2.60__tar.gz → 0.2.62__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.60 → memleaf-0.2.62}/CHANGELOG.md +19 -0
- {memleaf-0.2.60/src/memleaf.egg-info → memleaf-0.2.62}/PKG-INFO +7 -7
- {memleaf-0.2.60 → memleaf-0.2.62}/README.en.md +6 -6
- {memleaf-0.2.60 → memleaf-0.2.62}/README.md +6 -6
- {memleaf-0.2.60 → memleaf-0.2.62}/docs/extraction-latency.md +1 -1
- memleaf-0.2.62/docs/semantic-extraction-protocol.md +39 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/pyproject.toml +1 -1
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/extraction_budget.py +5 -5
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/extraction_work_state.py +7 -6
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/memory_writer.py +7 -2
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/model_execution.py +4 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/planning_context.py +34 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/process_common.py +12 -0
- memleaf-0.2.62/src/memleaf/semantic_maintenance.py +140 -0
- memleaf-0.2.62/src/memleaf/semantic_protocol.py +259 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/single_pass_memory_planner.py +216 -89
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/single_pass_plan.py +401 -69
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/turn_audit.py +12 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/validation.py +24 -9
- {memleaf-0.2.60 → memleaf-0.2.62/src/memleaf.egg-info}/PKG-INFO +7 -7
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf.egg-info/SOURCES.txt +3 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/LICENSE +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/MANIFEST.in +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/docs/capture-budget-design.md +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/docs/config-migrations.md +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/docs/core-refactor.md +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/docs/evidence-retention.md +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/docs/gate-evidence-boundary.md +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/docs/general-processing.md +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/docs/hermes-mcp-runtime.md +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/docs/processing-quality-acceptance.md +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/docs/v0.2.26-processing-status.md +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/examples/README.md +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/examples/basic_usage.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/install.ps1 +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/install.sh +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/setup.cfg +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/admission.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/batch_review.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/config.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/create_coordinator.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/evidence_budget.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/evidence_policy.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/evidence_structure.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/evidence_syntax.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/extraction_capability.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/hermes_provider/_provider.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/hermes_provider/_shared.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/hermes_runtime.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/index.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/inspection.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/llm/thinking.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/memory_commit.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/memory_planner.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/model_capabilities.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/models.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/native_registration.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/parallel_model.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/process_jobs.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/process_journal.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/process_owner.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/processing.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/prompts.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/provenance.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/recording_policy.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/retention.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/service.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/source_policy.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/state_layout.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/subprocess_flags.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/summary_batch.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/target_reconciliation.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/turn_plan.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/update_coordinator.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/update_review.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.60 → memleaf-0.2.62}/src/memleaf.egg-info/top_level.txt +0 -0
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.62 — 2026-09-16
|
|
6
|
+
|
|
7
|
+
- Record conclusions, not process. The contract now opens with one precedence rule -- 只记结论: what the user chose, agreed to, or what happened; options, plans and drafts offered by the assistant are process even when they look like reusable knowledge. It replaces a genuine conflict between two earlier sentences: `reusable`/通用经验 invited storing the assistant's own outline, while the assistant-proposal clause forbade it, so the model decided by coin flip. The contradictory sentence about assistant suggestions never being a task basis is gone with it. Measured on the reproduced turn, the assistant's four-section outline was written in one of two runs before the change and in none of four runs after it.
|
|
8
|
+
- Write one topic once. The contract states it directly instead of enumerating types, and the fact+todo twin that a single sentence ("明天要给鑫元基金发任务排期清单") used to produce is gone. Types are not enumerated because the topic may be neither a fact nor a todo.
|
|
9
|
+
- Stop discarding whole candidates. Four fail-closed gates were removed or degraded, because each one traded a whole memory for one doubtful field: (1) a missing owner no longer defers -- the model's project name is kept, and `unscoped`/empty normalizes to the single project named by the candidate's own evidence, otherwise `global`; (2) a deadline is validated rather than demanded -- `_resolve_candidate_due_date` accepts it only when the wording occurs in the candidate's evidence and anchors to ISO against that evidence's timestamp (`明天`, `周五`, `下周一`, `月底` resolve; anything else), and an unresolvable date costs the date field only; (3) assistant-stated intent, multi-project bodies and NO_CHANGE target relevance are the model's judgement again; (4) an unauthorized cross-scope UPDATE keeps the target's own scope instead of failing. Dates inside the body that cannot be anchored are now counted, not rejected.
|
|
10
|
+
- Keep the update target through review. The reproduced failure was that a state change became a new memory: the model set `target` correctly but labelled the type `fact` while the target was a `todo`, the review stage grouped candidates by (scope, type), the target fell outside the group, and the reviewer -- which was never shown the primary's target -- re-emitted it as a new item. Grouping now uses the target's own type and scope, the already-chosen target is kept in the group's `existing`, and target records stay in the review catalog.
|
|
11
|
+
- Delete the dead English B3 prompt. `SINGLE_PASS_SYSTEM` and `B3_COMPACT_CONTRACT` were no longer sent to any model; they survived only as an internal scaffold for fragment slicing and a compatibility read path, so they and the guard/imports they kept alive are removed. B3 remains readable for host adapters and stored plans, and the structure-repair request now carries a one-line schema note instead of the full contract.
|
|
12
|
+
- Tests: the suite went from 18 red to 115 green. Sixteen failures asserted sentences in that deleted English prompt; they are removed, and the one whose intent still holds (the model used to receive the date anchor) now pins `_resolve_candidate_due_date` with eight cases. Two behavioural tests were aligned to the current design and this is a deliberate call, not a discovered bug: a deferral row legitimately carries diagnostics now, and the documented turn budget is three requests (`MAX_MODEL_REQUESTS = 3`) rather than two.
|
|
13
|
+
|
|
14
|
+
Verification for this release replayed three real Hermes sessions (seven turns, real assistant replies, isolated Vault, live model route). Before: the email turn wrote nothing -- two candidates deferred for `scope_not_grounded` and the todo for `due_date_not_grounded` -- so no todo ever existed and every later "已经完成了" created another memory, which is how the live Vault ended up with two overlapping "排期已发出" facts. After: the first turn writes three memories including the todo with a resolved `2026-09-16` deadline, the completion turn updates that same todo to `completed`, the cross-session repeat is NO_CHANGE, a new request creates a new todo with `2026-09-18`, the query turn writes nothing, and every turn reports zero deferrals with `coverage_status=complete`, across four runs. Known residual: an assistant statement about its own state ("我这边没有底稿") is still stored occasionally.
|
|
15
|
+
|
|
16
|
+
## 0.2.61 — 2026-09-15
|
|
17
|
+
|
|
18
|
+
- Split ordinary automatic extraction into a model-facing semantic protocol and a Core-owned B3 write protocol. Core supplies short immutable evidence-fragment IDs and binds their original spans locally; the model first selects reusable topics and then synthesizes memory content. Core compiles the result into B3 candidate IDs, decisions, evidence, targets and write fields before the existing validators and commit path run.
|
|
19
|
+
- Separate retention from memory type with `reusable` and `session`. Transient execution, lookup and recovery details can settle as `no_memory`, while a durable failure, business fact or follow-up task remains eligible when its underlying meaning has future value. Coverage stays fail-closed, user tasks require explicit user assertion evidence, and a local target cannot be guessed into a CREATE or NO_CHANGE.
|
|
20
|
+
- Raise the durable automatic request budget to three: topic selection and synthesis use two requests, while candidate repair or same-target coordination shares one final request. Candidate-level repair can correct date/ownership/value disagreements without rewriting valid siblings; project-subject guards and bounded date diagnostics remain Core-owned and never persist arbitrary model or error text.
|
|
21
|
+
|
|
22
|
+
Verification for this release used 58 local synthetic regression tests with no model call or production Vault write (35 semantic-protocol tests and 23 existing extraction-boundary tests). Python 3.11 compilation/imports, `git diff --check` and a package wheel containing the Hermes provider resource pass. Real-provider quality, latency and a newly installed Hermes replay remain separate runtime acceptance.
|
|
23
|
+
|
|
5
24
|
## 0.2.60 — 2026-09-15
|
|
6
25
|
|
|
7
26
|
- Tighten the B3 planner's output discipline without changing its protocol or write semantics. The system prompt now says not to analyze beyond the task, decide directly and return promptly, and keep each memory as brief as possible without losing essential meaning. Evidence, Scope, date grounding, candidate decisions and commit safety remain unchanged.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.62
|
|
4
4
|
Summary: A local-first Markdown memory core for AI agents
|
|
5
5
|
Author: memleaf contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -23,10 +23,10 @@ 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.
|
|
27
|
-
> B3
|
|
28
|
-
>
|
|
29
|
-
>
|
|
26
|
+
> **版本:0.2.62。**
|
|
27
|
+
> 自动提炼只记结论:用户选定的、答应的、已发生的事;助手给出的选项、方案和草稿属于过程,即使它们看起来像可复用的经验也不写成记忆。同一件事只写一次,避免同一条内容同时落成事实和待办。提炼契约现在只有一份中文语义契约(旧版英文 B3 提示词已删除),B3 仅作为兼容读取格式保留。
|
|
28
|
+
> Core 不再因为归属、日期、任务依据或跨项目而丢弃整条候选:归属缺失会归一到证据里唯一被点名的项目、否则落 `global`;日期只接受能在本候选证据里找到出处、且能锚定成 ISO 的写法(`明天`、`周五`、`月底` 都会被解析),无法解析时只丢掉日期字段,记忆照常写入;未获授权的改归属沿用目标原归属。
|
|
29
|
+
> 待办 `due_date` 只表示待办动作本身明确声明的截止日;属于待办主题或预期结果的日期不会被误作截止日。项目归属由模型按语义判断,不要求项目名在证据里逐字出现。复核阶段会保留首轮已选定的更新目标,避免“状态变更”被降级成新增记忆。Markdown 仍是唯一事实源。
|
|
30
30
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
31
31
|
|
|
32
32
|
## 项目定位
|
|
@@ -99,8 +99,8 @@ memleaf 不把每句话都保存为记忆。处理一轮完整的 user + assista
|
|
|
99
99
|
|
|
100
100
|
- 通常一轮产生 0~1 条记忆,而不是按句子拆分;
|
|
101
101
|
- 新记忆必须有稳定标题、完整正文和合理 Scope;
|
|
102
|
-
-
|
|
103
|
-
- 相同未来用途优先 UPDATE/NO_CHANGE,不重复 CREATE
|
|
102
|
+
- 归属不明确时归一到证据里唯一被点名的项目,确实没有主体时落 `global`,不因为归属不明而丢掉这条记忆;
|
|
103
|
+
- 相同未来用途优先 UPDATE/NO_CHANGE,不重复 CREATE;同一件事只写一次;
|
|
104
104
|
- 用户显式要求保存时可调用 `remember`,但仍会整理、校验和去重;
|
|
105
105
|
- 模型、解析、写入或索引失败时保留 inbox 和处理水位,后续可重试;
|
|
106
106
|
- 自动清理有 24 小时安全期,不会因为一次处理失败就删除原始捕获。
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
[中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
|
|
6
6
|
|
|
7
|
-
> **Version: 0.2.
|
|
8
|
-
>
|
|
9
|
-
>
|
|
10
|
-
>
|
|
7
|
+
> **Version: 0.2.62.**
|
|
8
|
+
> Automatic extraction now records conclusions only: what the user chose, agreed to, or what happened. Options, plans and drafts offered by the assistant are process, even when they look like reusable knowledge. One topic is written once, so the same content no longer lands as both a fact and a todo. A single Chinese semantic contract is now sent; the older English B3 prompt has been deleted and B3 remains only as a compatibility read format.
|
|
9
|
+
> Core no longer discards a whole candidate over ownership, dates, task basis or cross-project wording. A missing owner normalizes to the single project named by that candidate's own evidence, otherwise `global`. A deadline is accepted only when it appears in the candidate's own evidence and can be anchored to ISO (`明天`, `周五`, `月底` all resolve); when it cannot, only the date field is dropped and the memory is still written. An unauthorized ownership change keeps the target's existing scope.
|
|
10
|
+
> A todo's `due_date` means only the explicit deadline of the todo action; dates describing its subject or desired outcome are not deadlines. Project ownership remains the model's semantic judgement and needs no literal name match. The review stage now keeps the update target chosen by the first pass, so a state change is no longer downgraded into a new memory. Markdown remains the sole source of truth.
|
|
11
11
|
> **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
|
|
12
12
|
|
|
13
13
|
## Project scope
|
|
@@ -78,8 +78,8 @@ Additional rules:
|
|
|
78
78
|
|
|
79
79
|
- A normal turn typically produces 0–1 memory rather than one memory per sentence.
|
|
80
80
|
- New memories need a stable title, self-contained body, and appropriate Scope.
|
|
81
|
-
-
|
|
82
|
-
- Prefer UPDATE or NO_CHANGE over creating a sibling for the same future use.
|
|
81
|
+
- An unclear owner normalizes to the single project named by that candidate's evidence, or `global` when there is genuinely no subject; an unclear owner never costs the memory.
|
|
82
|
+
- Prefer UPDATE or NO_CHANGE over creating a sibling for the same future use; one topic is written once.
|
|
83
83
|
- An explicit user request can call `remember`, but the content is still normalized, checked, and deduplicated.
|
|
84
84
|
- Model, parsing, write, or index failures keep the inbox and processing watermark retryable.
|
|
85
85
|
- Automatic cleanup has a 24-hour safety period; a failed processing attempt does not delete the original capture.
|
|
@@ -4,10 +4,10 @@
|
|
|
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.
|
|
8
|
-
> B3
|
|
9
|
-
>
|
|
10
|
-
>
|
|
7
|
+
> **版本:0.2.62。**
|
|
8
|
+
> 自动提炼只记结论:用户选定的、答应的、已发生的事;助手给出的选项、方案和草稿属于过程,即使它们看起来像可复用的经验也不写成记忆。同一件事只写一次,避免同一条内容同时落成事实和待办。提炼契约现在只有一份中文语义契约(旧版英文 B3 提示词已删除),B3 仅作为兼容读取格式保留。
|
|
9
|
+
> Core 不再因为归属、日期、任务依据或跨项目而丢弃整条候选:归属缺失会归一到证据里唯一被点名的项目、否则落 `global`;日期只接受能在本候选证据里找到出处、且能锚定成 ISO 的写法(`明天`、`周五`、`月底` 都会被解析),无法解析时只丢掉日期字段,记忆照常写入;未获授权的改归属沿用目标原归属。
|
|
10
|
+
> 待办 `due_date` 只表示待办动作本身明确声明的截止日;属于待办主题或预期结果的日期不会被误作截止日。项目归属由模型按语义判断,不要求项目名在证据里逐字出现。复核阶段会保留首轮已选定的更新目标,避免“状态变更”被降级成新增记忆。Markdown 仍是唯一事实源。
|
|
11
11
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
12
12
|
|
|
13
13
|
## 项目定位
|
|
@@ -80,8 +80,8 @@ memleaf 不把每句话都保存为记忆。处理一轮完整的 user + assista
|
|
|
80
80
|
|
|
81
81
|
- 通常一轮产生 0~1 条记忆,而不是按句子拆分;
|
|
82
82
|
- 新记忆必须有稳定标题、完整正文和合理 Scope;
|
|
83
|
-
-
|
|
84
|
-
- 相同未来用途优先 UPDATE/NO_CHANGE,不重复 CREATE
|
|
83
|
+
- 归属不明确时归一到证据里唯一被点名的项目,确实没有主体时落 `global`,不因为归属不明而丢掉这条记忆;
|
|
84
|
+
- 相同未来用途优先 UPDATE/NO_CHANGE,不重复 CREATE;同一件事只写一次;
|
|
85
85
|
- 用户显式要求保存时可调用 `remember`,但仍会整理、校验和去重;
|
|
86
86
|
- 模型、解析、写入或索引失败时保留 inbox 和处理水位,后续可重试;
|
|
87
87
|
- 自动清理有 24 小时安全期,不会因为一次处理失败就删除原始捕获。
|
|
@@ -19,7 +19,7 @@ llm:
|
|
|
19
19
|
|
|
20
20
|
## 保留的调用与提交约束
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
普通自动提炼正常使用两次模型调用(一次提炼、一次按需复核),全部遵循 `single_pass` 思考配置(默认 `disabled`),包括候选修复。候选存在分歧或字段违规时允许一次局部修复;复核、修复与同目标协调共享总计三次的请求上限。固定安全路由保留实际 outbound 请求计数和持久化预占,不增加隐藏 host-to-API fallback。语义质量和耗时分别验收。
|
|
23
23
|
|
|
24
24
|
每轮仍先读取持久状态、规划、校验、提交,再处理下一轮;一轮超过目标不会占用下一轮的“时间许可”。compaction 仍不在普通提炼的关键路径上。
|
|
25
25
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# 自动记忆提炼:语义契约与 Core 写入
|
|
2
|
+
|
|
3
|
+
普通自动提炼只给模型一份中文语义契约(`semantic_protocol.FRAGMENT_SYSTEM`)。模型只引用 Core 提供的短编号片段,原文、偏移、日期和写入字段都由 Core 绑定。
|
|
4
|
+
|
|
5
|
+
## 正常路径
|
|
6
|
+
|
|
7
|
+
1. **Core 建立证据片段**:按通用标点和换行生成短编号,保留原始 unit、字符位置、角色和时间锚点。模型只引用编号;原文和偏移由 Core 绑定。
|
|
8
|
+
2. **一次提炼**:模型从完整证据生成记忆(`memories` / `no_memory` / `deferred`),自行决定主题、类型、归属与日期写法。
|
|
9
|
+
3. **按需复核**:候选存在分歧、缺证据或字段违规时,Core 用维护契约(`MAINTENANCE_SYSTEM`)再走一次:输入按项目和类型分组,`incoming` 是本轮增量,`existing` 是可更新的 catalog ID。**首轮已选定的更新目标会保留在该组的 `existing` 里,并以目标自身的类型和归属参与分组**,因此状态变更不会被复核降级成新增。
|
|
10
|
+
4. **Core 编译与校验**:生成候选 ID、写入决策和来源,校验引用、证据与结构后交给原有提交与恢复机制。
|
|
11
|
+
|
|
12
|
+
正常两次请求;候选修复与同目标协调共享第三次额度。
|
|
13
|
+
|
|
14
|
+
## 提炼判据
|
|
15
|
+
|
|
16
|
+
契约只说我们要什么,不列禁止项:
|
|
17
|
+
|
|
18
|
+
- **只记结论**:用户选定的、答应的、已发生的事。助手给出的选项、方案、草稿属于过程——即使它看起来像可复用的通用经验,也仍然是过程。来源优先于"像不像经验"。
|
|
19
|
+
- **同一件事只写一次**:同一条内容不会同时落成事实和待办。
|
|
20
|
+
- `retention` 与类型独立:`reusable` 表示今后仍需使用的业务状态、约定或可复用结论;`session` 表示仅说明本轮如何查询、执行和恢复的过程,Core 会把它转成 `no_memory`。
|
|
21
|
+
|
|
22
|
+
## 降级而不是丢弃
|
|
23
|
+
|
|
24
|
+
Core 不再因为下列原因丢弃整条候选(每次丢弃都等于损失一条本可保留的记忆):
|
|
25
|
+
|
|
26
|
+
- **归属缺失**:模型给了项目名就保留;给了 `unscoped` 或空值时,归一到本候选证据里唯一被点名的项目,否则落 `global`。
|
|
27
|
+
- **日期无法锚定**:只校验 `due_date`。日期必须出现在本候选证据里、且能按该证据的时间戳锚定成 ISO(`明天`、`周五`、`下周一`、`月底` 都会被解析);不满足时**只丢掉日期字段**,记忆照常写入。
|
|
28
|
+
- **任务依据不足、正文涉及多个项目、NO_CHANGE 目标相关性无法证明**:由模型判断,Core 不再因此拒收。
|
|
29
|
+
- **未获授权的改归属**:沿用目标自身的归属继续写入,不再报错。
|
|
30
|
+
|
|
31
|
+
正文里的日期若无法用本轮证据锚定,只记入诊断计数,不再丢弃整条记忆。
|
|
32
|
+
|
|
33
|
+
## 保留价值与内容类型分离
|
|
34
|
+
|
|
35
|
+
主题和记忆候选支持 `retention: reusable | session`。模型判断内容是在描述本轮执行过程,还是已经成立的可复用状态、经验或后续事项;不根据工具名、错误码或来源角色做过滤。Core 将 `session` 引用转换为 `no_memory`,不产生写入候选。旧适配器省略该字段时保持原有行为。
|
|
36
|
+
|
|
37
|
+
## 验证边界
|
|
38
|
+
|
|
39
|
+
本地测试覆盖引用与覆盖、真正/虚假目标、日期解析与降级、跨项目正文、更新目标在复核阶段的保留,以及既有写入契约。真实模型验收另在隔离 Vault 中执行。十秒目标仍只是性能观测,不能用局部测试或一次成功重放宣称所有语义场景已通过。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Local-first Markdown memory core for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
3
|
+
__version__ = "0.2.62"
|
|
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
|
|
@@ -13,20 +13,20 @@ from typing import Any, Iterable, Mapping
|
|
|
13
13
|
from .llm import ModelError
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
MAX_MODEL_REQUESTS =
|
|
16
|
+
MAX_MODEL_REQUESTS = 3
|
|
17
17
|
TARGET_TOTAL_SECONDS = 10.0
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class SinglePassBudgetBackend:
|
|
21
|
-
"""Limit
|
|
21
|
+
"""Limit topic selection, synthesis and optional candidate repair to three requests.
|
|
22
22
|
|
|
23
23
|
``single_pass_safe`` routes map one complete() call to one request, without
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
Topic selection and synthesis use two requests. One remaining request
|
|
28
|
+
covers candidate correction or same-target reconciliation. Competing
|
|
29
|
+
follow-ups share that budget; they cannot extend it.
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
32
|
def __init__(
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
A detached worker can die after a provider request but before memleaf records a
|
|
4
4
|
normal model failure. The process-job ID survives that worker restart, so use
|
|
5
5
|
it as the stable work identity and reserve each outbound single-pass request
|
|
6
|
-
*before* dispatch. A restarted worker therefore cannot reopen the
|
|
6
|
+
*before* dispatch. A restarted worker therefore cannot reopen the bounded
|
|
7
7
|
automatic budget for the same turn.
|
|
8
8
|
|
|
9
9
|
Older releases also recorded a wall-clock start. Those timestamps are accepted
|
|
@@ -22,6 +22,7 @@ from pathlib import Path
|
|
|
22
22
|
from typing import Any, Mapping
|
|
23
23
|
|
|
24
24
|
from .locking import atomic_write_json, read_json
|
|
25
|
+
from .extraction_budget import MAX_MODEL_REQUESTS
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
_VERSION = 1
|
|
@@ -74,13 +75,13 @@ def _normalize_turn_state(value: Any) -> dict[str, Any]:
|
|
|
74
75
|
compatibility only; no request ordinal is reset and no expiry is inferred.
|
|
75
76
|
"""
|
|
76
77
|
|
|
77
|
-
if type(value) is int and 0 <= value <=
|
|
78
|
+
if type(value) is int and 0 <= value <= MAX_MODEL_REQUESTS:
|
|
78
79
|
return {"requests": value, "started_at_epoch": None}
|
|
79
80
|
if not isinstance(value, Mapping):
|
|
80
81
|
raise ExtractionWorkStateError("invalid extraction request budget counter")
|
|
81
82
|
requests = value.get("requests")
|
|
82
83
|
started = value.get("started_at_epoch")
|
|
83
|
-
if type(requests) is not int or not 0 <= requests <=
|
|
84
|
+
if type(requests) is not int or not 0 <= requests <= MAX_MODEL_REQUESTS:
|
|
84
85
|
raise ExtractionWorkStateError("invalid extraction request budget counter")
|
|
85
86
|
if started is not None and not _valid_epoch(started):
|
|
86
87
|
raise ExtractionWorkStateError("invalid extraction work start time")
|
|
@@ -213,9 +214,9 @@ def active_background_work_id(
|
|
|
213
214
|
|
|
214
215
|
|
|
215
216
|
def reserve_model_request(vault: Any, *, work_id: str, turn_id: str) -> int | None:
|
|
216
|
-
"""Atomically reserve the next provider request and return ordinal
|
|
217
|
+
"""Atomically reserve the next provider request and return its ordinal.
|
|
217
218
|
|
|
218
|
-
``None`` means this stable work+turn already consumed
|
|
219
|
+
``None`` means this stable work+turn already consumed the request budget. The
|
|
219
220
|
reservation happens before the outbound call, so a process kill after this
|
|
220
221
|
write still consumes that attempt conservatively.
|
|
221
222
|
"""
|
|
@@ -240,7 +241,7 @@ def reserve_model_request(vault: Any, *, work_id: str, turn_id: str) -> int | No
|
|
|
240
241
|
turn_state = _normalize_turn_state(turn_state)
|
|
241
242
|
turns[turn_id] = turn_state
|
|
242
243
|
count = turn_state["requests"]
|
|
243
|
-
if count >=
|
|
244
|
+
if count >= MAX_MODEL_REQUESTS:
|
|
244
245
|
return None
|
|
245
246
|
ordinal = count + 1
|
|
246
247
|
turn_state["requests"] = ordinal
|
|
@@ -244,9 +244,14 @@ class MemoryWriter:
|
|
|
244
244
|
raise self._preflight_error("batch contains duplicate deterministic memory id")
|
|
245
245
|
if deterministic_id in duplicate_ids:
|
|
246
246
|
raise self._preflight_error("batch memory id collides with metadata merge target")
|
|
247
|
-
|
|
247
|
+
# An UPDATE keeps its target's identity. Its deterministic request
|
|
248
|
+
# ID can equal that target when a previously created candidate is
|
|
249
|
+
# planned again; it is not an attempt to create another memory.
|
|
250
|
+
# Still reject a request ID that aliases a different update target.
|
|
251
|
+
if deterministic_id in target_ids and deterministic_id != target_id:
|
|
248
252
|
raise self._preflight_error("batch memory id collides with update target")
|
|
249
|
-
if target_id is not None and
|
|
253
|
+
if (target_id is not None and deterministic_id != target_id
|
|
254
|
+
and target_id.casefold() == deterministic_id.casefold()):
|
|
250
255
|
raise self._preflight_error("batch memory id collides with update target")
|
|
251
256
|
memory_ids.add(deterministic_id)
|
|
252
257
|
if "/" in deterministic_id or "\\" in deterministic_id or deterministic_id in (".", ".."):
|
|
@@ -802,6 +802,10 @@ class ModelExecutor:
|
|
|
802
802
|
"validation_detail": validation_detail,
|
|
803
803
|
**_model_output_statistics(raw, purpose),
|
|
804
804
|
}
|
|
805
|
+
date_info = getattr(error, "date_diagnostics", None)
|
|
806
|
+
if isinstance(date_info, Mapping):
|
|
807
|
+
from .validation import safe_date_diagnostics
|
|
808
|
+
entry.update(safe_date_diagnostics(date_info))
|
|
805
809
|
if evidence_check is not None:
|
|
806
810
|
entry["evidence_check"] = evidence_check
|
|
807
811
|
entry.update(_safe_evidence_diagnostics(error) if error is not None else {})
|
|
@@ -628,6 +628,40 @@ class PlanningContext:
|
|
|
628
628
|
native_query=visible,
|
|
629
629
|
return_bound_status=True,
|
|
630
630
|
)
|
|
631
|
+
# A session scope is conversational background, not a search fence.
|
|
632
|
+
# Include known projects named in this turn and this session's own
|
|
633
|
+
# active memories so a project switch cannot hide maintenance targets.
|
|
634
|
+
with self.service.vault.lock():
|
|
635
|
+
config = self.service.vault.config()
|
|
636
|
+
registry = config.get("scopes", {})
|
|
637
|
+
mentioned = [key for key in registry
|
|
638
|
+
if isinstance(key, str) and key.startswith("project:")
|
|
639
|
+
and self._scope_terms_present(visible, key, config)]
|
|
640
|
+
current_records = self.service._read_memories_unlocked("knowledge")
|
|
641
|
+
contextual = [record.memory.to_dict() for record in current_records
|
|
642
|
+
if (any(self._scope_terms_present(visible, key, config)
|
|
643
|
+
for key in record.memory.scopes if key.startswith("project:"))
|
|
644
|
+
or (record.memory.extra.get("source") == turn.source and any(
|
|
645
|
+
src.get("session_id") == turn.session_id
|
|
646
|
+
for src in record.memory.sources if isinstance(src, Mapping))))]
|
|
647
|
+
if explicit_scope is not None:
|
|
648
|
+
# Explicit caller scope remains a deliberate boundary.
|
|
649
|
+
contextual = [row for row in contextual
|
|
650
|
+
if filter_by_scope([Memory.from_mapping(row)], explicit_scope, config)]
|
|
651
|
+
elif mentioned:
|
|
652
|
+
scope_background = mentioned
|
|
653
|
+
if contextual:
|
|
654
|
+
contextual = self._overlay_related(contextual, overlay)
|
|
655
|
+
by_id = {row["memory_id"].casefold(): row for row in contextual}
|
|
656
|
+
combined = [row for row in related
|
|
657
|
+
if row.get("native") is True
|
|
658
|
+
or str(row.get("memory_id", "")).casefold() not in by_id]
|
|
659
|
+
related, complete = self._bound_related_with_status(
|
|
660
|
+
[*contextual, *combined], priority_memory_ids=by_id)
|
|
661
|
+
bound_complete = bool(bound_complete and complete)
|
|
662
|
+
# Several supplied targets are alternatives for the semantic
|
|
663
|
+
# matcher, not an incomplete lookup merely because they coexist.
|
|
664
|
+
scope_fallback = None
|
|
631
665
|
correction_rows, correction_complete = self._single_pass_scope_correction_context(turn)
|
|
632
666
|
if correction_rows:
|
|
633
667
|
existing_ids = {
|
|
@@ -832,6 +832,10 @@ def _summary_date_grounding_violations(
|
|
|
832
832
|
allowed_dates.add(canonical)
|
|
833
833
|
|
|
834
834
|
yearless_monthdays = source_monthdays | preserved_monthdays
|
|
835
|
+
# An absolute source date also supports a faithful yearless rendering.
|
|
836
|
+
# This does not authorize adding a year to an unanchored source date.
|
|
837
|
+
yearless_monthdays.update(value[5:] for value in allowed_dates
|
|
838
|
+
if isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value))
|
|
835
839
|
violations: list[str] = []
|
|
836
840
|
seen: set[str] = set()
|
|
837
841
|
for field in ("title", "body"):
|
|
@@ -974,12 +978,20 @@ def _native_result(value: Any) -> list[dict[str, Any]]:
|
|
|
974
978
|
def _merge_related(values: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
|
975
979
|
result: list[dict[str, Any]] = []
|
|
976
980
|
seen_bodies: set[str] = set()
|
|
981
|
+
seen_ids: set[str] = set()
|
|
977
982
|
for item in values:
|
|
978
983
|
if not isinstance(item, Mapping):
|
|
979
984
|
continue
|
|
980
985
|
body = item.get("body")
|
|
981
986
|
if not isinstance(body, str):
|
|
982
987
|
continue
|
|
988
|
+
memory_id = item.get("memory_id")
|
|
989
|
+
if item.get("native") is not True and isinstance(memory_id, str) and memory_id:
|
|
990
|
+
key = memory_id.casefold()
|
|
991
|
+
if key not in seen_ids:
|
|
992
|
+
seen_ids.add(key)
|
|
993
|
+
result.append(dict(item))
|
|
994
|
+
continue
|
|
983
995
|
normalized = normalize_term(body)
|
|
984
996
|
if normalized and normalized in seen_bodies:
|
|
985
997
|
continue
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Maintain scoped memory state from small, already evidence-bound deltas."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
from .semantic_protocol import expand_fragments, _invalid
|
|
6
|
+
from .validation import parse_strict_json
|
|
7
|
+
|
|
8
|
+
MAINTENANCE_SYSTEM = '''维护长期记忆,而不是再次摘录对话。groups 按项目和类型隔离,incoming 是本轮增量,existing 是可更新的 catalog ID。
|
|
9
|
+
同一事项的需求、进展、回复、附件位置和约定日期合并维护;后续状态替换旧状态,重复信息不新建。不同的独立事项保持分开。正文概括核心,不逐条转录文档或保存助手的临时建议。类型由输入确定,本阶段只维护同类型的状态。
|
|
10
|
+
返回 JSON {"memories":[{"from":[incoming ID],"target":"已有memory_id或null","title":"主题","body":"合并后的当前内容","type":"fact或todo等"}],"discard":[incoming ID],"deferred":[incoming ID]}。
|
|
11
|
+
每条仅合并同组 incoming;同一事项已有记忆时 target 必须选该组 existing 中的ID,保留其有效内容并更新变化;独立新事项 target=null。已有target保留原type;新任务没有同事项todo目标时新建todo,不借用fact ID。同一target只输出一次。无需修改的已有记忆可原样返回。todo 提供 status(active/completed/cancelled)和 due_date(原文日期,无则null);不能把任务变成一般事实而丢失动作。每个 incoming 由 from、discard 或 deferred 覆盖。无需处理原始片段ID、复制证据或生成记忆ID。'''
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def maintenance_input(raw: str, fragments: list[dict[str, Any]], catalog: list[dict[str, Any]], model_data: Mapping[str, Any]):
|
|
15
|
+
# Validate exact references before they become trusted input to maintenance.
|
|
16
|
+
expand_fragments(raw, fragments)
|
|
17
|
+
original = parse_strict_json(raw)
|
|
18
|
+
by_id = {
|
|
19
|
+
memory['memory_id'].casefold(): memory
|
|
20
|
+
for memory in catalog
|
|
21
|
+
if isinstance(memory.get('memory_id'), str)
|
|
22
|
+
}
|
|
23
|
+
groups = {}
|
|
24
|
+
incoming = {}
|
|
25
|
+
targeted: set[str] = set()
|
|
26
|
+
for index, row in enumerate(original['memories'], 1):
|
|
27
|
+
if row.get('retention') == 'session':
|
|
28
|
+
continue
|
|
29
|
+
row = dict(row)
|
|
30
|
+
target_id = row.get('target')
|
|
31
|
+
record = by_id.get(target_id.casefold()) if isinstance(target_id, str) else None
|
|
32
|
+
if record is not None:
|
|
33
|
+
# An UPDATE inherits its target's type and scope. The provisional
|
|
34
|
+
# type the model wrote must not move the candidate into another
|
|
35
|
+
# group, or the reviewer never sees the memory it is updating.
|
|
36
|
+
row['type'] = record.get('type', row.get('type', 'fact'))
|
|
37
|
+
scopes = record.get('scopes') or []
|
|
38
|
+
if len(scopes) == 1:
|
|
39
|
+
row['scope'] = scopes[0]
|
|
40
|
+
targeted.add(record['memory_id'])
|
|
41
|
+
scope = row['scope']
|
|
42
|
+
kind = row.get('type', 'fact')
|
|
43
|
+
group = groups.setdefault((scope, kind), {'scope': scope, 'type': kind, 'existing': [], 'incoming': []})
|
|
44
|
+
incoming[index] = row
|
|
45
|
+
group['incoming'].append({k:v for k,v in {'id':f'd{index}', **row}.items()
|
|
46
|
+
if k not in {'evidence','task_basis','retention'}})
|
|
47
|
+
if record is not None and record['memory_id'] not in group['existing']:
|
|
48
|
+
# The already-chosen target must stay selectable for the reviewer.
|
|
49
|
+
group['existing'].append(record['memory_id'])
|
|
50
|
+
related = [m for m in catalog if len(m.get('scopes', [])) == 1 and (m['scopes'][0], m['type']) in groups]
|
|
51
|
+
known = {m['memory_id'] for m in related}
|
|
52
|
+
for memory in catalog:
|
|
53
|
+
if memory['memory_id'] in targeted and memory['memory_id'] not in known:
|
|
54
|
+
# A target whose recorded type differs from the provisional one is
|
|
55
|
+
# still part of the comparison context.
|
|
56
|
+
related.append(memory)
|
|
57
|
+
known.add(memory['memory_id'])
|
|
58
|
+
# Small original snippets let the reviewer disambiguate a task or date,
|
|
59
|
+
# while the long source document is no longer a second extraction job.
|
|
60
|
+
snippets = [{**f, 'text': f['text'][:240]} for f in model_data['fragments']]
|
|
61
|
+
payload = {'groups': list(groups.values()), 'catalog': related, 'fragments': snippets}
|
|
62
|
+
return payload, (original, incoming, {m['memory_id'].casefold():m for m in related})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def expand_maintenance(raw: str, context) -> str:
|
|
66
|
+
value = parse_strict_json(raw)
|
|
67
|
+
original, incoming, catalog = context
|
|
68
|
+
# Older host adapters can still provide fully bound compact output.
|
|
69
|
+
if isinstance(value, dict) and set(value) == {'memories','no_memory','deferred'}:
|
|
70
|
+
return raw
|
|
71
|
+
if not isinstance(value, dict) or set(value) != {'memories','discard','deferred'}:
|
|
72
|
+
raise _invalid()
|
|
73
|
+
if any(not isinstance(v,list) for v in value.values()):
|
|
74
|
+
raise _invalid()
|
|
75
|
+
seen = set()
|
|
76
|
+
def resolve(refs):
|
|
77
|
+
if not isinstance(refs,list) or not refs:
|
|
78
|
+
raise _invalid('invalid_evidence')
|
|
79
|
+
normalized = []
|
|
80
|
+
for ref in refs:
|
|
81
|
+
r = ref[1:] if isinstance(ref, str) and ref.startswith('d') else ref
|
|
82
|
+
normalized.append(int(r) if isinstance(r, str) and len(r) <= 20 and r.isascii() and r.isdecimal() else r)
|
|
83
|
+
if any((type(r) is not int and not isinstance(r, str)) or r not in incoming for r in normalized):
|
|
84
|
+
raise _invalid('invalid_evidence')
|
|
85
|
+
seen.update(normalized)
|
|
86
|
+
return [incoming[r] for r in normalized]
|
|
87
|
+
result = {'memories':[], 'no_memory':list(original['no_memory']), 'deferred':list(original['deferred'])}
|
|
88
|
+
for row in original['memories']:
|
|
89
|
+
if row.get('retention')=='session':
|
|
90
|
+
result['no_memory'].extend(row['evidence'])
|
|
91
|
+
for row in value['memories']:
|
|
92
|
+
if not isinstance(row,dict) or 'target' not in row or not {'from','title','body'} <= set(row):
|
|
93
|
+
raise _invalid()
|
|
94
|
+
if row['from'] == []:
|
|
95
|
+
# A catalog echo without fresh evidence cannot authorize a write.
|
|
96
|
+
# Ignore it only when every supplied field is literally unchanged.
|
|
97
|
+
target = row['target']
|
|
98
|
+
record = catalog.get(target.casefold()) if isinstance(target, str) else None
|
|
99
|
+
fields = {k: v for k, v in row.items() if k not in {'from', 'target'} and v is not None}
|
|
100
|
+
if record is None or any(record.get(k) != v for k, v in fields.items()):
|
|
101
|
+
raise _invalid('invalid_evidence')
|
|
102
|
+
continue
|
|
103
|
+
sources = resolve(row['from'])
|
|
104
|
+
scopes = {s['scope'] for s in sources}
|
|
105
|
+
if len(scopes)!=1:
|
|
106
|
+
raise _invalid('scope_drift')
|
|
107
|
+
scope = next(iter(scopes))
|
|
108
|
+
kinds = {s.get('type', 'fact') for s in sources}
|
|
109
|
+
if len(kinds) != 1:
|
|
110
|
+
raise _invalid('invalid_type')
|
|
111
|
+
kind = next(iter(kinds))
|
|
112
|
+
target = row['target']
|
|
113
|
+
if target is not None:
|
|
114
|
+
record = catalog.get(target.casefold()) if isinstance(target,str) else None
|
|
115
|
+
if record is None or record['scopes'] != [scope]:
|
|
116
|
+
raise _invalid('scope_drift')
|
|
117
|
+
if kind != record['type']:
|
|
118
|
+
raise _invalid('invalid_type')
|
|
119
|
+
memory = {k:v for k,v in row.items() if k!='from'}
|
|
120
|
+
if 'scope' in memory and memory['scope']!=scope:
|
|
121
|
+
raise _invalid('scope_drift')
|
|
122
|
+
memory['scope']=scope
|
|
123
|
+
memory['type']=kind
|
|
124
|
+
if kind != 'todo':
|
|
125
|
+
for field in ('status', 'due_date', 'completed_at'):
|
|
126
|
+
memory.pop(field, None)
|
|
127
|
+
memory['evidence']=list(dict.fromkeys(int(r) for s in sources for r in s['evidence']))
|
|
128
|
+
bases = list(dict.fromkeys(int(r) for s in sources for r in (s.get('task_basis') or [])))
|
|
129
|
+
if bases:
|
|
130
|
+
memory['task_basis']=bases
|
|
131
|
+
result['memories'].append(memory)
|
|
132
|
+
for key,dest in (('discard','no_memory'),('deferred','deferred')):
|
|
133
|
+
if value[key]:
|
|
134
|
+
for row in resolve(value[key]):
|
|
135
|
+
result[dest].extend(row['evidence'])
|
|
136
|
+
if seen != set(incoming):
|
|
137
|
+
raise _invalid('invalid_evidence')
|
|
138
|
+
for key in ('no_memory','deferred'):
|
|
139
|
+
result[key]=list(dict.fromkeys(int(r) for r in result[key]))
|
|
140
|
+
return json.dumps(result,ensure_ascii=False)
|