ultra-memory 3.1.0 → 3.2.0
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.
- package/integrations/__init__.py +1 -0
- package/integrations/langchain_memory.py +118 -0
- package/integrations/langgraph_checkpointer.py +76 -0
- package/integrations/n8n_nodes.py +150 -0
- package/package.json +17 -4
- package/scripts/auto_decay.py +351 -0
- package/scripts/cleanup.py +21 -0
- package/scripts/detect_contradictions.py +537 -0
- package/scripts/evolve_profile.py +414 -0
- package/scripts/extract_facts.py +471 -0
- package/scripts/log_op.py +42 -0
- package/scripts/multimodal/__init__.py +2 -0
- package/scripts/multimodal/extract_from_image.py +138 -0
- package/scripts/multimodal/extract_from_pdf.py +182 -0
- package/scripts/multimodal/transcribe_video.py +157 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ultra-memory: 自动遗忘引擎 (Evolution Engine Phase 3)
|
|
4
|
+
基于时间衰减 + 访问频率 + 重要性评分计算每条事实的 decay_score。
|
|
5
|
+
decay_score < 0.05 的事实标记为遗忘(soft-delete,不删除原始记录)。
|
|
6
|
+
|
|
7
|
+
衰减公式:
|
|
8
|
+
decay_score = importance_score × recency_weight × access_weight
|
|
9
|
+
|
|
10
|
+
recency_weight = 0.5 ^ (age_days / half_life_days) # half_life=30天
|
|
11
|
+
access_weight = min(1.0, log2(access_count + 1) / log2(11))
|
|
12
|
+
# access_count=0 → ~0, access_count=10 → ~0.95
|
|
13
|
+
|
|
14
|
+
衰减等级:
|
|
15
|
+
≥ 0.6 → "none" (健康)
|
|
16
|
+
0.4–0.6 → "mild" (轻度衰减)
|
|
17
|
+
0.2–0.4 → "moderate" (中度衰减)
|
|
18
|
+
0.05–0.2 → "severe" (即将遗忘)
|
|
19
|
+
< 0.05 → "forgotten" (触发遗忘)
|
|
20
|
+
|
|
21
|
+
被 cleanup.py --run-decay 调用,或每日定时触发。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import json
|
|
27
|
+
import argparse
|
|
28
|
+
import math
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
if sys.stdout.encoding != "utf-8":
|
|
33
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
34
|
+
if sys.stderr.encoding != "utf-8":
|
|
35
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
ULTRA_MEMORY_HOME = Path(os.environ.get("ULTRA_MEMORY_HOME", Path.home() / ".ultra-memory"))
|
|
38
|
+
|
|
39
|
+
# 默认衰减参数(可通过 config.json 覆盖)
|
|
40
|
+
DEFAULT_HALF_LIFE_DAYS = 30
|
|
41
|
+
DEFAULT_FORGET_THRESHOLD = 0.05
|
|
42
|
+
|
|
43
|
+
# 衰减等级边界
|
|
44
|
+
DECAY_LEVELS = [
|
|
45
|
+
(0.6, "none"),
|
|
46
|
+
(0.4, "mild"),
|
|
47
|
+
(0.2, "moderate"),
|
|
48
|
+
(0.05, "severe"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── 工具函数 ───────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _now_iso() -> str:
|
|
56
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_ts(ts_str: str) -> datetime:
|
|
60
|
+
"""解析 ISO 时间字符串"""
|
|
61
|
+
if not ts_str:
|
|
62
|
+
return datetime.now(timezone.utc)
|
|
63
|
+
try:
|
|
64
|
+
# 移除 Z 后缀
|
|
65
|
+
ts_str = ts_str.replace("Z", "+00:00")
|
|
66
|
+
return datetime.fromisoformat(ts_str)
|
|
67
|
+
except ValueError:
|
|
68
|
+
return datetime.now(timezone.utc)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_config() -> dict:
|
|
72
|
+
"""加载配置(如果有 decay 相关配置)"""
|
|
73
|
+
config_file = ULTRA_MEMORY_HOME / "config.json"
|
|
74
|
+
if config_file.exists():
|
|
75
|
+
try:
|
|
76
|
+
with open(config_file, encoding="utf-8") as f:
|
|
77
|
+
return json.load(f)
|
|
78
|
+
except (json.JSONDecodeError, IOError):
|
|
79
|
+
pass
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── 衰减计算 ───────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def compute_decay_score(
|
|
87
|
+
fact_metadata: dict,
|
|
88
|
+
now: datetime,
|
|
89
|
+
) -> float:
|
|
90
|
+
"""
|
|
91
|
+
计算单条事实的衰减评分。
|
|
92
|
+
|
|
93
|
+
decay_score = importance_score × recency_weight × access_weight
|
|
94
|
+
"""
|
|
95
|
+
age_days = (now - _parse_ts(fact_metadata.get("last_updated", ""))).days
|
|
96
|
+
half_life_days = fact_metadata.get("ttl_days", DEFAULT_HALF_LIFE_DAYS)
|
|
97
|
+
|
|
98
|
+
# 时间衰减权重
|
|
99
|
+
recency_weight = math.pow(0.5, age_days / half_life_days) if half_life_days > 0 else 0.0
|
|
100
|
+
|
|
101
|
+
# 访问频率权重
|
|
102
|
+
access_count = fact_metadata.get("access_count", 0)
|
|
103
|
+
access_weight = min(1.0, math.log2(access_count + 1) / math.log2(11))
|
|
104
|
+
|
|
105
|
+
# 重要性评分
|
|
106
|
+
importance_score = fact_metadata.get("importance_score", 0.5)
|
|
107
|
+
|
|
108
|
+
score = importance_score * recency_weight * access_weight
|
|
109
|
+
|
|
110
|
+
# 从未访问且较老的事实额外惩罚
|
|
111
|
+
if access_count == 0 and age_days > half_life_days:
|
|
112
|
+
score *= 0.5
|
|
113
|
+
|
|
114
|
+
return max(0.0, min(1.0, score))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def compute_decay_level(score: float) -> str:
|
|
118
|
+
"""根据衰减评分确定衰减等级"""
|
|
119
|
+
for threshold, level in DECAY_LEVELS:
|
|
120
|
+
if score >= threshold:
|
|
121
|
+
return level
|
|
122
|
+
return "forgotten"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def compute_importance_score(fact: dict, meta: dict) -> float:
|
|
126
|
+
"""
|
|
127
|
+
计算事实的重要性评分 (0.0–1.0)。
|
|
128
|
+
来源类型 × 矛盾抵抗 × 用户确认度 的加权组合。
|
|
129
|
+
"""
|
|
130
|
+
base = 0.5
|
|
131
|
+
|
|
132
|
+
# 来源类型权重
|
|
133
|
+
source_weights = {
|
|
134
|
+
"milestone": 0.9,
|
|
135
|
+
"decision": 0.85,
|
|
136
|
+
"user_instruction": 0.8,
|
|
137
|
+
"file_write": 0.7,
|
|
138
|
+
"tool_call": 0.6,
|
|
139
|
+
"reasoning": 0.6,
|
|
140
|
+
"bash_exec": 0.55,
|
|
141
|
+
"error": 0.5,
|
|
142
|
+
"file_read": 0.4,
|
|
143
|
+
}
|
|
144
|
+
source_type = fact.get("source_type", "")
|
|
145
|
+
base = source_weights.get(source_type, 0.6)
|
|
146
|
+
|
|
147
|
+
# 矛盾抵抗:矛盾越少越稳定
|
|
148
|
+
contradiction_count = meta.get("contradiction_count", 0)
|
|
149
|
+
contradiction_penalty = min(0.3, contradiction_count * 0.1)
|
|
150
|
+
base = max(0.1, base - contradiction_penalty)
|
|
151
|
+
|
|
152
|
+
# 用户显式确认权重(如果 correction_history 有 manual 条目)
|
|
153
|
+
manual_corrections = [
|
|
154
|
+
c for c in meta.get("correction_history", [])
|
|
155
|
+
if c.get("source") == "manual"
|
|
156
|
+
]
|
|
157
|
+
if manual_corrections:
|
|
158
|
+
base = min(1.0, base + 0.15)
|
|
159
|
+
|
|
160
|
+
return max(0.1, min(1.0, base))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── 遗忘处理 ───────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _load_facts() -> list[dict]:
|
|
167
|
+
"""加载所有 facts"""
|
|
168
|
+
facts_file = ULTRA_MEMORY_HOME / "evolution" / "facts.jsonl"
|
|
169
|
+
if not facts_file.exists():
|
|
170
|
+
return []
|
|
171
|
+
facts = []
|
|
172
|
+
with open(facts_file, encoding="utf-8") as f:
|
|
173
|
+
for line in f:
|
|
174
|
+
line = line.strip()
|
|
175
|
+
if not line:
|
|
176
|
+
continue
|
|
177
|
+
try:
|
|
178
|
+
facts.append(json.loads(line))
|
|
179
|
+
except json.JSONDecodeError:
|
|
180
|
+
continue
|
|
181
|
+
return facts
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _load_metadata() -> dict:
|
|
185
|
+
"""加载 fact_metadata.json"""
|
|
186
|
+
meta_file = ULTRA_MEMORY_HOME / "evolution" / "fact_metadata.json"
|
|
187
|
+
if not meta_file.exists():
|
|
188
|
+
return {"version": 1, "updated_at": _now_iso(), "facts": {}}
|
|
189
|
+
try:
|
|
190
|
+
with open(meta_file, encoding="utf-8") as f:
|
|
191
|
+
return json.load(f)
|
|
192
|
+
except (json.JSONDecodeError, IOError):
|
|
193
|
+
return {"version": 1, "updated_at": _now_iso(), "facts": {}}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _save_metadata(meta: dict):
|
|
197
|
+
"""原子写入 metadata"""
|
|
198
|
+
evolution_dir = ULTRA_MEMORY_HOME / "evolution"
|
|
199
|
+
evolution_dir.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
meta_file = evolution_dir / "fact_metadata.json"
|
|
201
|
+
meta["updated_at"] = _now_iso()
|
|
202
|
+
|
|
203
|
+
tmp_file = meta_file.with_suffix(".tmp")
|
|
204
|
+
with open(tmp_file, "w", encoding="utf-8") as f:
|
|
205
|
+
json.dump(meta, f, ensure_ascii=False, indent=2)
|
|
206
|
+
tmp_file.replace(meta_file)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def append_decay_log(entry: dict):
|
|
210
|
+
"""追加写入 decay_log.jsonl"""
|
|
211
|
+
evolution_dir = ULTRA_MEMORY_HOME / "evolution"
|
|
212
|
+
evolution_dir.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
log_file = evolution_dir / "decay_log.jsonl"
|
|
214
|
+
|
|
215
|
+
with open(log_file, "a", encoding="utf-8") as f:
|
|
216
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── 全量衰减扫描 ───────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def run_decay_pass(session_id: str | None = None):
|
|
223
|
+
"""
|
|
224
|
+
执行全量衰减扫描。
|
|
225
|
+
session_id 不为 None 时只扫描指定 session 的事实。
|
|
226
|
+
"""
|
|
227
|
+
now = datetime.now(timezone.utc)
|
|
228
|
+
facts = _load_facts()
|
|
229
|
+
meta = _load_metadata()
|
|
230
|
+
|
|
231
|
+
config = _load_config()
|
|
232
|
+
forget_threshold = (
|
|
233
|
+
config.get("decay", {}).get("forget_threshold", DEFAULT_FORGET_THRESHOLD)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# 初始化所有 fact 的 metadata(如果不存在)
|
|
237
|
+
for fact in facts:
|
|
238
|
+
fid = fact.get("fact_id")
|
|
239
|
+
if not fid:
|
|
240
|
+
continue
|
|
241
|
+
if fid not in meta["facts"]:
|
|
242
|
+
meta["facts"][fid] = {
|
|
243
|
+
"confidence": fact.get("confidence", 0.7),
|
|
244
|
+
"access_count": fact.get("access_count", 1),
|
|
245
|
+
"last_accessed": fact.get("last_accessed", _now_iso()),
|
|
246
|
+
"last_updated": fact.get("ts", _now_iso()),
|
|
247
|
+
"importance_score": compute_importance_score(fact, {}),
|
|
248
|
+
"decay_level": "none",
|
|
249
|
+
"ttl_days": DEFAULT_HALF_LIFE_DAYS,
|
|
250
|
+
"expires_at": None,
|
|
251
|
+
"status": "active",
|
|
252
|
+
"contradiction_count": fact.get("contradiction_count", 0),
|
|
253
|
+
"correction_history": [],
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
# 计算每条事实的衰减
|
|
257
|
+
forgotten_ids = []
|
|
258
|
+
severe_ids = []
|
|
259
|
+
updated_ids = []
|
|
260
|
+
|
|
261
|
+
for fid, fact_meta in meta["facts"].items():
|
|
262
|
+
if fact_meta.get("status") in ("forgotten", "superseded"):
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
# session_id 过滤
|
|
266
|
+
if session_id:
|
|
267
|
+
# 找到对应 fact
|
|
268
|
+
matching_fact = next(
|
|
269
|
+
(f for f in facts if f.get("fact_id") == fid), None
|
|
270
|
+
)
|
|
271
|
+
if not matching_fact:
|
|
272
|
+
continue
|
|
273
|
+
if matching_fact.get("session_id") != session_id:
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
# TTL 过期检测
|
|
277
|
+
if fact_meta.get("expires_at"):
|
|
278
|
+
expires_at = _parse_ts(fact_meta["expires_at"])
|
|
279
|
+
if now >= expires_at:
|
|
280
|
+
fact_meta["decay_level"] = "forgotten"
|
|
281
|
+
|
|
282
|
+
# 重新计算重要性评分(基于最新事实数据)
|
|
283
|
+
matching_fact = next(
|
|
284
|
+
(f for f in facts if f.get("fact_id") == fid), None
|
|
285
|
+
)
|
|
286
|
+
if matching_fact:
|
|
287
|
+
fact_meta["importance_score"] = compute_importance_score(
|
|
288
|
+
matching_fact, fact_meta
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# 计算衰减评分
|
|
292
|
+
decay_score = compute_decay_score(fact_meta, now)
|
|
293
|
+
old_level = fact_meta.get("decay_level", "none")
|
|
294
|
+
new_level = compute_decay_level(decay_score)
|
|
295
|
+
fact_meta["decay_level"] = new_level
|
|
296
|
+
|
|
297
|
+
# 更新 ttl_days 配置
|
|
298
|
+
ttl_days = fact_meta.get("ttl_days", DEFAULT_HALF_LIFE_DAYS)
|
|
299
|
+
|
|
300
|
+
# 触发遗忘
|
|
301
|
+
if new_level == "forgotten" and old_level != "forgotten":
|
|
302
|
+
fact_meta["status"] = "forgotten"
|
|
303
|
+
fact_meta["forgotten_at"] = _now_iso()
|
|
304
|
+
forgotten_ids.append(fid)
|
|
305
|
+
|
|
306
|
+
append_decay_log({
|
|
307
|
+
"ts": _now_iso(),
|
|
308
|
+
"fact_id": fid,
|
|
309
|
+
"reason": "ttl_expired" if fact_meta.get("expires_at") else "importance_decay",
|
|
310
|
+
"decay_level_before": old_level,
|
|
311
|
+
"action": "marked_forgotten",
|
|
312
|
+
"decay_score": round(decay_score, 3),
|
|
313
|
+
"session_id": session_id or "system",
|
|
314
|
+
})
|
|
315
|
+
elif new_level == "severe" and old_level in ("none", "mild", "moderate"):
|
|
316
|
+
severe_ids.append(fid)
|
|
317
|
+
|
|
318
|
+
if new_level != old_level:
|
|
319
|
+
updated_ids.append(fid)
|
|
320
|
+
|
|
321
|
+
_save_metadata(meta)
|
|
322
|
+
|
|
323
|
+
print(f"[ultra-memory] ✅ 衰减扫描完成 (session: {session_id or 'all'})")
|
|
324
|
+
print(f" 遗忘: {len(forgotten_ids)} 条")
|
|
325
|
+
print(f" 严重衰减: {len(severe_ids)} 条")
|
|
326
|
+
print(f" 等级变化: {len(updated_ids)} 条")
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
"forgotten": forgotten_ids,
|
|
330
|
+
"severe": severe_ids,
|
|
331
|
+
"updated": updated_ids,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ── CLI ─────────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
if __name__ == "__main__":
|
|
339
|
+
parser = argparse.ArgumentParser(description="执行事实衰减扫描")
|
|
340
|
+
parser.add_argument(
|
|
341
|
+
"--session", default=None,
|
|
342
|
+
help="会话 ID(省略则扫描所有事实)"
|
|
343
|
+
)
|
|
344
|
+
parser.add_argument(
|
|
345
|
+
"--run", action="store_true",
|
|
346
|
+
help="执行衰减扫描(配合 cleanup.py 使用)"
|
|
347
|
+
)
|
|
348
|
+
args = parser.parse_args()
|
|
349
|
+
|
|
350
|
+
result = run_decay_pass(args.session)
|
|
351
|
+
sys.exit(0)
|
package/scripts/cleanup.py
CHANGED
|
@@ -152,5 +152,26 @@ if __name__ == "__main__":
|
|
|
152
152
|
parser.add_argument("--archive-only", action="store_true", help="只归档到 archive/ 目录,不删除")
|
|
153
153
|
parser.add_argument("--dry-run", action="store_true", help="演习模式,只打印不执行")
|
|
154
154
|
parser.add_argument("--project", default=None, help="只清理指定项目(默认所有项目)")
|
|
155
|
+
parser.add_argument(
|
|
156
|
+
"--run-decay", action="store_true",
|
|
157
|
+
help="执行事实衰减扫描(auto_decay.py),在清理前运行"
|
|
158
|
+
)
|
|
155
159
|
args = parser.parse_args()
|
|
160
|
+
|
|
161
|
+
if args.run_decay:
|
|
162
|
+
import subprocess
|
|
163
|
+
scripts_dir = Path(__file__).parent
|
|
164
|
+
python = sys.executable
|
|
165
|
+
startupinfo = subprocess.STARTUPINFO()
|
|
166
|
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
167
|
+
print("[ultra-memory] 运行事实衰减扫描...")
|
|
168
|
+
result = subprocess.run(
|
|
169
|
+
[python, str(scripts_dir / "auto_decay.py"), "--session", args.project or ""],
|
|
170
|
+
capture_output=True, text=True, startupinfo=startupinfo,
|
|
171
|
+
)
|
|
172
|
+
if result.stdout:
|
|
173
|
+
print(result.stdout)
|
|
174
|
+
if result.stderr:
|
|
175
|
+
print(result.stderr, file=sys.stderr)
|
|
176
|
+
|
|
156
177
|
cleanup(args.days, args.archive_only, args.dry_run, args.project)
|