memleaf 0.2.59__tar.gz → 0.2.61__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.59 → memleaf-0.2.61}/CHANGELOG.md +15 -0
- {memleaf-0.2.59/src/memleaf.egg-info → memleaf-0.2.61}/PKG-INFO +4 -2
- {memleaf-0.2.59 → memleaf-0.2.61}/README.en.md +3 -1
- {memleaf-0.2.59 → memleaf-0.2.61}/README.md +3 -1
- {memleaf-0.2.59 → memleaf-0.2.61}/docs/extraction-latency.md +1 -1
- memleaf-0.2.61/docs/semantic-extraction-protocol.md +33 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/pyproject.toml +1 -1
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/extraction_budget.py +5 -5
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/extraction_work_state.py +7 -6
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/model_execution.py +4 -0
- memleaf-0.2.61/src/memleaf/semantic_protocol.py +237 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/single_pass_memory_planner.py +31 -8
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/single_pass_plan.py +296 -21
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/turn_audit.py +8 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/validation.py +13 -0
- {memleaf-0.2.59 → memleaf-0.2.61/src/memleaf.egg-info}/PKG-INFO +4 -2
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf.egg-info/SOURCES.txt +2 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/LICENSE +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/MANIFEST.in +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/docs/capture-budget-design.md +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/docs/config-migrations.md +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/docs/core-refactor.md +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/docs/evidence-retention.md +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/docs/gate-evidence-boundary.md +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/docs/general-processing.md +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/docs/hermes-mcp-runtime.md +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/docs/processing-quality-acceptance.md +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/docs/v0.2.26-processing-status.md +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/examples/README.md +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/examples/basic_usage.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/install.ps1 +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/install.sh +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/setup.cfg +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/admission.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/batch_review.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/config.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/create_coordinator.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/evidence_budget.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/evidence_policy.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/evidence_structure.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/evidence_syntax.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/extraction_capability.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/hermes_provider/_provider.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/hermes_provider/_shared.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/hermes_runtime.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/index.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/inspection.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/llm/thinking.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/memory_commit.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/memory_planner.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/model_capabilities.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/models.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/native_registration.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/parallel_model.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/planning_context.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/process_common.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/process_jobs.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/process_journal.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/process_owner.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/processing.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/prompts.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/provenance.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/recording_policy.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/retention.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/service.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/source_policy.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/state_layout.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/subprocess_flags.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/summary_batch.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/target_reconciliation.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/turn_plan.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/update_coordinator.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/update_review.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.59 → memleaf-0.2.61}/src/memleaf.egg-info/top_level.txt +0 -0
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.61 — 2026-09-15
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- 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.
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
## 0.2.60 — 2026-09-15
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
- Treat the prompt change as a bounded efficiency-oriented instruction, not a measured latency claim; provider behavior and model quality remain runtime-dependent.
|
|
17
|
+
|
|
18
|
+
Verification for this release used 23 focused local regression tests with no model call and no production Vault write. Python 3.11 compilation/imports and `git diff --check` pass; real-provider latency and adherence remain unmeasured, and a newly installed Hermes replay remains post-release runtime acceptance.
|
|
19
|
+
|
|
5
20
|
## 0.2.59 — 2026-09-15
|
|
6
21
|
|
|
7
22
|
- Simplify the B3 planner prompt around ordinary semantic judgment. The system instruction is now under 3 KiB and avoids scenario-specific rules; it asks the model to keep only future-use information, identify the user's exact unfinished action before classifying a todo, and attach a date only when that action is explicitly due by it.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.61
|
|
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,9 @@ 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.61。**
|
|
27
|
+
> 普通自动提炼改为“主题选择 → 内容提炼”的语义协议,模型只引用 Core 提供的短证据片段;Core 继续编译并校验内部 B3 写入协议。临时执行过程可标记为 `session` 而不写入长期记忆,正常路径两次模型调用,候选修复与同目标协调共享第三次预算。
|
|
28
|
+
> B3 提取提示要求模型只聚焦当前任务、直接返回并保持记忆简短,减少无关分析和冗长输出;证据、Scope、日期和写入规则不变。
|
|
27
29
|
> 待办 `due_date` 只表示待办动作本身明确声明的截止日;Core 不再从候选证据中的其他日期自动补填。属于待办主题或预期结果的日期不会被误作截止日,无法确认动作截止日时就省略。
|
|
28
30
|
> 自动提取按助手回复中的 Markdown 结构建立候选级来源,过宽的整段引用和未被用户接受的助手意图不会形成记忆。项目归属由模型按语义判断,不再要求项目名在证据里逐字出现:同一个项目换个说法也能落到已有 Scope 上,名字也不再决定这条记忆的生死。日期和截止日期使用统一的边界安全解析,todo 只在候选自己的证据给出唯一明确截止日时填充。Markdown 仍是唯一事实源。
|
|
29
31
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
@@ -4,7 +4,9 @@
|
|
|
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.61.**
|
|
8
|
+
> Ordinary automatic extraction now uses a “topic selection → content synthesis” semantic protocol. The model cites only short evidence fragments supplied by Core, while Core still compiles and validates the internal B3 write contract. Transient execution can be marked `session` without entering long-term memory; the normal path uses two model requests, with candidate repair and same-target coordination sharing a third budget.
|
|
9
|
+
> The B3 extraction prompt now tells the model to focus only on the task, decide directly, return promptly, and keep memories brief; evidence, Scope, date and write semantics are unchanged.
|
|
8
10
|
> A todo's `due_date` now means only the explicit deadline of the todo action. Core no longer fills it from other dates in candidate evidence; dates describing the todo's subject or desired outcome are not deadlines, and an unconfirmed action deadline is omitted.
|
|
9
11
|
> Automatic extraction now creates candidate-local source units from Markdown structure in assistant replies; broad whole-report citations and assistant-only intent are not allowed to create memories. Project ownership is the model's semantic judgement and no longer requires the project name to appear literally in the evidence: one project referred to in different words lands on the existing Scope, and the name no longer decides whether the memory survives. Dates and deadlines use one boundary-safe parser, and a todo receives a due date only when its own evidence supplies one unambiguous deadline. 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.
|
|
10
12
|
> **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
|
|
@@ -4,7 +4,9 @@
|
|
|
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.61。**
|
|
8
|
+
> 普通自动提炼改为“主题选择 → 内容提炼”的语义协议,模型只引用 Core 提供的短证据片段;Core 继续编译并校验内部 B3 写入协议。临时执行过程可标记为 `session` 而不写入长期记忆,正常路径两次模型调用,候选修复与同目标协调共享第三次预算。
|
|
9
|
+
> B3 提取提示要求模型只聚焦当前任务、直接返回并保持记忆简短,减少无关分析和冗长输出;证据、Scope、日期和写入规则不变。
|
|
8
10
|
> 待办 `due_date` 只表示待办动作本身明确声明的截止日;Core 不再从候选证据中的其他日期自动补填。属于待办主题或预期结果的日期不会被误作截止日,无法确认动作截止日时就省略。
|
|
9
11
|
> 自动提取按助手回复中的 Markdown 结构建立候选级来源,过宽的整段引用和未被用户接受的助手意图不会形成记忆。项目归属由模型按语义判断,不再要求项目名在证据里逐字出现:同一个项目换个说法也能落到已有 Scope 上,名字也不再决定这条记忆的生死。日期和截止日期使用统一的边界安全解析,todo 只在候选自己的证据给出唯一明确截止日时填充。Markdown 仍是唯一事实源。
|
|
10
12
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
@@ -19,7 +19,7 @@ llm:
|
|
|
19
19
|
|
|
20
20
|
## 保留的调用与提交约束
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
普通自动提炼分为主题选择和内容提炼,正常使用两次模型调用,全部遵循 `single_pass` 思考配置(默认 `disabled`),包括候选修复。B3 是 Core 内部写入协议。候选校验失败时允许一次局部修复;结构修复和同目标协调共享总计三次的请求上限。固定安全路由保留实际 outbound 请求计数和持久化预占,不增加隐藏 host-to-API fallback。语义质量和耗时分别验收。
|
|
23
23
|
|
|
24
24
|
每轮仍先读取持久状态、规划、校验、提交,再处理下一轮;一轮超过目标不会占用下一轮的“时间许可”。compaction 仍不在普通提炼的关键路径上。
|
|
25
25
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# 自动记忆提炼:语义协议与 Core 写入协议
|
|
2
|
+
|
|
3
|
+
普通自动提炼保留 B3 作为内部写入协议,模型不再同时生成完整 B3 决策、候选 ID、精确引文、覆盖原因和正文。
|
|
4
|
+
|
|
5
|
+
## 正常路径
|
|
6
|
+
|
|
7
|
+
1. **Core 建立证据片段**:按通用标点和换行生成短编号,保留原始 unit、字符位置、角色和时间锚点。模型只引用编号;原文和偏移由 Core 绑定。
|
|
8
|
+
2. **主题选择**:使用 `single_pass` 配置(当前关闭思考),仅判断长期价值、独立主题及归属,返回 `topics / no_memory / deferred`。
|
|
9
|
+
3. **内容提炼**:使用 `single_pass` 配置(当前关闭思考),从完整证据独立生成内容,并复核主题选择阶段的 no_memory,防止第一阶段的遗漏变成假完整。业务事实默认 `fact`;新 Todo 必须提供用户陈述中的任务依据。上一轮候选正文不作为新证据。
|
|
10
|
+
4. **Core 编译与校验**:生成 B3 候选 ID、写入决策和覆盖原因,验证所有引用、目标、字段、日期与归属;之后使用原有提交和恢复机制。
|
|
11
|
+
|
|
12
|
+
两个阶段均依据底层长期含义重新判断,不继承源对话的标题、紧急程度或建议处理方式。语义协议不包含客户、产品或项目名单。
|
|
13
|
+
|
|
14
|
+
## 容错与保护
|
|
15
|
+
|
|
16
|
+
- 缺失或未知片段不会自动变成 `no_memory`。片段覆盖必须完整;同一原始 unit 内存在未解决主题时仍保留未完成状态。
|
|
17
|
+
- 模型无需复制 Markdown 或计算 Unicode 偏移,也不需要根据记录时间推算正文日期。日期保持原文形式,Core 仅以候选已引用的证据解析相对日期和明确截止日期。
|
|
18
|
+
- 新 Todo 必须绑定 `user_assertion`。他方请求可建立业务事实,助手建议和用户查询不能单独建立用户任务。已有 Todo 仍通过真实目录目标维护。
|
|
19
|
+
- `target` 只能引用当前目录。无效目标限制在候选内,不会猜成 CREATE;只有完整已知内容相同才由 Core 生成 NO_CHANGE。
|
|
20
|
+
- 已注册或当前计划中出现的项目,在正文不同分句中作为独立主语出现时,Core 拒绝合并写入。显式项目标签也可建立此冲突。仅在一个关系句中提及两个项目,不自动视为独立主题。未知实体的语义识别仍由模型完成,此保护不是任意文本的完备分类器。
|
|
21
|
+
- 校验失败或两阶段对长期价值存在分歧时,允许一次候选级修复或裁决:提供原始证据和具体违规信息,重新提炼并再次校验,保留已成立核心事实。不会机械删除一句可能改变原意的文本;有效兄弟候选不重写。
|
|
22
|
+
- 正常两次请求,修复或同目标协调共享第三次额度。计数在后台持久化预占,重启不清零;最终仍无法验证的候选继续 DEFERRED,不伪装 complete。
|
|
23
|
+
- 拒绝日期时记录 allowlist 字段名和受限日期字面值。可选模型诊断与最终拒绝候选的审计保留这些信息,不保存任意错误正文、凭证或完整拒绝句子。
|
|
24
|
+
|
|
25
|
+
## 验证边界
|
|
26
|
+
|
|
27
|
+
本地测试覆盖引用与覆盖、真正/虚假目标、用户任务依据、日期修复、跨项目污染、合法关系句、修复失败及持久预算。真实模型验收另在隔离 Vault 中执行。十秒目标仍只是性能观测,不能用局部测试或一次成功重放宣称所有语义场景已通过。
|
|
28
|
+
|
|
29
|
+
### 保留价值与内容类型分离
|
|
30
|
+
|
|
31
|
+
主题和记忆候选支持 `retention: reusable | session`。模型判断内容是在描述本轮执行过程,还是已经成立的可复用状态、经验或后续事项;不根据工具名、错误码或来源角色做过滤。Core 将 `session` 引用转换为 `no_memory`,不产生写入候选;同一证据单元中其他可保留片段继续独立处理。旧适配器省略该字段时保持原有行为。语义判断仍可能出错,此字段不是确定性内容分类器。
|
|
32
|
+
|
|
33
|
+
关闭思考的目标会话重放中,临时读取故障经局部复核标记为 `session`,未写入隔离记忆库。整体覆盖仍为 partial,原因是其他候选校验;本次仅验收临时执行信息过滤。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Local-first Markdown memory core for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
3
|
+
__version__ = "0.2.61"
|
|
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
|
|
@@ -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 {})
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Small model-facing extraction contract; B3 remains the Core write contract.
|
|
2
|
+
|
|
3
|
+
IDs, decisions, evidence roles and coverage reasons are compiled locally. Missing
|
|
4
|
+
coverage is never interpreted as no-memory. All resulting claims still pass the
|
|
5
|
+
existing admission and memory validators before any write.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
|
|
12
|
+
from .validation import MEMORY_TYPES, ModelOutputError, parse_strict_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _invalid(detail: str = 'other_schema_violation') -> ModelOutputError:
|
|
16
|
+
return ModelOutputError('invalid semantic extraction contract', validation_detail=detail)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compile_semantic(raw: str, *, protocol_version: str, local_by_key: Mapping[str, Any],
|
|
20
|
+
prefix: str = 'c') -> tuple[dict[str, Any], dict[str, Any]]:
|
|
21
|
+
value = parse_strict_json(raw)
|
|
22
|
+
if not isinstance(value, dict) or set(value) != {'memories', 'no_memory', 'deferred'}:
|
|
23
|
+
raise _invalid()
|
|
24
|
+
if any(not isinstance(value[k], list) for k in value):
|
|
25
|
+
raise _invalid()
|
|
26
|
+
items, bases = [], {}
|
|
27
|
+
required = {'title', 'body', 'scope', 'evidence'}
|
|
28
|
+
optional = {'type', 'target', 'task_basis', 'due_date', 'status', 'completed_at'}
|
|
29
|
+
for i, row in enumerate(value['memories']):
|
|
30
|
+
if not isinstance(row, dict) or not required <= set(row) or set(row) - required - optional:
|
|
31
|
+
raise _invalid()
|
|
32
|
+
row = {"type": "fact", **row}
|
|
33
|
+
if any(not isinstance(row[k], str) or not row[k].strip() for k in ('title', 'body', 'type', 'scope')):
|
|
34
|
+
raise _invalid()
|
|
35
|
+
if not isinstance(row['evidence'], list) or not row['evidence']:
|
|
36
|
+
raise _invalid('invalid_evidence')
|
|
37
|
+
claims = []
|
|
38
|
+
for claim in row['evidence']:
|
|
39
|
+
if (not isinstance(claim, dict) or not {'unit_id', 'quote'} <= set(claim)
|
|
40
|
+
or set(claim) - {'unit_id', 'quote', 'start', 'end'}
|
|
41
|
+
or any(not isinstance(claim[k], str) or not claim[k] for k in ('unit_id', 'quote'))):
|
|
42
|
+
raise _invalid('invalid_evidence')
|
|
43
|
+
claims.append({**claim, 'role': 'assertion'})
|
|
44
|
+
cid = f'{prefix}{i + 1}'
|
|
45
|
+
target = row.get('target')
|
|
46
|
+
if target is not None and (not isinstance(target, str) or target.casefold() not in local_by_key):
|
|
47
|
+
# Invalid targets remain candidate-local and cannot become CREATE.
|
|
48
|
+
items.append({'candidate_id': cid, 'decision': 'DEFERRED',
|
|
49
|
+
'reason': 'target_ambiguous', 'evidence': claims})
|
|
50
|
+
continue
|
|
51
|
+
memory = {k: row[k] for k in ('title', 'body', 'due_date', 'status', 'completed_at') if k in row}
|
|
52
|
+
item = {'candidate_id': cid, 'evidence': claims, 'memory': memory}
|
|
53
|
+
if target is None:
|
|
54
|
+
item.update(decision='CREATE', type=row['type'], scopes=[row['scope']])
|
|
55
|
+
else:
|
|
56
|
+
record = local_by_key[target.casefold()]
|
|
57
|
+
# Semantic matches still use UPDATE and the normal Core validator;
|
|
58
|
+
# only literal equality can be compiled to NO_CHANGE here.
|
|
59
|
+
same = (row['type'] == record['type'] and [row['scope']] == record['scopes']
|
|
60
|
+
and all(memory.get(k) == record.get(k) for k in memory)
|
|
61
|
+
and all(record.get(k) is None or k in memory for k in ('status', 'completed_at', 'due_date')))
|
|
62
|
+
item.update(decision='NO_CHANGE' if same else 'UPDATE', target_memory_id=record['memory_id'])
|
|
63
|
+
if same:
|
|
64
|
+
del item['memory']
|
|
65
|
+
else:
|
|
66
|
+
item['scopes'] = [row['scope']]
|
|
67
|
+
if 'task_basis' in row:
|
|
68
|
+
basis = row['task_basis']
|
|
69
|
+
if (not isinstance(basis, dict) or set(basis) != {'unit_id', 'quote'}
|
|
70
|
+
or any(not isinstance(v, str) or not v for v in basis.values())):
|
|
71
|
+
raise _invalid('invalid_evidence')
|
|
72
|
+
bases[cid] = basis
|
|
73
|
+
items.append(item)
|
|
74
|
+
for i, uid in enumerate(value['deferred']):
|
|
75
|
+
if not isinstance(uid, str) or not uid:
|
|
76
|
+
raise _invalid('invalid_evidence')
|
|
77
|
+
items.append({'candidate_id': f'{prefix}d{i + 1}', 'decision': 'DEFERRED',
|
|
78
|
+
'reason': 'evidence_insufficient',
|
|
79
|
+
'evidence': [{'unit_id': uid, 'whole_unit': True, 'role': 'assertion'}]})
|
|
80
|
+
if any(not isinstance(uid, str) or not uid for uid in value['no_memory']):
|
|
81
|
+
raise _invalid('invalid_evidence')
|
|
82
|
+
return {'protocol_version': protocol_version, 'items': items,
|
|
83
|
+
'no_memory': [{'unit_id': uid, 'reason': 'no_future_value'} for uid in value['no_memory']]}, bases
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def source_fragments(b3_prompt: str) -> dict[str, Any]:
|
|
87
|
+
"""Assign short references to immutable source spans, not generated quotes."""
|
|
88
|
+
import re
|
|
89
|
+
data = json.loads(b3_prompt.removeprefix('B3_INPUT\n').removesuffix(
|
|
90
|
+
'\nReturn the complete strict B3 envelope.'))
|
|
91
|
+
fragments = []
|
|
92
|
+
for unit in data['current_evidence']:
|
|
93
|
+
text = unit['content']
|
|
94
|
+
matches = list(re.finditer(r'[^\n。!?!?;;]+[。!?!?;;]?', text))
|
|
95
|
+
if not matches and text.strip():
|
|
96
|
+
matches = [re.search(r'[\s\S]+', text)]
|
|
97
|
+
for match in matches:
|
|
98
|
+
if not match.group().strip():
|
|
99
|
+
continue
|
|
100
|
+
fragments.append({'id': len(fragments) + 1, 'unit_id': unit['unit_id'],
|
|
101
|
+
'role': unit['role'], 'text': match.group(),
|
|
102
|
+
'start': match.start(), 'end': match.end(),
|
|
103
|
+
'context': unit.get('section_path', []),
|
|
104
|
+
'timestamp': unit.get('timestamp')})
|
|
105
|
+
return {'fragments': fragments, 'catalog': data['local_memory_catalog'],
|
|
106
|
+
'scope_context': data['scope_background'], 'scope_registry': data['scope_registry']}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
RETENTION_GUIDANCE = """retention 独立于 fact/todo 类型:reusable 表示今后仍需使用的业务状态、约定、稳定环境或可复用结论;session 表示仅说明本轮如何查询、执行和恢复的过程及瞬时结果。一次操作成功或失败不自动建立稳定结论;若已明确形成持续问题、后续任务或通用经验,保留其成立的核心含义。session 由 Core 转为 no_memory,不写入长期记忆。"""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
FRAGMENT_SYSTEM = f'''根据底层长期有效的业务含义提炼记忆,不继承原文的标题、紧急程度、列表分类或建议处理方式。
|
|
113
|
+
{RETENTION_GUIDANCE}
|
|
114
|
+
返回 JSON:{{"memories":[],"no_memory":[],"deferred":[]}}。
|
|
115
|
+
每条 memory:{{"retention":"reusable 或 session","title":"简短主题","body":"脱离本轮对话仍有价值的核心内容","scope":"project:项目名 或 global","evidence":[片段ID]}}。type 默认 fact,表示业务事实或状态;可选类型 preference、project、todo、event、identity、other,event 仅用于事件本身而非其携带的业务事实。一条一个独立主题与归属,scope 是核心事实实际所属项目,不是报告该信息的系统。同一段的独立主题分别提炼;还支持 domain:名称、portfolio:名称、unscoped。
|
|
116
|
+
新 todo 额外提供 task_basis:[用户角色片段ID],其内容须明确建立用户自己承担的未完成动作;他方请求或助手建议本身是事实依据,不是用户任务依据。todo 可选 status(active/completed/cancelled)、completed_at、due_date(原文明确属于该动作的截止日期)。日期保留原文写法,由 Core 解析相对日期。
|
|
117
|
+
no_memory 填仅服务本轮交互、没有后续使用价值的片段ID;deferred 填语义尚无法确定的片段ID。每个片段须被 memory 引用或列入其中一个数组。同片段允许支持多条 memory。若与 catalog 中已有记忆是同一主题,可填 target 为其真实 ID;否则省略。无需输出写入决策、生成ID或复制原文。'''
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def expand_fragments(raw: str, fragments: list[dict[str, Any]]) -> str:
|
|
121
|
+
"""Bind short references exactly and compile fragment coverage to unit coverage."""
|
|
122
|
+
value = parse_strict_json(raw)
|
|
123
|
+
if not isinstance(value, dict) or set(value) != {'memories', 'no_memory', 'deferred'}:
|
|
124
|
+
raise _invalid()
|
|
125
|
+
if any(not isinstance(value[k], list) for k in value):
|
|
126
|
+
raise _invalid()
|
|
127
|
+
by_id = {f['id']: f for f in fragments}
|
|
128
|
+
seen = set()
|
|
129
|
+
claimed_units = set()
|
|
130
|
+
def resolve(ids):
|
|
131
|
+
if not isinstance(ids, list) or any(type(i) is not int or i not in by_id for i in ids):
|
|
132
|
+
raise _invalid('invalid_evidence')
|
|
133
|
+
seen.update(ids)
|
|
134
|
+
return [by_id[i] for i in ids]
|
|
135
|
+
rows = []
|
|
136
|
+
session_refs = []
|
|
137
|
+
for row in value['memories']:
|
|
138
|
+
if not isinstance(row, dict):
|
|
139
|
+
raise _invalid()
|
|
140
|
+
refs = resolve(row.get('evidence'))
|
|
141
|
+
if not refs:
|
|
142
|
+
raise _invalid('invalid_evidence')
|
|
143
|
+
retention = row.get('retention', 'reusable')
|
|
144
|
+
if retention not in ('reusable', 'session'):
|
|
145
|
+
raise _invalid()
|
|
146
|
+
if retention == 'session':
|
|
147
|
+
if 'task_basis' in row:
|
|
148
|
+
session_refs.extend(resolve(row['task_basis']))
|
|
149
|
+
session_refs.extend(refs)
|
|
150
|
+
continue
|
|
151
|
+
claimed_units.update(f['unit_id'] for f in refs)
|
|
152
|
+
row = {k: v for k, v in row.items() if k != 'retention'}
|
|
153
|
+
result = {**row, 'evidence': [{'unit_id': f['unit_id'], 'quote': f['text'], 'start': f['start'], 'end': f['end']} for f in refs]}
|
|
154
|
+
if 'task_basis' in row:
|
|
155
|
+
bases = resolve(row['task_basis'])
|
|
156
|
+
if not bases:
|
|
157
|
+
raise _invalid('invalid_evidence')
|
|
158
|
+
# Task basis is itself an explicit source citation. Bind it too.
|
|
159
|
+
for f in bases:
|
|
160
|
+
if f['id'] not in row['evidence']:
|
|
161
|
+
result['evidence'].append({'unit_id': f['unit_id'], 'quote': f['text'], 'start': f['start'], 'end': f['end']})
|
|
162
|
+
claimed_units.add(f['unit_id'])
|
|
163
|
+
# Keep one concrete admitted basis for the ownership reviewer.
|
|
164
|
+
result['task_basis'] = {'unit_id': bases[0]['unit_id'], 'quote': bases[0]['text']}
|
|
165
|
+
rows.append(result)
|
|
166
|
+
ignored = resolve(value['no_memory']) + session_refs
|
|
167
|
+
deferred = resolve(value['deferred'])
|
|
168
|
+
if seen != set(by_id):
|
|
169
|
+
raise _invalid('invalid_evidence')
|
|
170
|
+
deferred_units = {f['unit_id'] for f in deferred}
|
|
171
|
+
# A partial unit with an unresolved topic remains unresolved even if its
|
|
172
|
+
# other topic was retained. No-memory is terminal only for unclaimed units.
|
|
173
|
+
return json.dumps({'memories': rows,
|
|
174
|
+
'no_memory': list(dict.fromkeys(f['unit_id'] for f in ignored
|
|
175
|
+
if f['unit_id'] not in claimed_units | deferred_units)),
|
|
176
|
+
'deferred': list(dict.fromkeys(f['unit_id'] for f in deferred))}, ensure_ascii=False)
|
|
177
|
+
|
|
178
|
+
def _independent_project_subjects(text: str, scope_registry: Mapping[str, Any] | None) -> set[str]:
|
|
179
|
+
"""Conservative structural guard, not a project-name classifier.
|
|
180
|
+
|
|
181
|
+
Explicit labels and registered subjects in separate clauses are enough to
|
|
182
|
+
prove a multi-project aggregate. Merely mentioning a dependency/vendor in
|
|
183
|
+
one relational clause does not imply independent ownership.
|
|
184
|
+
"""
|
|
185
|
+
import re
|
|
186
|
+
from .process_common import _explicit_project_scope_labels
|
|
187
|
+
from .scope_state import project_scope_matches_text
|
|
188
|
+
registry = scope_registry if isinstance(scope_registry, Mapping) else {}
|
|
189
|
+
labels: set[str] = set()
|
|
190
|
+
subjects: set[str] = set()
|
|
191
|
+
for clause in re.split(r"[。!?!?;;\n、]+", text):
|
|
192
|
+
clause = clause.strip(" -*•0123456789.()()")
|
|
193
|
+
clause_labels = _explicit_project_scope_labels([clause], registry)
|
|
194
|
+
# Multiple names within one relationship clause are not proof of
|
|
195
|
+
# independent topics. Separate explicitly labelled clauses are.
|
|
196
|
+
if len(clause_labels) == 1:
|
|
197
|
+
labels.update(clause_labels)
|
|
198
|
+
matches = project_scope_matches_text(clause, {"scopes": registry})
|
|
199
|
+
for scope in matches:
|
|
200
|
+
node = registry.get(scope, {})
|
|
201
|
+
terms = [scope.partition(":")[2]]
|
|
202
|
+
if isinstance(node, Mapping):
|
|
203
|
+
terms += [v for v in node.get("aliases", []) if isinstance(v, str)]
|
|
204
|
+
if any(clause.casefold().startswith(term.casefold()) for term in terms if term):
|
|
205
|
+
subjects.add(scope)
|
|
206
|
+
return labels | subjects
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
TOPIC_SYSTEM = RETENTION_GUIDANCE + "\n" + '''根据底层长期有效的业务含义识别值得保留的独立主题,不继承原文标题、紧急程度、列表分类或建议处理方式。
|
|
211
|
+
这一阶段只选择有后续价值的主题及其证据,不写记忆正文,不分类,不处理日期,不决定数据库操作。
|
|
212
|
+
返回 JSON {"topics":[{"retention":"reusable 或 session","scope":"project:项目名 或 global","evidence":[片段ID]}],"no_memory":[片段ID],"deferred":[片段ID]}。
|
|
213
|
+
每个独立主题单独列出,scope 表示主题真正所属的项目,系统/工具名不自动成为归属。其他合法 scope:domain:名称、portfolio:名称、unscoped。
|
|
214
|
+
no_memory 表示仅服务本轮交互的操作过程或瞬时信息,没有长期业务含义;deferred 表示语义无法确定。覆盖所有片段,每个被一个或多个主题引用或列入一个数组。'''
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def compile_topics(raw: str, fragments: list[dict[str, Any]], protocol_version: str):
|
|
218
|
+
value = parse_strict_json(raw)
|
|
219
|
+
if not isinstance(value, dict) or set(value) != {'topics', 'no_memory', 'deferred'} or not isinstance(value['topics'], list):
|
|
220
|
+
raise _invalid()
|
|
221
|
+
rows = []
|
|
222
|
+
scopes = []
|
|
223
|
+
for topic in value['topics']:
|
|
224
|
+
if not isinstance(topic, dict) or not {'scope', 'evidence'} <= set(topic) or set(topic) - {'scope', 'evidence', 'retention'} or not isinstance(topic['scope'], str):
|
|
225
|
+
raise _invalid()
|
|
226
|
+
if topic.get('retention', 'reusable') != 'session':
|
|
227
|
+
scopes.append(topic['scope'])
|
|
228
|
+
rows.append({'title':'Pending topic','body':'Pending semantic extraction.', **topic})
|
|
229
|
+
expanded = expand_fragments(json.dumps({'memories':rows,'no_memory':value['no_memory'],
|
|
230
|
+
'deferred':value['deferred']}), fragments)
|
|
231
|
+
envelope, _ = compile_semantic(expanded,protocol_version=protocol_version,local_by_key={})
|
|
232
|
+
for item in envelope['items']:
|
|
233
|
+
if item['decision']=='CREATE':
|
|
234
|
+
for key in ('type','scopes','memory'):
|
|
235
|
+
item.pop(key,None)
|
|
236
|
+
item.update(decision='DEFERRED',reason='maintenance_uncertain')
|
|
237
|
+
return envelope, scopes
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
"""Production-shaped automatic planner for the B3 single-pass experiment.
|
|
2
2
|
|
|
3
3
|
This class intentionally reuses the existing MemoryPlanner request identity,
|
|
4
|
-
retry ledger, Core validation and writer contracts while
|
|
5
|
-
automatic Gate -> reconciliation -> Summary -> semantic-review chain with one
|
|
6
|
-
semantic model call. Explicit remember remains delegated until its dedicated
|
|
4
|
+
retry ledger, Core validation and writer contracts while compiling a compact topic-selection and synthesis protocol into B3. Explicit remember remains delegated until its dedicated
|
|
7
5
|
B3 contract is validated.
|
|
8
6
|
"""
|
|
9
7
|
from __future__ import annotations
|
|
@@ -34,8 +32,9 @@ from .process_common import (
|
|
|
34
32
|
)
|
|
35
33
|
from .single_pass_plan import run_single_pass_stage
|
|
36
34
|
from .turn_plan import dedup_digest, revision_digest
|
|
37
|
-
from .validation import ModelOutputError, parse_summarize_output
|
|
35
|
+
from .validation import ModelOutputError, parse_summarize_output, calendar_tokens
|
|
38
36
|
from .evidence_syntax import _assistant_intent_only
|
|
37
|
+
from .semantic_protocol import _independent_project_subjects
|
|
39
38
|
from .llm import ModelUnavailable
|
|
40
39
|
|
|
41
40
|
|
|
@@ -108,7 +107,7 @@ def _global_scope_conflicts_with_candidate_evidence(
|
|
|
108
107
|
|
|
109
108
|
|
|
110
109
|
class SinglePassMemoryPlanner(MemoryPlanner):
|
|
111
|
-
"""
|
|
110
|
+
"""Bounded topic selection and synthesis for an ordinary automatic turn."""
|
|
112
111
|
|
|
113
112
|
@staticmethod
|
|
114
113
|
def _fallback_scopes(scope_background: Any) -> tuple[list[str], str]:
|
|
@@ -511,6 +510,12 @@ class SinglePassMemoryPlanner(MemoryPlanner):
|
|
|
511
510
|
if decision == "UPDATE" and isinstance(target_id, str):
|
|
512
511
|
candidate["update_memory_id"] = target_id
|
|
513
512
|
|
|
513
|
+
subjects = _independent_project_subjects(str(proposed.get("body", "")), validation_scope_registry)
|
|
514
|
+
if len(subjects) > 1:
|
|
515
|
+
raise ModelOutputError(
|
|
516
|
+
"memory combines independently owned project subjects",
|
|
517
|
+
validation_detail="scope_drift",
|
|
518
|
+
)
|
|
514
519
|
candidate_evidence = _claim_date_evidence(claims, by_unit, events)
|
|
515
520
|
if _global_scope_conflicts_with_candidate_evidence(
|
|
516
521
|
scopes, candidate_evidence, by_unit, validation_scope_registry
|
|
@@ -569,6 +574,13 @@ class SinglePassMemoryPlanner(MemoryPlanner):
|
|
|
569
574
|
deadline_dates = _grounded_deadline_dates(candidate_date_evidence)
|
|
570
575
|
grounded_dates.update(deadline_dates)
|
|
571
576
|
summary = dict(proposed)
|
|
577
|
+
# The model need not calculate a calendar year. Resolve a yearless
|
|
578
|
+
# proposed deadline only against the admitted deadline set.
|
|
579
|
+
due_tokens = calendar_tokens(summary.get("due_date", ""))
|
|
580
|
+
if len(due_tokens) == 1 and not due_tokens[0].has_year:
|
|
581
|
+
matches = [date for date in deadline_dates if date[5:] == due_tokens[0].monthday]
|
|
582
|
+
if len(matches) == 1:
|
|
583
|
+
summary["due_date"] = matches[0]
|
|
572
584
|
if decision == "UPDATE" and target_memory is not None:
|
|
573
585
|
summary.setdefault("title", target_memory.title)
|
|
574
586
|
summary.setdefault("tags", list(target_memory.tags))
|
|
@@ -608,7 +620,7 @@ class SinglePassMemoryPlanner(MemoryPlanner):
|
|
|
608
620
|
allow_no_change=False,
|
|
609
621
|
allow_update_target=target_memory is not None,
|
|
610
622
|
)
|
|
611
|
-
|
|
623
|
+
date_violations = _summary_date_grounding_violations(
|
|
612
624
|
parsed,
|
|
613
625
|
grounded_dates=grounded_dates,
|
|
614
626
|
source_texts=[
|
|
@@ -619,11 +631,19 @@ class SinglePassMemoryPlanner(MemoryPlanner):
|
|
|
619
631
|
target_memory.body,
|
|
620
632
|
target_memory.due_date,
|
|
621
633
|
) if target_memory is not None else (),
|
|
622
|
-
)
|
|
623
|
-
|
|
634
|
+
)
|
|
635
|
+
if date_violations:
|
|
636
|
+
error = ModelOutputError(
|
|
624
637
|
"B3 memory contains a date absent from admitted evidence",
|
|
625
638
|
validation_detail="relative_time",
|
|
626
639
|
)
|
|
640
|
+
# Only literal calendar tokens and program-defined field names
|
|
641
|
+
# enter diagnostics; never the rejected sentence or body.
|
|
642
|
+
fields = [field for field in ("title", "body")
|
|
643
|
+
if any((token.canonical or token.raw) in date_violations
|
|
644
|
+
for token in calendar_tokens(parsed.get(field, "")))]
|
|
645
|
+
error.date_diagnostics = {"date_fields": fields, "dates": list(date_violations)}
|
|
646
|
+
raise error
|
|
627
647
|
validated_candidates[candidate_id] = candidate
|
|
628
648
|
return parsed
|
|
629
649
|
|
|
@@ -765,6 +785,9 @@ class SinglePassMemoryPlanner(MemoryPlanner):
|
|
|
765
785
|
"scope_source": target_memory.scope_source if target_memory is not None else fallback_scope_source,
|
|
766
786
|
"evidence_unit_ids": unit_ids,
|
|
767
787
|
}
|
|
788
|
+
diagnostics = result.get("_defer_diagnostics", {}).get(candidate_id)
|
|
789
|
+
if isinstance(diagnostics, Mapping):
|
|
790
|
+
candidate["validation_diagnostics"] = dict(diagnostics)
|
|
768
791
|
if decision == "NO_CHANGE":
|
|
769
792
|
self.audit._record_disposition(
|
|
770
793
|
turn_ref,
|