memleaf 0.2.26__tar.gz → 0.2.27__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.26 → memleaf-0.2.27}/CHANGELOG.md +9 -0
- {memleaf-0.2.26/src/memleaf.egg-info → memleaf-0.2.27}/PKG-INFO +27 -5
- {memleaf-0.2.26 → memleaf-0.2.27}/README.en.md +26 -4
- {memleaf-0.2.26 → memleaf-0.2.27}/README.md +26 -4
- {memleaf-0.2.26 → memleaf-0.2.27}/docs/general-processing.md +1 -1
- {memleaf-0.2.26 → memleaf-0.2.27}/pyproject.toml +1 -1
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/compaction.py +93 -64
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/config.py +18 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/hermes_provider/__init__.py +0 -82
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/mcp_server.py +2 -1
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/memory_planner.py +19 -75
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/memory_writer.py +22 -16
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/planning_context.py +11 -136
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/process_common.py +0 -18
- memleaf-0.2.27/src/memleaf/prompts.py +368 -0
- memleaf-0.2.27/src/memleaf/retention.py +192 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/service.py +39 -2
- memleaf-0.2.27/src/memleaf/source_policy.py +125 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/validation.py +2 -410
- {memleaf-0.2.26 → memleaf-0.2.27/src/memleaf.egg-info}/PKG-INFO +27 -5
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf.egg-info/SOURCES.txt +3 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_admission_noise.py +26 -42
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_cross_turn_dedupe_regressions.py +5 -9
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_extraction_quality_regressions.py +23 -99
- memleaf-0.2.27/tests/test_long_run_hygiene.py +179 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_model_owned_fields.py +11 -7
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_b1.py +63 -105
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_b2a.py +3 -4
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_b3a_commit.py +1 -1
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_b3d_scope_maintenance.py +9 -10
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_v023_scope_correction.py +17 -8
- memleaf-0.2.26/src/memleaf/prompts.py +0 -681
- {memleaf-0.2.26 → memleaf-0.2.27}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/LICENSE +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/MANIFEST.in +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/docs/core-refactor.md +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/docs/evidence-retention.md +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/docs/hermes-mcp-runtime.md +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/docs/v0.2.26-processing-status.md +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/examples/README.md +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/examples/basic_usage.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/examples/live_processing_acceptance.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/install.ps1 +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/install.sh +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/setup.cfg +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/admission.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/evidence_policy.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/hermes_runtime.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/index.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/inspection.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/memory_commit.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/model_execution.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/models.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/native_registration.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/process_journal.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/process_owner.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/processing.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/provenance.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/recording_policy.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/turn_audit.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/turn_plan.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/update_coordinator.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/__init__.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/semantic_fixtures.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_email_actionable_coverage.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_evidence_retention_policy.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_general_evidence_admission.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_general_tool_provenance.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_global_todo_acceptance.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_global_todo_query_no_write.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_global_todo_retrieval.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_hermes_native_registration.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_hermes_provider.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_hermes_runtime_install.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_host_events.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_install.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_maintenance_v2.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_phase2_model_decisions.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_process_owner_locking.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_processing_contract_v026.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_pypi_install.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_shared_memory_refactor.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_c1_mcp.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_stage_c3_packaging.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_todo_state_recovery.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_update_target_recovery.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_v2_nomatch_semantics.py +0 -0
- {memleaf-0.2.26 → memleaf-0.2.27}/tests/test_v2_search_gate_acceptance.py +0 -0
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.27 — 2026-09-05
|
|
6
|
+
|
|
7
|
+
- Remove application-, document-, tool- and business-specific semantic classifiers from the Core admission/target path. The model owns future-use and atomicity judgments; Core retains source-neutral evidence, Scope, type, target, date, revision and conflict validation.
|
|
8
|
+
- Bound per-memory provenance to 16 retained source rows while preserving cumulative source count/digest metadata, preventing repeated UPDATEs and copied history versions from growing `sources` without limit.
|
|
9
|
+
- Retire completed/cancelled todos from active `knowledge/` after a configurable grace period (30 days by default) while keeping them queryable through historical todo enumeration.
|
|
10
|
+
- Add bounded history retention (`3650` days and `32` complete versions per stable identity by default) with an explicit `keep_all` opt-out for audit-oriented Vaults.
|
|
11
|
+
- Preserve an existing canonical memory identity during compaction instead of generating `mem-compact-*` IDs; rollback remains journaled and crash-safe.
|
|
12
|
+
- Keep Markdown as the source of truth, retain zero runtime dependencies and no daemon, and preserve Hermes/Codex shared-Vault retrieval and write contracts.
|
|
13
|
+
|
|
5
14
|
## 0.2.26 — 2026-09-05
|
|
6
15
|
|
|
7
16
|
- Refactor automatic processing into explicit model execution, planning, update coordination, commit, journal and audit owners while preserving Markdown Vaults, memory IDs, Hermes MemoryProvider and cross-Agent retrieval contracts. No runtime dependency or independent service is added.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.27
|
|
4
4
|
Summary: A local-first Markdown memory core for AI agents
|
|
5
5
|
Author: memleaf contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -23,8 +23,8 @@ 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
|
-
> 核心库、Vault、stdio MCP Server、初始化 CLI
|
|
26
|
+
> **版本:0.2.27。**
|
|
27
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。本版收口长期运行治理:Core 语义判断保持 source-neutral,并为来源、关闭待办、历史版本和压缩身份增加有界生命周期。真实模型语义效果仍需结合本地模型和代表性样本验收。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
30
30
|
## 项目定位
|
|
@@ -417,6 +417,28 @@ $HOME/.memleaf/
|
|
|
417
417
|
|
|
418
418
|
`knowledge/` 和 `history/` 是可读数据;`_index/` 主要是派生索引或运行状态。删除或直接修改 `_index/` 可能丢失处理水位、Hook 游标或当前检索账本;需要重建时优先使用 `rebuild_index()`。
|
|
419
419
|
|
|
420
|
+
### 长期运行与保留策略
|
|
421
|
+
|
|
422
|
+
默认配置对长期增长做三层治理,而不引入后台服务:
|
|
423
|
+
|
|
424
|
+
```yaml
|
|
425
|
+
process:
|
|
426
|
+
memory_compact_threshold_tokens: 100000
|
|
427
|
+
memory_compact_candidate_ratio: 0.30
|
|
428
|
+
inbox_cleanup_hours: 24
|
|
429
|
+
closed_todo_retention_days: 30
|
|
430
|
+
history:
|
|
431
|
+
policy: bounded
|
|
432
|
+
retention_days: 3650
|
|
433
|
+
max_versions_per_memory: 32
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
- active Markdown 每条最多保留 16 个来源明细,同时记录累计 `source_count`、`source_digest` 和省略数量,避免 UPDATE 让 `sources` 无限膨胀;
|
|
437
|
+
- 已完成/已取消 todo 默认在 30 天后退出 `knowledge/` 并进入 `history/`;`list_todos(status=completed|cancelled|all)` 仍可枚举这些历史待办;
|
|
438
|
+
- `history.policy: bounded` 默认每个稳定记忆身份最多保留 32 个完整历史版本,且超过 3650 天的版本可被维护流程淘汰;需要永久审计时可显式设置 `history.policy: keep_all`;
|
|
439
|
+
- compaction 会保留一个既有 canonical `memory_id`,不会再为整理结果创建新的 `mem-compact-*` 身份;单条精简保持原 ID,多条合并选择一个稳定 survivor。
|
|
440
|
+
- 这些维护动作在正常 `process()` / `remember()` / `compact()` 生命周期中执行,不需要 daemon;普通只读检索本身不触发维护写入。
|
|
441
|
+
|
|
420
442
|
默认目录尽量使用 `0700`,文件默认明文保存。memleaf 没有内置加密层,请自行保护 Vault、备份和模型调用凭证。
|
|
421
443
|
|
|
422
444
|
## 隐私与安全边界
|
|
@@ -457,7 +479,7 @@ python -m build --wheel --sdist
|
|
|
457
479
|
- 没有模型路由时只能捕获和检索,不能完成自动提炼、显式记忆或压缩;
|
|
458
480
|
- 真实宿主长期运行效果仍取决于本机 Agent 版本、配置、重启和模型可用性;
|
|
459
481
|
- 不提供 Obsidian 插件、Web 管理界面、云同步或透明加密层;
|
|
460
|
-
-
|
|
482
|
+
- 普通只读检索不会触发保留策略写入;历史淘汰和关闭待办退休只在正常处理/维护生命周期中执行。
|
|
461
483
|
|
|
462
484
|
|
|
463
485
|
## License
|
|
@@ -468,7 +490,7 @@ MIT,见 [LICENSE](LICENSE)。
|
|
|
468
490
|
*Your memories, in files you own.*
|
|
469
491
|
|
|
470
492
|
|
|
471
|
-
## 通用处理与只读验收(0.2.
|
|
493
|
+
## 通用处理与只读验收(0.2.27)
|
|
472
494
|
|
|
473
495
|
邮件、日历、工单、文件、浏览器与普通对话共用证据准入、覆盖检查和写入路径。
|
|
474
496
|
自动摘要只能使用获准引用的原文;助手复述和旧记忆回读不能单独授权新增写入。
|
|
@@ -4,8 +4,8 @@
|
|
|
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
|
-
> The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. This
|
|
7
|
+
> **Version: 0.2.27.**
|
|
8
|
+
> The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. This release closes long-run lifecycle gaps: Core semantic decisions remain source-neutral, while provenance, closed todos, history versions, and compaction identity are bounded. Real-model semantics still require local acceptance with the selected model and representative inputs.
|
|
9
9
|
> **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
|
|
10
10
|
|
|
11
11
|
## Project scope
|
|
@@ -400,6 +400,28 @@ $HOME/.memleaf/
|
|
|
400
400
|
|
|
401
401
|
`knowledge/` and `history/` are human-readable data. `_index/` contains derived indexes and runtime state. Deleting or editing it directly can lose processing watermarks, host cursors, or the current retrieval ledger; use `rebuild_index()` when a rebuild is needed.
|
|
402
402
|
|
|
403
|
+
### Long-run retention
|
|
404
|
+
|
|
405
|
+
The default configuration bounds long-run growth without adding a background service:
|
|
406
|
+
|
|
407
|
+
```yaml
|
|
408
|
+
process:
|
|
409
|
+
memory_compact_threshold_tokens: 100000
|
|
410
|
+
memory_compact_candidate_ratio: 0.30
|
|
411
|
+
inbox_cleanup_hours: 24
|
|
412
|
+
closed_todo_retention_days: 30
|
|
413
|
+
history:
|
|
414
|
+
policy: bounded
|
|
415
|
+
retention_days: 3650
|
|
416
|
+
max_versions_per_memory: 32
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
- Each active Markdown memory retains at most 16 detailed provenance rows while tracking cumulative `source_count`, `source_digest`, and omitted rows, so repeated UPDATEs cannot grow `sources` without bound.
|
|
420
|
+
- Completed/cancelled todos leave `knowledge/` after 30 days by default and move to `history/`; `list_todos(status=completed|cancelled|all)` can still enumerate retired todos.
|
|
421
|
+
- With `history.policy: bounded`, each stable memory identity keeps at most 32 full historical versions, and versions older than 3650 days are eligible for pruning. Set `history.policy: keep_all` explicitly when permanent audit retention is required.
|
|
422
|
+
- Compaction preserves an existing canonical `memory_id` instead of creating a new `mem-compact-*` identity; a single-memory rewrite keeps its ID and a multi-memory merge chooses one stable survivor.
|
|
423
|
+
- Maintenance runs through normal `process()` / `remember()` / `compact()` lifecycle calls and requires no daemon. Ordinary read-only retrieval does not trigger maintenance writes.
|
|
424
|
+
|
|
403
425
|
Directories are normally created with mode `0700`, and files are stored as plaintext by default. memleaf has no built-in encryption layer; protect the Vault, backups, and model credentials yourself.
|
|
404
426
|
|
|
405
427
|
## Privacy and security boundaries
|
|
@@ -440,7 +462,7 @@ The following should not be interpreted as delivered capabilities:
|
|
|
440
462
|
- Without a model route, memleaf can capture and retrieve but cannot perform automatic extraction, explicit model-backed memory, or compaction.
|
|
441
463
|
- Long-running real-host behavior still depends on the local Agent version, configuration, restart, and model availability.
|
|
442
464
|
- There is no Obsidian plugin, web management UI, cloud sync, or transparent encryption layer.
|
|
443
|
-
- Routine retrieval never performs
|
|
465
|
+
- Routine read-only retrieval never performs retention writes; history pruning and closed-todo retirement run only during normal processing/maintenance lifecycle calls.
|
|
444
466
|
|
|
445
467
|
|
|
446
468
|
## License
|
|
@@ -452,7 +474,7 @@ MIT; see [LICENSE](LICENSE).
|
|
|
452
474
|
*Your memories, in files you own.*
|
|
453
475
|
|
|
454
476
|
|
|
455
|
-
## General processing and read-only inspection (0.2.
|
|
477
|
+
## General processing and read-only inspection (0.2.27)
|
|
456
478
|
|
|
457
479
|
Dialogue, calendars, tickets, files, web results and other tools share the evidence, coverage and write path.
|
|
458
480
|
Models interpret semantics; Core validates physical provenance and exact original quotations.
|
|
@@ -4,8 +4,8 @@
|
|
|
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
|
-
> 核心库、Vault、stdio MCP Server、初始化 CLI
|
|
7
|
+
> **版本:0.2.27。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。本版收口长期运行治理:Core 语义判断保持 source-neutral,并为来源、关闭待办、历史版本和压缩身份增加有界生命周期。真实模型语义效果仍需结合本地模型和代表性样本验收。
|
|
9
9
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
10
10
|
|
|
11
11
|
## 项目定位
|
|
@@ -398,6 +398,28 @@ $HOME/.memleaf/
|
|
|
398
398
|
|
|
399
399
|
`knowledge/` 和 `history/` 是可读数据;`_index/` 主要是派生索引或运行状态。删除或直接修改 `_index/` 可能丢失处理水位、Hook 游标或当前检索账本;需要重建时优先使用 `rebuild_index()`。
|
|
400
400
|
|
|
401
|
+
### 长期运行与保留策略
|
|
402
|
+
|
|
403
|
+
默认配置对长期增长做三层治理,而不引入后台服务:
|
|
404
|
+
|
|
405
|
+
```yaml
|
|
406
|
+
process:
|
|
407
|
+
memory_compact_threshold_tokens: 100000
|
|
408
|
+
memory_compact_candidate_ratio: 0.30
|
|
409
|
+
inbox_cleanup_hours: 24
|
|
410
|
+
closed_todo_retention_days: 30
|
|
411
|
+
history:
|
|
412
|
+
policy: bounded
|
|
413
|
+
retention_days: 3650
|
|
414
|
+
max_versions_per_memory: 32
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
- active Markdown 每条最多保留 16 个来源明细,同时记录累计 `source_count`、`source_digest` 和省略数量,避免 UPDATE 让 `sources` 无限膨胀;
|
|
418
|
+
- 已完成/已取消 todo 默认在 30 天后退出 `knowledge/` 并进入 `history/`;`list_todos(status=completed|cancelled|all)` 仍可枚举这些历史待办;
|
|
419
|
+
- `history.policy: bounded` 默认每个稳定记忆身份最多保留 32 个完整历史版本,且超过 3650 天的版本可被维护流程淘汰;需要永久审计时可显式设置 `history.policy: keep_all`;
|
|
420
|
+
- compaction 会保留一个既有 canonical `memory_id`,不会再为整理结果创建新的 `mem-compact-*` 身份;单条精简保持原 ID,多条合并选择一个稳定 survivor。
|
|
421
|
+
- 这些维护动作在正常 `process()` / `remember()` / `compact()` 生命周期中执行,不需要 daemon;普通只读检索本身不触发维护写入。
|
|
422
|
+
|
|
401
423
|
默认目录尽量使用 `0700`,文件默认明文保存。memleaf 没有内置加密层,请自行保护 Vault、备份和模型调用凭证。
|
|
402
424
|
|
|
403
425
|
## 隐私与安全边界
|
|
@@ -438,7 +460,7 @@ python -m build --wheel --sdist
|
|
|
438
460
|
- 没有模型路由时只能捕获和检索,不能完成自动提炼、显式记忆或压缩;
|
|
439
461
|
- 真实宿主长期运行效果仍取决于本机 Agent 版本、配置、重启和模型可用性;
|
|
440
462
|
- 不提供 Obsidian 插件、Web 管理界面、云同步或透明加密层;
|
|
441
|
-
-
|
|
463
|
+
- 普通只读检索不会触发保留策略写入;历史淘汰和关闭待办退休只在正常处理/维护生命周期中执行。
|
|
442
464
|
|
|
443
465
|
|
|
444
466
|
## License
|
|
@@ -449,7 +471,7 @@ MIT,见 [LICENSE](LICENSE)。
|
|
|
449
471
|
*Your memories, in files you own.*
|
|
450
472
|
|
|
451
473
|
|
|
452
|
-
## 通用处理与只读验收(0.2.
|
|
474
|
+
## 通用处理与只读验收(0.2.27)
|
|
453
475
|
|
|
454
476
|
邮件、日历、工单、文件、浏览器与普通对话共用证据准入、覆盖检查和写入路径。
|
|
455
477
|
自动摘要只能使用获准引用的原文;助手复述和旧记忆回读不能单独授权新增写入。
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# General processing reliability contract — 0.2.
|
|
1
|
+
# General processing reliability contract — 0.2.27
|
|
2
2
|
|
|
3
3
|
This is source-neutral processing, not a mail extractor. Dialogue, documents,
|
|
4
4
|
calendars, issue trackers and terminal/tool observations use the same admission
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Local-first Markdown memory core for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
3
|
+
__version__ = "0.2.27"
|
|
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
|
|
@@ -14,6 +14,8 @@ from typing import Any, Iterable, Mapping
|
|
|
14
14
|
from .llm import CallableBackend, ModelError, ModelRouter, ModelUnavailable
|
|
15
15
|
from .locking import atomic_unlink, atomic_write_json, atomic_write_text, read_json
|
|
16
16
|
from .models import Memory, utc_now
|
|
17
|
+
from .source_policy import merge_memory_provenance, merge_sources
|
|
18
|
+
from .retention import RetentionManager, RetentionError
|
|
17
19
|
from .prompts import COMPACT_SYSTEM, compact_prompt
|
|
18
20
|
from .validation import ModelOutputError, parse_compact_output
|
|
19
21
|
from .vault import safe_component
|
|
@@ -330,31 +332,44 @@ class Compactor:
|
|
|
330
332
|
staging = self._staging_dir_unlocked(transaction_id, create=False)
|
|
331
333
|
if staging.is_symlink() or not staging.exists() or not staging.is_dir():
|
|
332
334
|
raise CompactionError("missing compaction staging")
|
|
335
|
+
replacement_hashes = {
|
|
336
|
+
entry["memory_id"]: entry["sha256"] for entry in journal["replacements"]
|
|
337
|
+
}
|
|
338
|
+
source_ids = {entry["memory_id"] for entry in journal["sources"]}
|
|
333
339
|
for entry in journal["sources"]:
|
|
334
340
|
source_path = self.service.vault.memory_path(entry["memory_id"], "knowledge")
|
|
335
341
|
if source_path.is_symlink():
|
|
336
342
|
raise CompactionError("unsafe active memory path during recovery")
|
|
343
|
+
staged_path = staging / entry["staging_file"]
|
|
344
|
+
if staged_path.is_symlink() or not staged_path.exists():
|
|
345
|
+
raise CompactionError("missing staged active memory")
|
|
346
|
+
try:
|
|
347
|
+
original = staged_path.read_text(encoding="utf-8")
|
|
348
|
+
except (OSError, UnicodeError) as error:
|
|
349
|
+
raise CompactionError("cannot read staged active memory") from error
|
|
350
|
+
if _sha256(original) != entry["sha256"]:
|
|
351
|
+
raise CompactionError("staged active memory hash mismatch")
|
|
337
352
|
if source_path.exists():
|
|
338
353
|
try:
|
|
339
354
|
current = source_path.read_text(encoding="utf-8")
|
|
340
355
|
except (OSError, UnicodeError) as error:
|
|
341
356
|
raise CompactionError("cannot read active memory during recovery") from error
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if _sha256(current) != entry["sha256"]:
|
|
357
|
+
current_hash = _sha256(current)
|
|
358
|
+
if current_hash == entry["sha256"]:
|
|
345
359
|
continue
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
atomic_write_text(source_path, original)
|
|
360
|
+
# A canonical survivor is replaced in-place. If the file is
|
|
361
|
+
# exactly our staged replacement, restore its original source.
|
|
362
|
+
if replacement_hashes.get(entry["memory_id"]) == current_hash:
|
|
363
|
+
atomic_write_text(source_path, original)
|
|
364
|
+
continue
|
|
365
|
+
# Preserve an unrelated user edit rather than overwriting it.
|
|
366
|
+
continue
|
|
367
|
+
atomic_write_text(source_path, original)
|
|
368
|
+
# Backward-compatible cleanup for pre-0.2.27 journals whose
|
|
369
|
+
# replacement ID was not one of the consumed source IDs.
|
|
357
370
|
for entry in journal["replacements"]:
|
|
371
|
+
if entry["memory_id"] in source_ids:
|
|
372
|
+
continue
|
|
358
373
|
path = self.service.vault.memory_path(entry["memory_id"], "knowledge")
|
|
359
374
|
if self._read_expected_file_unlocked(path, entry["sha256"], "compaction replacement") is not None:
|
|
360
375
|
atomic_unlink(path)
|
|
@@ -408,30 +423,26 @@ class Compactor:
|
|
|
408
423
|
return value
|
|
409
424
|
|
|
410
425
|
@staticmethod
|
|
411
|
-
def
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
digest = hashlib.sha256(
|
|
432
|
-
json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
433
|
-
).hexdigest()
|
|
434
|
-
return f"mem-compact-{digest[:24]}"
|
|
426
|
+
def _canonical_candidate(source_candidates: list[_Candidate]) -> _Candidate:
|
|
427
|
+
if not source_candidates:
|
|
428
|
+
raise CompactionError("compaction replacement has no sources")
|
|
429
|
+
# Preserve the identity with the strongest prior user/reference value.
|
|
430
|
+
# Ties choose the oldest stable identity, then lexical ID.
|
|
431
|
+
def key(candidate: _Candidate) -> tuple[int, int, datetime, str]:
|
|
432
|
+
memory = candidate.memory
|
|
433
|
+
created = _parse_time(memory.created) or datetime.max.replace(tzinfo=timezone.utc)
|
|
434
|
+
return (
|
|
435
|
+
0 if memory.extra.get("explicit_remember") is True else 1,
|
|
436
|
+
-memory.hit_count,
|
|
437
|
+
created,
|
|
438
|
+
memory.memory_id.casefold(),
|
|
439
|
+
)
|
|
440
|
+
return min(source_candidates, key=key)
|
|
441
|
+
|
|
442
|
+
@classmethod
|
|
443
|
+
def _replacement_id(cls, summary: Mapping[str, Any], source_candidates: list[_Candidate]) -> str:
|
|
444
|
+
del summary
|
|
445
|
+
return cls._canonical_candidate(source_candidates).memory.memory_id
|
|
435
446
|
|
|
436
447
|
@staticmethod
|
|
437
448
|
def _history_id(source: Memory, replacement_id: str, source_hash: str) -> str:
|
|
@@ -445,21 +456,12 @@ class Compactor:
|
|
|
445
456
|
).hexdigest()
|
|
446
457
|
return f"hist-{digest[:24]}"
|
|
447
458
|
|
|
459
|
+
|
|
448
460
|
@staticmethod
|
|
449
461
|
def _merge_sources(memories: Iterable[Memory]) -> list[dict[str, Any]]:
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
for source in memory.sources:
|
|
454
|
-
if not isinstance(source, Mapping):
|
|
455
|
-
continue
|
|
456
|
-
value = dict(source)
|
|
457
|
-
key = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
458
|
-
if key in seen:
|
|
459
|
-
continue
|
|
460
|
-
seen.add(key)
|
|
461
|
-
result.append(value)
|
|
462
|
-
return result
|
|
462
|
+
"""Compatibility projection using the shared bounded provenance policy."""
|
|
463
|
+
|
|
464
|
+
return merge_memory_provenance(memories)[0]
|
|
463
465
|
|
|
464
466
|
@staticmethod
|
|
465
467
|
def _first_created(memories: Iterable[Memory], now: str) -> str:
|
|
@@ -486,14 +488,18 @@ class Compactor:
|
|
|
486
488
|
) -> _Replacement:
|
|
487
489
|
source_memories = [candidate.memory for candidate in source_candidates]
|
|
488
490
|
source_ids = tuple(memory.memory_id for memory in source_memories)
|
|
489
|
-
|
|
491
|
+
canonical = self._canonical_candidate(source_candidates).memory
|
|
492
|
+
replacement_id = self._replacement_id(summary, source_candidates)
|
|
490
493
|
status = summary.get("status")
|
|
491
494
|
if summary["type"] == "todo" and status is None:
|
|
492
495
|
status = "active"
|
|
493
|
-
|
|
496
|
+
sources, source_metadata = merge_memory_provenance(source_memories)
|
|
497
|
+
extra = dict(canonical.extra)
|
|
498
|
+
extra.update(source_metadata)
|
|
499
|
+
extra.update({
|
|
494
500
|
"compaction_source_ids": list(source_ids),
|
|
495
501
|
"compacted_at": now,
|
|
496
|
-
}
|
|
502
|
+
})
|
|
497
503
|
if any(memory.extra.get("explicit_remember") is True for memory in source_memories):
|
|
498
504
|
extra["explicit_remember"] = True
|
|
499
505
|
memory = Memory(
|
|
@@ -506,8 +512,8 @@ class Compactor:
|
|
|
506
512
|
scope_source=summary["scope_source"],
|
|
507
513
|
aliases=list(summary["aliases"]),
|
|
508
514
|
keywords=list(summary["keywords"]),
|
|
509
|
-
sources=
|
|
510
|
-
created=
|
|
515
|
+
sources=sources,
|
|
516
|
+
created=canonical.created,
|
|
511
517
|
updated=now,
|
|
512
518
|
hit_count=sum(memory.hit_count for memory in source_memories),
|
|
513
519
|
last_hit_at=self._last_hit(source_memories),
|
|
@@ -561,8 +567,10 @@ class Compactor:
|
|
|
561
567
|
if replacement.memory.memory_id in replacement_ids:
|
|
562
568
|
raise CompactionError("duplicate compaction replacement id")
|
|
563
569
|
replacement_ids.add(replacement.memory.memory_id)
|
|
564
|
-
if replacement.memory.memory_id in
|
|
565
|
-
raise CompactionError("compaction replacement
|
|
570
|
+
if replacement.memory.memory_id not in replacement.source_ids:
|
|
571
|
+
raise CompactionError("compaction replacement must preserve one source identity")
|
|
572
|
+
if replacement.memory.memory_id not in active_ids:
|
|
573
|
+
raise CompactionError("compaction canonical survivor is not active")
|
|
566
574
|
for source_id in replacement.source_ids:
|
|
567
575
|
if source_id in consumed:
|
|
568
576
|
raise CompactionError("compaction source memory is consumed twice")
|
|
@@ -581,8 +589,8 @@ class Compactor:
|
|
|
581
589
|
raise CompactionError("compaction source is outside the candidate set")
|
|
582
590
|
for replacement in replacements:
|
|
583
591
|
path = self.service.vault.memory_path(replacement.memory.memory_id, "knowledge")
|
|
584
|
-
if path.is_symlink() or path.exists():
|
|
585
|
-
raise CompactionError("compaction
|
|
592
|
+
if path.is_symlink() or not path.exists():
|
|
593
|
+
raise CompactionError("compaction canonical survivor is unavailable")
|
|
586
594
|
|
|
587
595
|
def _build_plan(
|
|
588
596
|
self,
|
|
@@ -601,6 +609,8 @@ class Compactor:
|
|
|
601
609
|
source_candidates = [by_id[source_id.casefold()] for source_id in source_ids]
|
|
602
610
|
except KeyError as error:
|
|
603
611
|
raise CompactionError("compaction source is outside the snapshot") from error
|
|
612
|
+
if any(candidate.memory.type != summary.get("type") for candidate in source_candidates):
|
|
613
|
+
raise CompactionError("compaction cannot change memory type")
|
|
604
614
|
replacement = self._build_replacement(summary, source_candidates, now=now)
|
|
605
615
|
if replacement.replacement_tokens >= replacement.source_tokens:
|
|
606
616
|
raise CompactionError("compaction replacement is not smaller than its sources")
|
|
@@ -616,6 +626,8 @@ class Compactor:
|
|
|
616
626
|
) -> tuple[str, Memory, str]:
|
|
617
627
|
history_id = self._history_id(source.memory, replacement.memory.memory_id, source.raw_hash)
|
|
618
628
|
extra = dict(source.memory.extra)
|
|
629
|
+
history_sources, source_metadata = merge_sources([], source.memory.sources, extra=extra)
|
|
630
|
+
extra.update(source_metadata)
|
|
619
631
|
extra.update(
|
|
620
632
|
{
|
|
621
633
|
"reason": "compaction",
|
|
@@ -637,7 +649,7 @@ class Compactor:
|
|
|
637
649
|
scope_source=source.memory.scope_source,
|
|
638
650
|
aliases=list(source.memory.aliases),
|
|
639
651
|
keywords=list(source.memory.keywords),
|
|
640
|
-
sources=
|
|
652
|
+
sources=history_sources,
|
|
641
653
|
created=source.memory.created,
|
|
642
654
|
updated=source.memory.updated,
|
|
643
655
|
hit_count=source.memory.hit_count,
|
|
@@ -741,12 +753,22 @@ class Compactor:
|
|
|
741
753
|
self._write_journal_unlocked(journal)
|
|
742
754
|
for replacement in replacements:
|
|
743
755
|
path = self.service.vault.memory_path(replacement.memory.memory_id, "knowledge")
|
|
744
|
-
if path.
|
|
745
|
-
raise CompactionError("compaction
|
|
756
|
+
if path.is_symlink() or not path.exists():
|
|
757
|
+
raise CompactionError("compaction canonical survivor disappeared")
|
|
758
|
+
canonical_source = selected_by_id[replacement.memory.memory_id]
|
|
759
|
+
try:
|
|
760
|
+
current = path.read_text(encoding="utf-8")
|
|
761
|
+
except (OSError, UnicodeError) as error:
|
|
762
|
+
raise CompactionError("cannot read compaction canonical survivor") from error
|
|
763
|
+
if _sha256(current) != canonical_source.raw_hash:
|
|
764
|
+
raise CompactionError("compaction canonical survivor changed before replacement")
|
|
746
765
|
atomic_write_text(path, replacement.raw)
|
|
747
766
|
journal = dict(journal, phase="replacements")
|
|
748
767
|
self._write_journal_unlocked(journal)
|
|
768
|
+
survivor_ids = {replacement.memory.memory_id for replacement in replacements}
|
|
749
769
|
for source in consumed_candidates:
|
|
770
|
+
if source.memory.memory_id in survivor_ids:
|
|
771
|
+
continue
|
|
750
772
|
if source.path.is_symlink():
|
|
751
773
|
raise CompactionError("unsafe active memory path")
|
|
752
774
|
try:
|
|
@@ -792,8 +814,16 @@ class Compactor:
|
|
|
792
814
|
|
|
793
815
|
def _run(self, *, model: Any = None, router: Any = None, explicit: bool) -> dict[str, Any]:
|
|
794
816
|
threshold, ratio = self._config()
|
|
817
|
+
now = _clock_now(getattr(self.service, "clock", None))
|
|
818
|
+
try:
|
|
819
|
+
retention = RetentionManager(self.service).maintain(now)
|
|
820
|
+
except RetentionError as error:
|
|
821
|
+
if explicit:
|
|
822
|
+
raise CompactionError("retention maintenance failed") from error
|
|
823
|
+
return {"status": "failed", "error": "retention maintenance failed"}
|
|
795
824
|
selected, all_active, active_tokens = self._snapshot(threshold, ratio)
|
|
796
825
|
result = self._base_result(selected, all_active, active_tokens, threshold, ratio)
|
|
826
|
+
result["retention"] = retention
|
|
797
827
|
if not selected:
|
|
798
828
|
return result
|
|
799
829
|
try:
|
|
@@ -818,7 +848,6 @@ class Compactor:
|
|
|
818
848
|
if not output["memories"]:
|
|
819
849
|
result["status"] = "noop"
|
|
820
850
|
return result
|
|
821
|
-
now = _clock_now(getattr(self.service, "clock", None))
|
|
822
851
|
replacements = self._build_plan(output, selected, now=now)
|
|
823
852
|
self._preflight(selected, all_active, replacements)
|
|
824
853
|
history_ids, replacement_ids = self._commit(
|
|
@@ -43,6 +43,12 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
43
43
|
"memory_compact_threshold_tokens": 100000,
|
|
44
44
|
"memory_compact_candidate_ratio": 0.30,
|
|
45
45
|
"inbox_cleanup_hours": 24,
|
|
46
|
+
"closed_todo_retention_days": 30,
|
|
47
|
+
},
|
|
48
|
+
"history": {
|
|
49
|
+
"policy": "bounded",
|
|
50
|
+
"retention_days": 3650,
|
|
51
|
+
"max_versions_per_memory": 32,
|
|
46
52
|
},
|
|
47
53
|
"capture": {
|
|
48
54
|
"visible_messages_only": True,
|
|
@@ -118,6 +124,18 @@ def load_config(path: Path | str, *, vault: Path | str | None = None) -> dict[st
|
|
|
118
124
|
cleanup_hours = process.get("inbox_cleanup_hours") if isinstance(process, Mapping) else None
|
|
119
125
|
if type(cleanup_hours) is not int or cleanup_hours < 0:
|
|
120
126
|
raise ValueError("invalid memleaf process.inbox_cleanup_hours")
|
|
127
|
+
closed_todo_days = process.get("closed_todo_retention_days") if isinstance(process, Mapping) else None
|
|
128
|
+
if type(closed_todo_days) is not int or closed_todo_days < 0:
|
|
129
|
+
raise ValueError("invalid memleaf process.closed_todo_retention_days")
|
|
130
|
+
history = merged.get("history")
|
|
131
|
+
if not isinstance(history, Mapping):
|
|
132
|
+
raise ValueError("invalid memleaf history settings")
|
|
133
|
+
if history.get("policy") not in {"bounded", "keep_all"}:
|
|
134
|
+
raise ValueError("invalid memleaf history.policy")
|
|
135
|
+
if type(history.get("retention_days")) is not int or history["retention_days"] < 1:
|
|
136
|
+
raise ValueError("invalid memleaf history.retention_days")
|
|
137
|
+
if type(history.get("max_versions_per_memory")) is not int or history["max_versions_per_memory"] < 1:
|
|
138
|
+
raise ValueError("invalid memleaf history.max_versions_per_memory")
|
|
121
139
|
llm = merged.get("llm")
|
|
122
140
|
if not isinstance(llm, Mapping):
|
|
123
141
|
raise ValueError("invalid memleaf llm settings")
|
|
@@ -108,11 +108,6 @@ _UNTRUSTED_TOOL_RESULT_TAG = "<untrusted_tool_result"
|
|
|
108
108
|
_UNTRUSTED_TOOL_RESULT_END = "</untrusted_tool_result>"
|
|
109
109
|
|
|
110
110
|
|
|
111
|
-
_MAX_MAIL_EVIDENCE_ITEMS = 8
|
|
112
|
-
_MAIL_ADDRESS_RE = re.compile(r"(?i)([A-Z0-9._%+-]+@([A-Z0-9.-]+\.[A-Z]{2,63}))")
|
|
113
|
-
_MAIL_TOOL_RE = re.compile(r"(?:gmail|outlook|mail|email)", re.IGNORECASE)
|
|
114
|
-
|
|
115
|
-
|
|
116
111
|
class _MCPToolError(RuntimeError):
|
|
117
112
|
"""Safe structured model error returned by the local MCP boundary."""
|
|
118
113
|
|
|
@@ -1180,83 +1175,6 @@ def _resolve_command(config: Mapping[str, Any]) -> Optional[str]:
|
|
|
1180
1175
|
)
|
|
1181
1176
|
|
|
1182
1177
|
|
|
1183
|
-
def _bounded_mail_evidence(messages: Optional[List[Dict[str, Any]]]) -> list[dict[str, str]]:
|
|
1184
|
-
"""Extract only message id/subject/sender/domain from visible mail tool results."""
|
|
1185
|
-
|
|
1186
|
-
calls = _visible_tool_calls(messages)
|
|
1187
|
-
results = _visible_tool_results(messages)
|
|
1188
|
-
used: set[int] = set()
|
|
1189
|
-
output: list[dict[str, str]] = []
|
|
1190
|
-
seen: set[tuple[tuple[str, str], ...]] = set()
|
|
1191
|
-
|
|
1192
|
-
def add_record(mapping: Mapping[str, Any]) -> None:
|
|
1193
|
-
if len(output) >= _MAX_MAIL_EVIDENCE_ITEMS:
|
|
1194
|
-
return
|
|
1195
|
-
lowered = {str(key).casefold(): value for key, value in mapping.items()}
|
|
1196
|
-
message_id = next((lowered.get(key) for key in ("message_id", "messageid", "id") if isinstance(lowered.get(key), (str, int))), None)
|
|
1197
|
-
subject = next((lowered.get(key) for key in ("subject", "title") if isinstance(lowered.get(key), str)), None)
|
|
1198
|
-
sender = next((lowered.get(key) for key in ("sender", "from", "from_address", "from_email", "email") if isinstance(lowered.get(key), str)), None)
|
|
1199
|
-
domain = next((lowered.get(key) for key in ("domain", "sender_domain") if isinstance(lowered.get(key), str)), None)
|
|
1200
|
-
if isinstance(sender, str):
|
|
1201
|
-
match = _MAIL_ADDRESS_RE.search(sender)
|
|
1202
|
-
if match and not domain:
|
|
1203
|
-
domain = match.group(2)
|
|
1204
|
-
item: dict[str, str] = {}
|
|
1205
|
-
for key, value, limit in (
|
|
1206
|
-
("message_id", message_id, 160),
|
|
1207
|
-
("subject", subject, 320),
|
|
1208
|
-
("sender", sender, 320),
|
|
1209
|
-
("domain", domain, 253),
|
|
1210
|
-
):
|
|
1211
|
-
if value is None:
|
|
1212
|
-
continue
|
|
1213
|
-
text = str(value).strip()
|
|
1214
|
-
if not text or any(ch in text for ch in "\x00\r\n"):
|
|
1215
|
-
continue
|
|
1216
|
-
if key == "domain":
|
|
1217
|
-
text = text.casefold().lstrip("@")
|
|
1218
|
-
if not re.fullmatch(r"(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}", text):
|
|
1219
|
-
continue
|
|
1220
|
-
item[key] = text[:limit]
|
|
1221
|
-
if "domain" not in item:
|
|
1222
|
-
return
|
|
1223
|
-
fingerprint = tuple(sorted(item.items()))
|
|
1224
|
-
if fingerprint not in seen:
|
|
1225
|
-
seen.add(fingerprint)
|
|
1226
|
-
output.append(item)
|
|
1227
|
-
|
|
1228
|
-
def visit(value: Any, depth: int = 0) -> None:
|
|
1229
|
-
if depth > 4 or len(output) >= _MAX_MAIL_EVIDENCE_ITEMS:
|
|
1230
|
-
return
|
|
1231
|
-
decoded = _decode_hermes_tool_result(value)
|
|
1232
|
-
if decoded is _MISSING_TOOL_RESULT:
|
|
1233
|
-
return
|
|
1234
|
-
if decoded is not value:
|
|
1235
|
-
visit(decoded, depth + 1)
|
|
1236
|
-
return
|
|
1237
|
-
if isinstance(value, Mapping):
|
|
1238
|
-
add_record(value)
|
|
1239
|
-
for child in value.values():
|
|
1240
|
-
if isinstance(child, (Mapping, list, tuple)):
|
|
1241
|
-
visit(child, depth + 1)
|
|
1242
|
-
elif isinstance(value, (list, tuple)):
|
|
1243
|
-
for child in value:
|
|
1244
|
-
visit(child, depth + 1)
|
|
1245
|
-
|
|
1246
|
-
ordinal = 0
|
|
1247
|
-
for call in calls:
|
|
1248
|
-
name = call.get("name")
|
|
1249
|
-
if not isinstance(name, str) or not _MAIL_TOOL_RE.search(name):
|
|
1250
|
-
continue
|
|
1251
|
-
ordinal += 1
|
|
1252
|
-
payload = _tool_result_for_call(call, calls, results, used)
|
|
1253
|
-
if payload is not _CALL_FAILED:
|
|
1254
|
-
visit(payload)
|
|
1255
|
-
if len(output) >= _MAX_MAIL_EVIDENCE_ITEMS:
|
|
1256
|
-
break
|
|
1257
|
-
return output
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
1178
|
def _bounded_current_tool_evidence(messages: Optional[List[Dict[str, Any]]], *, vault_root: Optional[Path] = None) -> list[dict[str, str]]:
|
|
1261
1179
|
"""Match current-turn results strictly by call ID, not tool name/order.
|
|
1262
1180
|
|