wb-agent-runtime-mcp 1.0.0__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.
- wb_agent_runtime_mcp-1.0.0/PKG-INFO +38 -0
- wb_agent_runtime_mcp-1.0.0/README.md +31 -0
- wb_agent_runtime_mcp-1.0.0/launcher.py +13 -0
- wb_agent_runtime_mcp-1.0.0/pyproject.toml +18 -0
- wb_agent_runtime_mcp-1.0.0/scripts/__init__.py +2 -0
- wb_agent_runtime_mcp-1.0.0/scripts/archive.py +306 -0
- wb_agent_runtime_mcp-1.0.0/scripts/backup.py +185 -0
- wb_agent_runtime_mcp-1.0.0/scripts/cli.py +613 -0
- wb_agent_runtime_mcp-1.0.0/scripts/config.py +81 -0
- wb_agent_runtime_mcp-1.0.0/scripts/conflict_resolver.py +517 -0
- wb_agent_runtime_mcp-1.0.0/scripts/deposit.py +294 -0
- wb_agent_runtime_mcp-1.0.0/scripts/embedder.py +391 -0
- wb_agent_runtime_mcp-1.0.0/scripts/embeddings.py +145 -0
- wb_agent_runtime_mcp-1.0.0/scripts/export.py +300 -0
- wb_agent_runtime_mcp-1.0.0/scripts/graph.py +179 -0
- wb_agent_runtime_mcp-1.0.0/scripts/hardware.py +185 -0
- wb_agent_runtime_mcp-1.0.0/scripts/health.py +196 -0
- wb_agent_runtime_mcp-1.0.0/scripts/legacy.py +295 -0
- wb_agent_runtime_mcp-1.0.0/scripts/mcp_server.py +313 -0
- wb_agent_runtime_mcp-1.0.0/scripts/multimodal.py +331 -0
- wb_agent_runtime_mcp-1.0.0/scripts/narrative.py +332 -0
- wb_agent_runtime_mcp-1.0.0/scripts/orchestrator_server.py +250 -0
- wb_agent_runtime_mcp-1.0.0/scripts/rebuild_index.py +197 -0
- wb_agent_runtime_mcp-1.0.0/scripts/retrieval.py +336 -0
- wb_agent_runtime_mcp-1.0.0/scripts/setup.py +125 -0
- wb_agent_runtime_mcp-1.0.0/scripts/store.py +445 -0
- wb_agent_runtime_mcp-1.0.0/scripts/update_check.py +124 -0
- wb_agent_runtime_mcp-1.0.0/scripts/version.py +6 -0
- wb_agent_runtime_mcp-1.0.0/scripts/web_server.py +443 -0
- wb_agent_runtime_mcp-1.0.0/setup.cfg +4 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/condition_evaluator.py +403 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/dag_validator.py +532 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/error_recovery.py +318 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/flow_controller.py +669 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/hardware_detector.py +183 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/orchestrator.py +663 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/pipeline_registry.py +269 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/pipeline_reporter.py +467 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/snapshot_store.py +502 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/state_recovery.py +239 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/state_store.py +874 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/template_lib.py +305 -0
- wb_agent_runtime_mcp-1.0.0/src/scripts/update_checker.py +145 -0
- wb_agent_runtime_mcp-1.0.0/wb_agent_runtime_mcp.egg-info/PKG-INFO +38 -0
- wb_agent_runtime_mcp-1.0.0/wb_agent_runtime_mcp.egg-info/SOURCES.txt +46 -0
- wb_agent_runtime_mcp-1.0.0/wb_agent_runtime_mcp.egg-info/dependency_links.txt +1 -0
- wb_agent_runtime_mcp-1.0.0/wb_agent_runtime_mcp.egg-info/entry_points.txt +2 -0
- wb_agent_runtime_mcp-1.0.0/wb_agent_runtime_mcp.egg-info/top_level.txt +6 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wb-agent-runtime-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: 智能体本地运行时:长期记忆与知识图谱 + DAG 任务流水线编排,零密钥零依赖
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# 智能体记忆与任务编排(agent-runtime)—— 本地智能体运行时
|
|
9
|
+
|
|
10
|
+
**开发者:牛魔科技工作室**
|
|
11
|
+
|
|
12
|
+
智能体本地运行时连接器(纯本地、零密钥、零第三方依赖):
|
|
13
|
+
- **长期记忆与知识图谱**:记忆自动沉淀(事实抽取、去重、冲突仲裁)、语义与时间线检索、
|
|
14
|
+
实体关系图谱、记忆健康度审计;
|
|
15
|
+
- **DAG 任务流水线编排**:流水线验证(循环依赖/孤立节点/防注入)、拓扑执行计划、
|
|
16
|
+
逐步执行与上游输出注入、断点续传、失败登记与统一错误恢复(retry/fallback/impact)、
|
|
17
|
+
下游影响分析、Markdown 报告 + HTML 甘特图、官方预置模板(政采日报/视频分析/周报/巡检);
|
|
18
|
+
- **两者协同**:流水线节点的输入输出可沉淀为长期记忆,编排时检索历史经验。
|
|
19
|
+
|
|
20
|
+
## 工具族(16 个)
|
|
21
|
+
|
|
22
|
+
| 家族 | 工具 |
|
|
23
|
+
|------|------|
|
|
24
|
+
| 记忆 | `zwjh_query` `zwjh_deposit` `zwjh_health` |
|
|
25
|
+
| 编排 | `validate_pipeline` `plan_pipeline` `run_pipeline` `step_pipeline` `status_pipeline` `resume_pipeline` `complete_node` `fail_node` `visualize_pipeline` `impact_analysis` `hardware_detect` `pipeline_template_list` `recover_node` |
|
|
26
|
+
|
|
27
|
+
## 运行
|
|
28
|
+
|
|
29
|
+
由 WorkBuddy 按 `mcp.json` 自动拉起(`uvx wb-agent-runtime-mcp==1.0.0`)。
|
|
30
|
+
|
|
31
|
+
## 安全
|
|
32
|
+
|
|
33
|
+
- 记忆库与流水线状态全部落在本机用户目录,数据不出本机
|
|
34
|
+
- 零第三方依赖,纯 Python 标准库
|
|
35
|
+
|
|
36
|
+
## 反馈
|
|
37
|
+
|
|
38
|
+
有更好建议:njskills@agent.qq.com
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# 智能体记忆与任务编排(agent-runtime)—— 本地智能体运行时
|
|
2
|
+
|
|
3
|
+
**开发者:牛魔科技工作室**
|
|
4
|
+
|
|
5
|
+
智能体本地运行时连接器(纯本地、零密钥、零第三方依赖):
|
|
6
|
+
- **长期记忆与知识图谱**:记忆自动沉淀(事实抽取、去重、冲突仲裁)、语义与时间线检索、
|
|
7
|
+
实体关系图谱、记忆健康度审计;
|
|
8
|
+
- **DAG 任务流水线编排**:流水线验证(循环依赖/孤立节点/防注入)、拓扑执行计划、
|
|
9
|
+
逐步执行与上游输出注入、断点续传、失败登记与统一错误恢复(retry/fallback/impact)、
|
|
10
|
+
下游影响分析、Markdown 报告 + HTML 甘特图、官方预置模板(政采日报/视频分析/周报/巡检);
|
|
11
|
+
- **两者协同**:流水线节点的输入输出可沉淀为长期记忆,编排时检索历史经验。
|
|
12
|
+
|
|
13
|
+
## 工具族(16 个)
|
|
14
|
+
|
|
15
|
+
| 家族 | 工具 |
|
|
16
|
+
|------|------|
|
|
17
|
+
| 记忆 | `zwjh_query` `zwjh_deposit` `zwjh_health` |
|
|
18
|
+
| 编排 | `validate_pipeline` `plan_pipeline` `run_pipeline` `step_pipeline` `status_pipeline` `resume_pipeline` `complete_node` `fail_node` `visualize_pipeline` `impact_analysis` `hardware_detect` `pipeline_template_list` `recover_node` |
|
|
19
|
+
|
|
20
|
+
## 运行
|
|
21
|
+
|
|
22
|
+
由 WorkBuddy 按 `mcp.json` 自动拉起(`uvx wb-agent-runtime-mcp==1.0.0`)。
|
|
23
|
+
|
|
24
|
+
## 安全
|
|
25
|
+
|
|
26
|
+
- 记忆库与流水线状态全部落在本机用户目录,数据不出本机
|
|
27
|
+
- 零第三方依赖,纯 Python 标准库
|
|
28
|
+
|
|
29
|
+
## 反馈
|
|
30
|
+
|
|
31
|
+
有更好建议:njskills@agent.qq.com
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Universal launcher: run the original mcp_server.py exactly as `python mcp_server.py`."""
|
|
3
|
+
import os
|
|
4
|
+
import runpy
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
9
|
+
runpy.run_path(os.path.join(here, "mcp_server.py"), run_name="__main__")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
main()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wb-agent-runtime-mcp"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "智能体本地运行时:长期记忆与知识图谱 + DAG 任务流水线编排,零密钥零依赖"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
wb-agent-runtime-mcp = "launcher:main"
|
|
15
|
+
|
|
16
|
+
[tool.setuptools]
|
|
17
|
+
packages = {find = {where = ["."]}}
|
|
18
|
+
py-modules = ["launcher", "mcp_server"]
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
自动归档策略 —— 冷热分层 + 时间衰减,自动控制记忆膨胀。
|
|
4
|
+
|
|
5
|
+
核心算法:
|
|
6
|
+
记忆热度分 = 0.6 * ln(access_count + 1) + 0.4 * exp(-days_since_access / 30)
|
|
7
|
+
|
|
8
|
+
热 (score ≥ 0.7): 保持原样,不处理
|
|
9
|
+
温 (0.3 ≤ score < 0.7): 抽取式摘要(保留关键句),存入 archive 表
|
|
10
|
+
冷 (score < 0.3): 仅保留事实抽取结果,高压缩比归档
|
|
11
|
+
|
|
12
|
+
自动触发: autopilot 末尾自动调用 archive.run()
|
|
13
|
+
硬件感知: 批处理大小按 hardware.tier 分配
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sqlite3
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from datetime import datetime, timedelta
|
|
25
|
+
|
|
26
|
+
from . import config, embeddings, store
|
|
27
|
+
from .hardware import get_plan
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── 热度分层阈值 ─────────────────────────────────────────────────────────
|
|
31
|
+
HOT_THRESHOLD = 0.7 # ≥ 此值为热
|
|
32
|
+
WARM_THRESHOLD = 0.3 # ≥ 此值为温,< 此值为冷
|
|
33
|
+
|
|
34
|
+
# 时间衰减半衰期(天)
|
|
35
|
+
DECAY_HALF_LIFE = 30
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compute_heat(access_count: int, days_since_access: float) -> float:
|
|
39
|
+
"""
|
|
40
|
+
计算记忆热度分(0~1)。
|
|
41
|
+
|
|
42
|
+
公式:0.6 * ln(access_count + 1) + 0.4 * exp(-days / 30)
|
|
43
|
+
- access_count 越大,热度越高(对数增长,避免刷屏)
|
|
44
|
+
- days_since_access 越大,热度越低(指数衰减)
|
|
45
|
+
"""
|
|
46
|
+
freq_score = math.log(access_count + 1) # ln(1)=0, ln(2)=0.69, ln(5)=1.61
|
|
47
|
+
# 归一化到 0~1(ln(10)≈2.3 作为上限)
|
|
48
|
+
freq_normalized = min(freq_score / 2.3, 1.0)
|
|
49
|
+
decay_score = math.exp(-days_since_access / DECAY_HALF_LIFE)
|
|
50
|
+
return 0.6 * freq_normalized + 0.4 * decay_score
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def classify_tier(heat: float) -> str:
|
|
54
|
+
"""根据热度分返回 hot / warm / cold。"""
|
|
55
|
+
if heat >= HOT_THRESHOLD:
|
|
56
|
+
return "hot"
|
|
57
|
+
if heat >= WARM_THRESHOLD:
|
|
58
|
+
return "warm"
|
|
59
|
+
return "cold"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── 归档器 ─────────────────────────────────────────────────────────────────
|
|
63
|
+
class Archiver:
|
|
64
|
+
"""自动归档器。"""
|
|
65
|
+
|
|
66
|
+
def __init__(self):
|
|
67
|
+
self._ensure_archive_table()
|
|
68
|
+
|
|
69
|
+
def _ensure_archive_table(self) -> None:
|
|
70
|
+
"""确保归档表存在。"""
|
|
71
|
+
conn = store.get_conn()
|
|
72
|
+
conn.execute("""
|
|
73
|
+
CREATE TABLE IF NOT EXISTS archive (
|
|
74
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
75
|
+
memory_id INTEGER NOT NULL,
|
|
76
|
+
original_text TEXT, -- 原始文本(冷归档时保留备份)
|
|
77
|
+
compressed_text TEXT NOT NULL, -- 压缩后的文本
|
|
78
|
+
tier TEXT NOT NULL, -- hot / warm / cold
|
|
79
|
+
heat_score REAL NOT NULL,
|
|
80
|
+
archived_at TEXT NOT NULL,
|
|
81
|
+
FOREIGN KEY(memory_id) REFERENCES memories(id) ON DELETE CASCADE
|
|
82
|
+
)
|
|
83
|
+
""")
|
|
84
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_archive_memory ON archive(memory_id)")
|
|
85
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_archive_tier ON archive(tier)")
|
|
86
|
+
conn.commit()
|
|
87
|
+
|
|
88
|
+
def analyze(self) -> dict:
|
|
89
|
+
"""
|
|
90
|
+
分析所有记忆的热度分布,返回统计信息。
|
|
91
|
+
不修改任何数据。
|
|
92
|
+
"""
|
|
93
|
+
conn = store.get_conn()
|
|
94
|
+
rows = conn.execute(
|
|
95
|
+
"SELECT id, raw_text, access_count, last_accessed FROM memories"
|
|
96
|
+
).fetchall()
|
|
97
|
+
|
|
98
|
+
now = datetime.now()
|
|
99
|
+
tiers = {"hot": 0, "warm": 0, "cold": 0}
|
|
100
|
+
heat_scores = []
|
|
101
|
+
|
|
102
|
+
for r in rows:
|
|
103
|
+
last_access = datetime.fromisoformat(r["last_accessed"]) if r["last_accessed"] else now
|
|
104
|
+
days_since = (now - last_access).days + (now - last_access).seconds / 86400.0
|
|
105
|
+
heat = compute_heat(r["access_count"] or 0, days_since)
|
|
106
|
+
tier = classify_tier(heat)
|
|
107
|
+
tiers[tier] += 1
|
|
108
|
+
heat_scores.append(heat)
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"total": len(rows),
|
|
112
|
+
"tiers": tiers,
|
|
113
|
+
"avg_heat": round(sum(heat_scores) / max(len(heat_scores), 1), 4),
|
|
114
|
+
"min_heat": round(min(heat_scores) if heat_scores else 0, 4),
|
|
115
|
+
"max_heat": round(max(heat_scores) if heat_scores else 0, 4),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def run(self, dry_run: bool = True) -> dict:
|
|
119
|
+
"""
|
|
120
|
+
执行归档。
|
|
121
|
+
|
|
122
|
+
dry_run=True: 只返回计划,不修改数据
|
|
123
|
+
dry_run=False: 真正执行归档
|
|
124
|
+
|
|
125
|
+
策略:
|
|
126
|
+
- 热记忆:跳过
|
|
127
|
+
- 温记忆:抽取式摘要(保留关键句),存入 archive 表
|
|
128
|
+
- 冷记忆:高压缩比归档(仅保留事实抽取结果),存入 archive 表
|
|
129
|
+
"""
|
|
130
|
+
plan = get_plan()
|
|
131
|
+
batch_size = plan.get("batch_size", 60)
|
|
132
|
+
|
|
133
|
+
conn = store.get_conn()
|
|
134
|
+
rows = conn.execute(
|
|
135
|
+
"SELECT id, raw_text, access_count, last_accessed, tokens_json FROM memories"
|
|
136
|
+
).fetchall()
|
|
137
|
+
|
|
138
|
+
now = datetime.now()
|
|
139
|
+
archived = {"hot": 0, "warm": 0, "cold": 0}
|
|
140
|
+
archived_details = []
|
|
141
|
+
|
|
142
|
+
for r in rows:
|
|
143
|
+
last_access = datetime.fromisoformat(r["last_accessed"]) if r["last_accessed"] else now
|
|
144
|
+
days_since = (now - last_access).days + (now - last_access).seconds / 86400.0
|
|
145
|
+
heat = compute_heat(r["access_count"] or 0, days_since)
|
|
146
|
+
tier = classify_tier(heat)
|
|
147
|
+
|
|
148
|
+
if tier == "hot":
|
|
149
|
+
archived["hot"] += 1
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
# 检查是否已归档
|
|
153
|
+
existing = conn.execute(
|
|
154
|
+
"SELECT id FROM archive WHERE memory_id=?", (r["id"],)
|
|
155
|
+
).fetchone()
|
|
156
|
+
if existing:
|
|
157
|
+
archived[tier] += 1
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# 按热度分层压缩
|
|
161
|
+
if tier == "warm":
|
|
162
|
+
compressed = self._warm_compress(r["raw_text"] or "")
|
|
163
|
+
else: # cold
|
|
164
|
+
compressed = self._cold_compress(r["raw_text"] or "")
|
|
165
|
+
|
|
166
|
+
if not dry_run:
|
|
167
|
+
conn.execute(
|
|
168
|
+
"INSERT INTO archive(memory_id, original_text, compressed_text, tier, heat_score, archived_at) "
|
|
169
|
+
"VALUES(?,?,?,?,?,?)",
|
|
170
|
+
(r["id"], r["raw_text"] if tier == "cold" else None,
|
|
171
|
+
compressed, tier, heat,
|
|
172
|
+
now.isoformat(timespec="seconds")),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
archived[tier] += 1
|
|
176
|
+
archived_details.append({
|
|
177
|
+
"memory_id": r["id"],
|
|
178
|
+
"tier": tier,
|
|
179
|
+
"heat": round(heat, 4),
|
|
180
|
+
"compressed_len": len(compressed),
|
|
181
|
+
"original_len": len(r["raw_text"] or ""),
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
if not dry_run:
|
|
185
|
+
conn.commit()
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
"dry_run": dry_run,
|
|
189
|
+
"total": len(rows),
|
|
190
|
+
"archived": archived,
|
|
191
|
+
"details": archived_details[:20], # 只返回前 20 条详情
|
|
192
|
+
"batch_size": batch_size,
|
|
193
|
+
"tier": plan["tier"],
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def _warm_compress(self, text: str) -> str:
|
|
197
|
+
"""
|
|
198
|
+
温记忆压缩:抽取式摘要,保留关键句。
|
|
199
|
+
压缩比约 30~50%。
|
|
200
|
+
"""
|
|
201
|
+
sents = []
|
|
202
|
+
for s in re.split(r"[。!?\n;;]", text):
|
|
203
|
+
s = s.strip()
|
|
204
|
+
if len(s) >= 6:
|
|
205
|
+
sents.append(s)
|
|
206
|
+
if not sents:
|
|
207
|
+
return text[:100] if text else ""
|
|
208
|
+
|
|
209
|
+
# 按 token 频次打分
|
|
210
|
+
freq = Counter()
|
|
211
|
+
for s in sents:
|
|
212
|
+
for tk in embeddings.tokenize(s):
|
|
213
|
+
freq[tk] += 1
|
|
214
|
+
|
|
215
|
+
scored = sorted(sents, key=lambda s: sum(freq.get(tk, 0) for tk in embeddings.tokenize(s)),
|
|
216
|
+
reverse=True)
|
|
217
|
+
# 保留 top-3 关键句
|
|
218
|
+
return ";".join(scored[:3])
|
|
219
|
+
|
|
220
|
+
def _cold_compress(self, text: str) -> str:
|
|
221
|
+
"""
|
|
222
|
+
冷记忆压缩:仅保留事实抽取结果(实体+谓词+值)。
|
|
223
|
+
压缩比约 70~90%。
|
|
224
|
+
"""
|
|
225
|
+
# 极简事实抽取:找「X的Y是Z」模式
|
|
226
|
+
facts = []
|
|
227
|
+
for m in re.finditer(r"([\u4e00-\u9fff]{1,10})的([\u4e00-\u9fff]{1,8})[是为::]\s*([^\n。;;]{1,30})", text):
|
|
228
|
+
entity, predicate, value = m.groups()
|
|
229
|
+
# 清理首尾噪声
|
|
230
|
+
entity = entity.strip()
|
|
231
|
+
predicate = predicate.strip()
|
|
232
|
+
value = value.strip()
|
|
233
|
+
if entity and predicate and value:
|
|
234
|
+
facts.append("%s的%s是%s" % (entity, predicate, value))
|
|
235
|
+
|
|
236
|
+
if facts:
|
|
237
|
+
return " | ".join(facts[:5]) # 最多保留 5 条事实
|
|
238
|
+
|
|
239
|
+
# 回退:截断到 50 字
|
|
240
|
+
return text[:50] + "..." if len(text) > 50 else text
|
|
241
|
+
|
|
242
|
+
def restore(self, memory_id: int) -> str | None:
|
|
243
|
+
"""
|
|
244
|
+
从归档恢复原始文本(仅冷归档有原始文本备份)。
|
|
245
|
+
返回原始文本,若不存在返回 None。
|
|
246
|
+
"""
|
|
247
|
+
conn = store.get_conn()
|
|
248
|
+
row = conn.execute(
|
|
249
|
+
"SELECT original_text, tier FROM archive WHERE memory_id=?", (memory_id,)
|
|
250
|
+
).fetchone()
|
|
251
|
+
if not row:
|
|
252
|
+
return None
|
|
253
|
+
return row["original_text"] # 冷归档有,温归档为 None
|
|
254
|
+
|
|
255
|
+
def get_archive_stats(self) -> dict:
|
|
256
|
+
"""获取归档统计信息。"""
|
|
257
|
+
conn = store.get_conn()
|
|
258
|
+
total = conn.execute("SELECT COUNT(*) FROM archive").fetchone()[0]
|
|
259
|
+
by_tier = {}
|
|
260
|
+
for tier in ("hot", "warm", "cold"):
|
|
261
|
+
cnt = conn.execute("SELECT COUNT(*) FROM archive WHERE tier=?", (tier,)).fetchone()[0]
|
|
262
|
+
by_tier[tier] = cnt
|
|
263
|
+
|
|
264
|
+
# 计算压缩节省的空间
|
|
265
|
+
orig_total = conn.execute(
|
|
266
|
+
"SELECT SUM(LENGTH(original_text)) FROM archive"
|
|
267
|
+
).fetchone()[0] or 0
|
|
268
|
+
comp_total = conn.execute(
|
|
269
|
+
"SELECT SUM(LENGTH(compressed_text)) FROM archive"
|
|
270
|
+
).fetchone()[0] or 0
|
|
271
|
+
|
|
272
|
+
saving_pct = (1 - comp_total / max(orig_total, 1)) * 100 if orig_total > 0 else 0
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
"total_archived": total,
|
|
276
|
+
"by_tier": by_tier,
|
|
277
|
+
"original_size_kb": round(orig_total / 1024, 2),
|
|
278
|
+
"compressed_size_kb": round(comp_total / 1024, 2),
|
|
279
|
+
"saving_percent": round(saving_pct, 1),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ── 便捷函数 ─────────────────────────────────────────────────────────────────
|
|
284
|
+
def analyze() -> dict:
|
|
285
|
+
"""分析记忆热度分布。"""
|
|
286
|
+
return Archiver().analyze()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def run_archive(dry_run: bool = True) -> dict:
|
|
290
|
+
"""执行归档。"""
|
|
291
|
+
return Archiver().run(dry_run=dry_run)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def get_archive_stats() -> dict:
|
|
295
|
+
"""获取归档统计。"""
|
|
296
|
+
return Archiver().get_archive_stats()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
if __name__ == "__main__":
|
|
300
|
+
import pprint
|
|
301
|
+
print("=== 热度分析 ===")
|
|
302
|
+
pprint.pprint(analyze())
|
|
303
|
+
print("\n=== 归档计划 ===")
|
|
304
|
+
pprint.pprint(run_archive(dry_run=True))
|
|
305
|
+
print("\n=== 归档统计 ===")
|
|
306
|
+
pprint.pprint(get_archive_stats())
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
备份 / 恢复 —— 导出、备份、恢复。
|
|
4
|
+
|
|
5
|
+
默认目标:本地文件(零密钥、零联网、立即可用)。
|
|
6
|
+
可插拔目标:百度网盘(baidunetdisk)。该适配器仅在「用户已配置本地 token」时启用;
|
|
7
|
+
未配置则给出明确指引,绝不偷偷联网或要求密钥。
|
|
8
|
+
|
|
9
|
+
设计原则:
|
|
10
|
+
- 备份是「可恢复」的:snapshot(JSON) + 每日日志原文一并打包到备份目录。
|
|
11
|
+
- 保留最近 N 份(config.BACKUP_KEEP),自动清理过旧备份。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
|
|
21
|
+
from . import config, health, store, embeddings
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def export_local(backup_dir: str | None = None) -> dict:
|
|
25
|
+
"""
|
|
26
|
+
导出到本地备份目录:快照 + 每日日志原文。
|
|
27
|
+
返回 {backup_path, snapshot, logs_copied}。
|
|
28
|
+
"""
|
|
29
|
+
config.ensure_dirs()
|
|
30
|
+
if backup_dir is None:
|
|
31
|
+
backup_dir = str(config.ZWJH_DIR / "backups")
|
|
32
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
33
|
+
dest = os.path.join(backup_dir, f"zwjh_backup_{stamp}")
|
|
34
|
+
os.makedirs(dest, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
# 1) 快照(图谱 + 事实 + 配置)
|
|
37
|
+
snap = health.snapshot(os.path.join(dest, "snapshot.json"))
|
|
38
|
+
|
|
39
|
+
# 2) 每日日志原文
|
|
40
|
+
logs_copied = 0
|
|
41
|
+
logs_src = config.MEMORY_DIR
|
|
42
|
+
logs_dst = os.path.join(dest, "daily_logs")
|
|
43
|
+
os.makedirs(logs_dst, exist_ok=True)
|
|
44
|
+
for fp in sorted(logs_src.glob("20??-??-??.md")):
|
|
45
|
+
try:
|
|
46
|
+
shutil.copy2(fp, os.path.join(logs_dst, fp.name))
|
|
47
|
+
logs_copied += 1
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
# 3) 清理旧备份(保留最近 BACKUP_KEEP 份)
|
|
52
|
+
_prune_backups(backup_dir)
|
|
53
|
+
|
|
54
|
+
return {"backup_path": dest, "snapshot": snap, "logs_copied": logs_copied}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _prune_backups(backup_dir: str) -> None:
|
|
58
|
+
try:
|
|
59
|
+
items = sorted(
|
|
60
|
+
[d for d in os.listdir(backup_dir) if d.startswith("zwjh_backup_")],
|
|
61
|
+
reverse=True,
|
|
62
|
+
)
|
|
63
|
+
for old in items[config.BACKUP_KEEP:]:
|
|
64
|
+
shutil.rmtree(os.path.join(backup_dir, old), ignore_errors=True)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def restore_local(backup_path: str, include_logs: bool = True) -> dict:
|
|
70
|
+
"""从本地备份恢复(快照写回库;可选覆盖每日日志)。"""
|
|
71
|
+
snap_file = os.path.join(backup_path, "snapshot.json")
|
|
72
|
+
if not os.path.exists(snap_file):
|
|
73
|
+
return {"status": "error", "reason": "no_snapshot"}
|
|
74
|
+
with open(snap_file, "r", encoding="utf-8") as f:
|
|
75
|
+
data = json.load(f)
|
|
76
|
+
|
|
77
|
+
# 写回实体/关系/事实
|
|
78
|
+
conn = store.get_conn()
|
|
79
|
+
# 清空派生索引(保留每日日志原文)
|
|
80
|
+
store.wipe_memories_only()
|
|
81
|
+
ent_id_map = {}
|
|
82
|
+
for e in data.get("entities", []):
|
|
83
|
+
new_id = store.upsert_entity(e["type"], e["name"],
|
|
84
|
+
json.loads(e.get("aliases_json", "[]")),
|
|
85
|
+
e.get("importance", 0.5))
|
|
86
|
+
ent_id_map[e["id"]] = new_id
|
|
87
|
+
for r in data.get("relations", []):
|
|
88
|
+
frm = ent_id_map.get(r["from_id"])
|
|
89
|
+
to = ent_id_map.get(r["to_id"])
|
|
90
|
+
if frm and to:
|
|
91
|
+
store.add_relation(frm, to, r["relation"], r.get("weight", 1.0),
|
|
92
|
+
r.get("confidence", 1.0))
|
|
93
|
+
# 事实写回
|
|
94
|
+
for fct in data.get("facts", []):
|
|
95
|
+
eid = ent_id_map.get(fct["entity_id"])
|
|
96
|
+
if eid:
|
|
97
|
+
conn.execute(
|
|
98
|
+
"INSERT INTO facts(entity_id, predicate, value, valid_from, valid_to, "
|
|
99
|
+
"superseded, source_memory_id, created_at) VALUES(?,?,?,?,?,?,?,?)",
|
|
100
|
+
(eid, fct["predicate"], fct["value"], fct.get("valid_from"),
|
|
101
|
+
fct.get("valid_to"), fct.get("superseded", 0),
|
|
102
|
+
None, fct.get("created_at")),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# 记忆写回(快照含记忆,避免恢复后记忆表为空导致数据丢失)
|
|
106
|
+
for m in data.get("memories", []):
|
|
107
|
+
try:
|
|
108
|
+
toks = json.loads(m.get("tokens_json", "[]"))
|
|
109
|
+
except Exception:
|
|
110
|
+
toks = embeddings.tokenize(m.get("raw_text", ""))
|
|
111
|
+
store.add_memory(
|
|
112
|
+
m.get("day", config.today_str()),
|
|
113
|
+
m.get("source", "conversation"),
|
|
114
|
+
m.get("raw_text", ""),
|
|
115
|
+
m.get("norm_hash", ""),
|
|
116
|
+
toks,
|
|
117
|
+
importance=float(m.get("importance", 0.5)),
|
|
118
|
+
)
|
|
119
|
+
conn.commit()
|
|
120
|
+
|
|
121
|
+
logs_restored = 0
|
|
122
|
+
if include_logs:
|
|
123
|
+
logs_src = os.path.join(backup_path, "daily_logs")
|
|
124
|
+
if os.path.isdir(logs_src):
|
|
125
|
+
for fp in os.listdir(logs_src):
|
|
126
|
+
if fp.endswith(".md"):
|
|
127
|
+
try:
|
|
128
|
+
shutil.copy2(os.path.join(logs_src, fp),
|
|
129
|
+
os.path.join(str(config.MEMORY_DIR), fp))
|
|
130
|
+
logs_restored += 1
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
return {"status": "ok", "entities": len(ent_id_map),
|
|
134
|
+
"logs_restored": logs_restored}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── 百度网盘适配器(可插拔,零密钥默认不可用) ──────────────────────────────
|
|
138
|
+
def backup_baidunetdisk(backup_dir: str | None = None) -> dict:
|
|
139
|
+
"""
|
|
140
|
+
备份到百度网盘。
|
|
141
|
+
|
|
142
|
+
仅在用户本地已配置百度网盘访问凭证时可用(凭证存于本地 config,不进技能包)。
|
|
143
|
+
未配置时返回明确指引,不报错、不联网试探。
|
|
144
|
+
"""
|
|
145
|
+
cfg = config.load_config()
|
|
146
|
+
token = cfg.get("baidunetdisk", {}).get("access_token")
|
|
147
|
+
if not token:
|
|
148
|
+
return {
|
|
149
|
+
"status": "unavailable",
|
|
150
|
+
"reason": "未配置百度网盘凭证",
|
|
151
|
+
"hint": "在 config.json 写入 baidunetdisk.access_token 即可启用;"
|
|
152
|
+
"或先运行「本地备份」再做手动上传。",
|
|
153
|
+
}
|
|
154
|
+
# 已配置:本地先导出,再调用上传适配器(mixin 接口,便于替换实现)
|
|
155
|
+
local = export_local(backup_dir)
|
|
156
|
+
return _upload_to_baidu(local["backup_path"], token)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _upload_to_baidu(local_path: str, token: str) -> dict:
|
|
160
|
+
"""
|
|
161
|
+
上传适配器占位实现:优先使用已安装的 `bypy` CLI(需用户自行授权)。
|
|
162
|
+
不内置任何密钥;这里只负责调用已授权的本地客户端。
|
|
163
|
+
"""
|
|
164
|
+
import subprocess
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
proc = subprocess.run(
|
|
168
|
+
["bypy", "upload", local_path, "/zwjh_backup/"],
|
|
169
|
+
capture_output=True, text=True, timeout=120,
|
|
170
|
+
)
|
|
171
|
+
if proc.returncode == 0:
|
|
172
|
+
return {"status": "ok", "via": "bypy", "path": local_path}
|
|
173
|
+
return {"status": "error", "reason": proc.stderr[:200]}
|
|
174
|
+
except FileNotFoundError:
|
|
175
|
+
return {
|
|
176
|
+
"status": "unavailable",
|
|
177
|
+
"reason": "未找到 bypy 客户端",
|
|
178
|
+
"hint": "pip install bypy 并完成授权后可启用百度网盘自动备份。",
|
|
179
|
+
}
|
|
180
|
+
except Exception as e:
|
|
181
|
+
return {"status": "error", "reason": str(e)[:200]}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
print(export_local())
|