mindforge-memory 6.4.1__py3-none-any.whl

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.
Files changed (64) hide show
  1. MindForge.py +20 -0
  2. mindforge/__init__.py +111 -0
  3. mindforge/adapters/__init__.py +18 -0
  4. mindforge/adapters/claude_adapter.py +84 -0
  5. mindforge/adapters/crewai_memory.py +76 -0
  6. mindforge/adapters/generic_api.py +183 -0
  7. mindforge/adapters/langgraph_store.py +463 -0
  8. mindforge/adapters/openclaw_adapter.py +91 -0
  9. mindforge/api/__init__.py +1 -0
  10. mindforge/api/server.py +1567 -0
  11. mindforge/cli/__init__.py +1 -0
  12. mindforge/cli/main.py +13607 -0
  13. mindforge/core/__init__.py +37 -0
  14. mindforge/core/embedding.py +847 -0
  15. mindforge/core/encryption.py +597 -0
  16. mindforge/core/indexer.py +363 -0
  17. mindforge/core/mindforge.py +6298 -0
  18. mindforge/core/paths.py +43 -0
  19. mindforge/core/query.py +501 -0
  20. mindforge/core/storage.py +18656 -0
  21. mindforge/core/types.py +314 -0
  22. mindforge/core/version.py +56 -0
  23. mindforge/daemon/__init__.py +0 -0
  24. mindforge/daemon/server.py +277 -0
  25. mindforge/mcp/__init__.py +4 -0
  26. mindforge/mcp/server.py +2559 -0
  27. mindforge/modules/__init__.py +138 -0
  28. mindforge/modules/categorizer.py +151 -0
  29. mindforge/modules/conflict_detector.py +356 -0
  30. mindforge/modules/connectors.py +331 -0
  31. mindforge/modules/context_window.py +337 -0
  32. mindforge/modules/event_bus.py +572 -0
  33. mindforge/modules/evolution.py +210 -0
  34. mindforge/modules/experience.py +402 -0
  35. mindforge/modules/federated.py +722 -0
  36. mindforge/modules/federated_acl.py +393 -0
  37. mindforge/modules/follow_up.py +315 -0
  38. mindforge/modules/hybrid_search.py +565 -0
  39. mindforge/modules/importer.py +374 -0
  40. mindforge/modules/integrator.py +196 -0
  41. mindforge/modules/intent_router.py +359 -0
  42. mindforge/modules/knowledge_graph.py +395 -0
  43. mindforge/modules/memory_compressor.py +592 -0
  44. mindforge/modules/memory_decay.py +474 -0
  45. mindforge/modules/memory_passport.py +250 -0
  46. mindforge/modules/multi_agent.py +534 -0
  47. mindforge/modules/multimodal.py +304 -0
  48. mindforge/modules/outcome_learning.py +186 -0
  49. mindforge/modules/personality.py +275 -0
  50. mindforge/modules/principled_forgetting.py +258 -0
  51. mindforge/modules/privacy.py +736 -0
  52. mindforge/modules/recall.py +263 -0
  53. mindforge/modules/session_focus.py +305 -0
  54. mindforge/modules/share_conflict.py +521 -0
  55. mindforge/modules/skill_extractor.py +340 -0
  56. mindforge/modules/skill_pack.py +205 -0
  57. mindforge/modules/timeline_viz.py +206 -0
  58. mindforge/modules/v6.py +931 -0
  59. mindforge_memory-6.4.1.dist-info/METADATA +281 -0
  60. mindforge_memory-6.4.1.dist-info/RECORD +64 -0
  61. mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
  62. mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
  63. mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
  64. mindforge_memory-6.4.1.dist-info/top_level.txt +2 -0
@@ -0,0 +1,572 @@
1
+ """
2
+ MindForge 事件总线与 Webhook 通知
3
+ =========================================
4
+
5
+ 提供内存级发布/订阅事件总线,支持:
6
+ - 记忆生命周期事件:created / updated / deleted / expired
7
+ - 矛盾检测事件:conflict_detected
8
+ - 版本回滚事件:version_rolled_back
9
+ - 导出事件:export_completed
10
+ - Webhook 回调:HTTP POST 到配置的 URL,支持签名验证、重试、超时
11
+
12
+ 配置方式:
13
+ 1. 构造参数:MindForge(webhooks=["https://example.com/hook"])
14
+ 2. 环境变量:MINDFORGE_WEBHOOK_URLS=https://a.com/hook,https://b.com/hook
15
+ 3. 运行时:mf.event_bus.register_webhook(url, events=["memory_created"])
16
+
17
+ 事件 payload 结构:
18
+ {
19
+ "event": "memory_created",
20
+ "timestamp": "2026-08-20T10:00:00Z",
21
+ "source": "mindforge",
22
+ "version": "<当前版本,取自 core/version.py>",
23
+ "data": { ... 事件相关数据 ... }
24
+ }
25
+
26
+ Webhook 签名(可选):
27
+ 设置 MINDFORGE_WEBHOOK_SECRET 后,每个请求携带 header:
28
+ X-MindForge-Signature: sha256=<hex>
29
+ 签名 = HMAC-SHA256(secret, raw_body)
30
+ """
31
+
32
+ import json
33
+ import time
34
+ import hmac
35
+ import hashlib
36
+ import logging
37
+ import threading
38
+ import urllib.request
39
+ import urllib.error
40
+ from typing import Callable, Optional, List, Dict, Any, Set
41
+
42
+
43
+ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
44
+ """SSRF 防护(N3):拒绝一切 3xx 跳转。
45
+
46
+ urllib 默认会跟随跳转,攻击者可注册一个公网 URL,再让它 302 到
47
+ 169.254.169.254(云元数据)或 127.0.0.1,从而绕过注册阶段的地址校验。
48
+ """
49
+
50
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
51
+ raise urllib.error.HTTPError(
52
+ req.full_url, code,
53
+ f"redirect blocked by SSRF guard: {newurl}",
54
+ headers, fp,
55
+ )
56
+ from dataclasses import dataclass, field
57
+ from datetime import datetime, timezone
58
+
59
+ # v5.6.8 修复:payload 与 User-Agent 中的版本号此前硬编码为 5.4.9(早已过期,
60
+ # 与 core/version.py 真值漂移),改为引用唯一真值。
61
+ from mindforge.core.version import __version__ as _MF_VERSION
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+ # 标准事件类型常量
66
+ EVENT_MEMORY_CREATED = "memory_created"
67
+ EVENT_MEMORY_UPDATED = "memory_updated"
68
+ EVENT_MEMORY_DELETED = "memory_deleted"
69
+ EVENT_MEMORY_EXPIRED = "memory_expired"
70
+ EVENT_CONFLICT_DETECTED = "conflict_detected"
71
+ EVENT_VERSION_ROLLED_BACK = "version_rolled_back"
72
+ EVENT_EXPORT_COMPLETED = "export_completed"
73
+ EVENT_IMPORT_COMPLETED = "import_completed"
74
+
75
+ ALL_EVENTS = frozenset({
76
+ EVENT_MEMORY_CREATED,
77
+ EVENT_MEMORY_UPDATED,
78
+ EVENT_MEMORY_DELETED,
79
+ EVENT_MEMORY_EXPIRED,
80
+ EVENT_CONFLICT_DETECTED,
81
+ EVENT_VERSION_ROLLED_BACK,
82
+ EVENT_EXPORT_COMPLETED,
83
+ EVENT_IMPORT_COMPLETED,
84
+ })
85
+
86
+
87
+ @dataclass
88
+ class WebhookConfig:
89
+ """单个 Webhook 配置"""
90
+ url: str
91
+ events: Set[str] = field(default_factory=lambda: set(ALL_EVENTS))
92
+ secret: str = ""
93
+ timeout: float = 10.0
94
+ max_retries: int = 3
95
+ retry_delay: float = 2.0
96
+ enabled: bool = True
97
+
98
+ def to_dict(self) -> Dict[str, Any]:
99
+ return {
100
+ "url": self.url,
101
+ "events": sorted(self.events),
102
+ "has_secret": bool(self.secret),
103
+ "timeout": self.timeout,
104
+ "max_retries": self.max_retries,
105
+ "enabled": self.enabled,
106
+ }
107
+
108
+
109
+ class EventBus:
110
+ """MindForge 事件总线(v5.4.9 新增)
111
+
112
+ 支持同步订阅者(Python 回调)和异步 Webhook(HTTP POST)。
113
+ Webhook 投递在后台线程执行,不阻塞主流程。
114
+ """
115
+
116
+ def __init__(self):
117
+ self._subscribers: Dict[str, List[Callable]] = {}
118
+ self._webhooks: List[WebhookConfig] = []
119
+ self._lock = threading.Lock()
120
+ self._enabled = True
121
+ self._event_count = 0
122
+ self._delivery_history: List[Dict[str, Any]] = []
123
+ self._max_history = 100
124
+
125
+ # ===== 订阅/取消订阅 =====
126
+
127
+ @staticmethod
128
+ def _resolve_host_ips(hostname: str) -> List[str]:
129
+ """解析主机名到全部 IP(IPv4 + IPv6)"""
130
+ import socket as _socket
131
+ try:
132
+ infos = _socket.getaddrinfo(hostname, None,
133
+ proto=_socket.IPPROTO_TCP)
134
+ except (_socket.gaierror, OSError, UnicodeError):
135
+ return []
136
+ ips: List[str] = []
137
+ for info in infos:
138
+ addr = info[4][0] if len(info) > 4 else ""
139
+ if addr and addr not in ips:
140
+ ips.append(addr)
141
+ return ips
142
+
143
+ @staticmethod
144
+ def _is_blocked_ip(ip_str: str) -> bool:
145
+ """判断 IP 是否落在受限网段(无法解析按不安全处理,fail-closed)"""
146
+ import ipaddress
147
+ try:
148
+ ip = ipaddress.ip_address(ip_str)
149
+ except ValueError:
150
+ return True
151
+ # IPv6-mapped(::ffff:127.0.0.1)先还原成 IPv4 再判断
152
+ mapped = getattr(ip, "ipv4_mapped", None)
153
+ if mapped is not None:
154
+ ip = mapped
155
+ return bool(ip.is_private or ip.is_loopback or ip.is_link_local
156
+ or ip.is_multicast or ip.is_unspecified or ip.is_reserved)
157
+
158
+ @classmethod
159
+ def _validate_no_ssrf(cls, url: str) -> None:
160
+ """SSRF 防护:拒绝指向内网/回环/链路本地的 Webhook URL
161
+
162
+ 阻止探测云元数据服务(169.254.169.254)、本地服务等。
163
+
164
+ N3 修复:原实现只校验「字面量 IP」,遇到域名直接 return,且注释承诺的
165
+ 「投递前再做 DNS 解析检查」在投递路径中并不存在。以下绕过因此全部成立:
166
+ - http://internal.corp/ 内网域名
167
+ - http://127.1/ 非规范 IPv4 写法
168
+ - http://2130706433/ 十进制 IPv4
169
+ - http://0x7f000001/ 十六进制 IPv4
170
+ - http://169.254.169.254.nip.io/ 域名解析到内网
171
+ - 公网 URL 302 → 169.254.169.254(urlopen/requests 默认跟随跳转)
172
+ 现在统一为:规范化 IP 写法 + DNS 全量解析 + 任一结果受限即拒绝。
173
+ """
174
+ from urllib.parse import urlparse
175
+ import ipaddress
176
+
177
+ parsed = urlparse(url)
178
+ if parsed.scheme not in ("http", "https"):
179
+ raise ValueError(f"Webhook URL 仅支持 http/https: {parsed.scheme!r}")
180
+
181
+ hostname = (parsed.hostname or "").strip()
182
+ if not hostname:
183
+ raise ValueError("Webhook URL 缺少主机名")
184
+
185
+ # 常见内网主机名直接拒绝
186
+ if hostname.lower() in ("localhost", "loopback", "metadata",
187
+ "metadata.google.internal"):
188
+ raise ValueError(f"Webhook URL 禁止指向本地/内网主机: {hostname}")
189
+
190
+ # 先按字面量 IP 判断(并兼容 127.1 / 0x7f000001 等非规范写法)
191
+ literal = None
192
+ try:
193
+ literal = ipaddress.ip_address(hostname)
194
+ except ValueError:
195
+ import socket as _socket
196
+ try:
197
+ literal = ipaddress.ip_address(_socket.inet_aton(hostname))
198
+ except (OSError, ValueError, UnicodeError):
199
+ literal = None
200
+ if literal is not None:
201
+ if cls._is_blocked_ip(str(literal)):
202
+ raise ValueError(f"Webhook URL 禁止指向内网 IP: {hostname}")
203
+ return
204
+
205
+ # 域名:必须成功解析,且所有解析结果都不得落在受限网段
206
+ ips = cls._resolve_host_ips(hostname)
207
+ if not ips:
208
+ raise ValueError(f"Webhook URL 主机名无法解析: {hostname}")
209
+ for ip_str in ips:
210
+ if cls._is_blocked_ip(ip_str):
211
+ raise ValueError(
212
+ f"Webhook URL 解析到受限地址 {ip_str}(host={hostname})"
213
+ )
214
+
215
+ def subscribe(self, event: str, callback: Callable[[Dict[str, Any]], None]) -> None:
216
+ """订阅指定事件
217
+
218
+ Args:
219
+ event: 事件类型(使用 EVENT_* 常量),或 "*" 订阅所有事件
220
+ callback: 回调函数,接收 event payload dict
221
+ """
222
+ with self._lock:
223
+ if event not in self._subscribers:
224
+ self._subscribers[event] = []
225
+ self._subscribers[event].append(callback)
226
+
227
+ def unsubscribe(self, event: str, callback: Callable) -> bool:
228
+ """取消订阅"""
229
+ with self._lock:
230
+ if event in self._subscribers:
231
+ try:
232
+ self._subscribers[event].remove(callback)
233
+ return True
234
+ except ValueError:
235
+ pass
236
+ return False
237
+
238
+ # ===== Webhook 管理 =====
239
+
240
+ def register_webhook(self, url: str,
241
+ events: Optional[List[str]] = None,
242
+ secret: str = "",
243
+ timeout: float = 10.0,
244
+ max_retries: int = 3) -> WebhookConfig:
245
+ """注册 Webhook 回调
246
+
247
+ Args:
248
+ url: 回调 URL
249
+ events: 监听的事件类型列表,None 表示所有事件
250
+ secret: 用于签名的密钥(可选)
251
+ timeout: 请求超时秒数
252
+ max_retries: 最大重试次数
253
+
254
+ Returns:
255
+ WebhookConfig 实例
256
+
257
+ Raises:
258
+ ValueError: URL 为空或格式无效
259
+ """
260
+ if not url or not isinstance(url, str):
261
+ raise ValueError("Webhook URL 不能为空")
262
+ url = url.strip()
263
+ if not url.startswith(("http://", "https://")):
264
+ raise ValueError(f"Webhook URL 必须以 http:// 或 https:// 开头: {url!r}")
265
+
266
+ # P1 安全修复:SSRF 防护 — 拒绝内网/回环/链路本地地址
267
+ self._validate_no_ssrf(url)
268
+
269
+ config = WebhookConfig(
270
+ url=url,
271
+ events=set(events) if events else set(ALL_EVENTS),
272
+ secret=secret,
273
+ timeout=timeout,
274
+ max_retries=max_retries,
275
+ )
276
+ with self._lock:
277
+ # 避免重复 URL
278
+ self._webhooks = [w for w in self._webhooks if w.url != url]
279
+ self._webhooks.append(config)
280
+ logger.info("Webhook 已注册: %s (events=%s)", url, sorted(config.events))
281
+ return config
282
+
283
+ def unregister_webhook(self, url: str) -> bool:
284
+ """注销 Webhook"""
285
+ with self._lock:
286
+ before = len(self._webhooks)
287
+ self._webhooks = [w for w in self._webhooks if w.url != url]
288
+ return len(self._webhooks) < before
289
+
290
+ def list_webhooks(self) -> List[Dict[str, Any]]:
291
+ """列出所有 Webhook 配置"""
292
+ with self._lock:
293
+ return [w.to_dict() for w in self._webhooks]
294
+
295
+ # ===== 发布事件 =====
296
+
297
+ def publish(self, event: str, data: Optional[Dict[str, Any]] = None,
298
+ sync: bool = False) -> Dict[str, Any]:
299
+ """发布事件
300
+
301
+ Args:
302
+ event: 事件类型
303
+ data: 事件数据
304
+ sync: True=同步投递(等待所有 webhook 完成),False=异步(默认)
305
+
306
+ Returns:
307
+ {"event": ..., "timestamp": ..., "subscribers_notified": int, "webhooks_dispatched": int}
308
+ """
309
+ if not self._enabled:
310
+ return {"event": event, "skipped": True, "reason": "event_bus_disabled"}
311
+
312
+ payload = {
313
+ "event": event,
314
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
315
+ "source": "mindforge",
316
+ "version": _MF_VERSION,
317
+ "data": data or {},
318
+ }
319
+
320
+ # 同步订阅者
321
+ notified = 0
322
+ with self._lock:
323
+ callbacks = list(self._subscribers.get(event, []))
324
+ callbacks.extend(self._subscribers.get("*", []))
325
+ webhooks = [w for w in self._webhooks
326
+ if w.enabled and event in w.events]
327
+
328
+ for cb in callbacks:
329
+ try:
330
+ cb(payload)
331
+ notified += 1
332
+ except Exception as e:
333
+ logger.warning("事件订阅者回调失败 [%s]: %s", event, e)
334
+
335
+ # Webhook 投递
336
+ dispatched = len(webhooks)
337
+ if webhooks:
338
+ if sync:
339
+ for wh in webhooks:
340
+ self._deliver_webhook(wh, payload)
341
+ else:
342
+ t = threading.Thread(
343
+ target=self._deliver_webhooks_async,
344
+ args=(webhooks, payload),
345
+ daemon=True,
346
+ )
347
+ t.start()
348
+
349
+ self._event_count += 1
350
+ result = {
351
+ "event": event,
352
+ "timestamp": payload["timestamp"],
353
+ "subscribers_notified": notified,
354
+ "webhooks_dispatched": dispatched,
355
+ }
356
+ return result
357
+
358
+ def _deliver_webhooks_async(self, webhooks: List[WebhookConfig],
359
+ payload: Dict[str, Any]) -> None:
360
+ """后台线程投递多个 Webhook"""
361
+ for wh in webhooks:
362
+ self._deliver_webhook(wh, payload)
363
+
364
+ def _deliver_webhook(self, config: WebhookConfig,
365
+ payload: Dict[str, Any]) -> bool:
366
+ """投递单个 Webhook(带重试)
367
+
368
+ P1 修复:签名与发送体必须一致 — 统一序列化一次 body,
369
+ 签名计算用同一个字节串,发送用 data=body 而非 json=payload。
370
+ P3-2 修复:尊重 config.timeout,4xx 不重试。
371
+ """
372
+ # 统一序列化:签名和发送共用同一个 body 字节串
373
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
374
+
375
+ headers = {
376
+ "Content-Type": "application/json; charset=utf-8",
377
+ "User-Agent": f"MindForge/{_MF_VERSION}",
378
+ "X-MindForge-Event": payload["event"],
379
+ }
380
+ if config.secret:
381
+ sig = hmac.new(
382
+ config.secret.encode("utf-8"),
383
+ body,
384
+ hashlib.sha256
385
+ ).hexdigest()
386
+ headers["X-MindForge-Signature"] = f"sha256={sig}"
387
+
388
+ # P3-2: 尊重用户配置的 timeout,requests 需要 (connect, read) 元组
389
+ # v5.5.7 fix: connect 超时也受 config.timeout 控制(之前硬编码 3s)
390
+ if isinstance(config.timeout, (tuple, list)):
391
+ timeout = tuple(config.timeout)
392
+ else:
393
+ timeout = (config.timeout, config.timeout)
394
+
395
+ last_error = ""
396
+ actual_attempts = 0
397
+ for attempt in range(config.max_retries + 1):
398
+ actual_attempts = attempt + 1
399
+ try:
400
+ # N3 修复:投递前重新做一次 SSRF 校验(防 DNS rebinding:注册时
401
+ # 解析到公网、投递时解析到内网),并禁止跟随 3xx 跳转
402
+ # (否则公网 URL 可 302 到 169.254.169.254)。
403
+ self._validate_no_ssrf(config.url)
404
+ import requests
405
+ response = requests.post(
406
+ config.url,
407
+ data=body,
408
+ headers=headers,
409
+ timeout=timeout,
410
+ allow_redirects=False,
411
+ )
412
+ status = response.status_code
413
+ success = 200 <= status < 300
414
+ self._record_delivery(config.url, payload["event"],
415
+ status, success, attempt)
416
+ if success:
417
+ logger.debug("Webhook 投递成功 [%s] -> %s (status=%d)",
418
+ payload["event"], config.url, status)
419
+ return True
420
+ last_error = f"HTTP {status}"
421
+ # P3-2: 4xx 客户端错误不重试
422
+ if 400 <= status < 500:
423
+ break
424
+ except ImportError:
425
+ # requests 库不可用时降级到 urllib(自带重试/退避,与 requests 路径一致)
426
+ last_error = self._deliver_webhook_urllib(config, body, headers)
427
+ if last_error is True:
428
+ return True
429
+ break
430
+ except Exception as e:
431
+ last_error = str(e)
432
+ self._record_delivery(config.url, payload["event"],
433
+ 0, False, attempt)
434
+
435
+ if attempt < config.max_retries:
436
+ time.sleep(config.retry_delay * (attempt + 1))
437
+
438
+ # v5.5.7 fix: 日志写实际尝试次数(4xx break 时只请求了 1 次)
439
+ logger.warning("Webhook 投递失败 [%s] -> %s: %s (after %d attempts)",
440
+ payload["event"], config.url, last_error,
441
+ actual_attempts)
442
+ return False
443
+
444
+ def _deliver_webhook_urllib(self, config: WebhookConfig,
445
+ body: bytes,
446
+ headers: Dict[str, str]) -> str:
447
+ """使用 urllib 的降级方案(requests 不可用时)
448
+
449
+ v5.6.9:补齐与 requests 路径一致的重试与指数退避(原先仅单次尝试、无退避)。
450
+ P1 修复:body 已在调用方序列化好,签名也基于同一字节串,不再重复序列化。
451
+ """
452
+ # urllib 的 timeout 仅接受标量,遇 (connect, read) 元组时取连接超时部分
453
+ timeout = config.timeout
454
+ if isinstance(timeout, (tuple, list)):
455
+ timeout = timeout[0] if timeout else 10.0
456
+ last_error = ""
457
+ for attempt in range(config.max_retries + 1):
458
+ try:
459
+ # N3 修复:与 requests 路径一致 —— 投递前复检 + 禁止跟随跳转
460
+ self._validate_no_ssrf(config.url)
461
+ req = urllib.request.Request(
462
+ config.url, data=body, headers=headers, method="POST")
463
+ opener = urllib.request.build_opener(_NoRedirectHandler)
464
+ with opener.open(req, timeout=timeout) as resp:
465
+ status = resp.getcode()
466
+ success = 200 <= status < 300
467
+ self._record_delivery(config.url, headers.get("X-MindForge-Event", "unknown"),
468
+ status, success, attempt)
469
+ if success:
470
+ logger.debug("Webhook 投递成功 -> %s (status=%d)",
471
+ config.url, status)
472
+ return True
473
+ last_error = f"HTTP {status}"
474
+ except urllib.error.HTTPError as e:
475
+ self._record_delivery(config.url, headers.get("X-MindForge-Event", "unknown"),
476
+ e.code, False, attempt)
477
+ last_error = f"HTTP {e.code}"
478
+ except Exception as e:
479
+ self._record_delivery(config.url, headers.get("X-MindForge-Event", "unknown"),
480
+ 0, False, attempt)
481
+ last_error = str(e)
482
+ # v5.6.9:与 requests 路径一致的指数退避
483
+ if attempt < config.max_retries:
484
+ time.sleep(config.retry_delay * (attempt + 1))
485
+ return last_error
486
+
487
+ def _record_delivery(self, url: str, event: str,
488
+ status: int, success: bool, attempt: int) -> None:
489
+ """记录投递历史(用于调试和监控)"""
490
+ record = {
491
+ "url": url,
492
+ "event": event,
493
+ "status": status,
494
+ "success": success,
495
+ "attempt": attempt,
496
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
497
+ }
498
+ with self._lock:
499
+ self._delivery_history.append(record)
500
+ if len(self._delivery_history) > self._max_history:
501
+ self._delivery_history = self._delivery_history[-self._max_history:]
502
+
503
+ # ===== 状态查询 =====
504
+
505
+ @property
506
+ def enabled(self) -> bool:
507
+ return self._enabled
508
+
509
+ @enabled.setter
510
+ def enabled(self, value: bool) -> None:
511
+ self._enabled = bool(value)
512
+
513
+ @property
514
+ def event_count(self) -> int:
515
+ return self._event_count
516
+
517
+ def delivery_history(self, limit: int = 50) -> List[Dict[str, Any]]:
518
+ """获取最近的投递记录"""
519
+ with self._lock:
520
+ return list(self._delivery_history[-limit:])
521
+
522
+ def stats(self) -> Dict[str, Any]:
523
+ """事件总线统计"""
524
+ with self._lock:
525
+ subscriber_count = sum(len(v) for v in self._subscribers.values())
526
+ return {
527
+ "enabled": self._enabled,
528
+ "total_events_published": self._event_count,
529
+ "subscribers": subscriber_count,
530
+ "webhooks": len(self._webhooks),
531
+ "delivery_history_size": len(self._delivery_history),
532
+ }
533
+
534
+
535
+ def create_event_bus_from_config(config: Optional[Dict[str, Any]] = None) -> EventBus:
536
+ """从配置字典创建事件总线(v5.4.9)
537
+
538
+ 支持的配置键:
539
+ webhooks: [{"url": ..., "events": [...], "secret": ...}]
540
+ webhook_urls: ["https://..."] (简写)
541
+ webhook_secret: "..."
542
+ enabled: true/false
543
+ """
544
+ import os
545
+ bus = EventBus()
546
+
547
+ # 从环境变量加载
548
+ env_urls = os.environ.get("MINDFORGE_WEBHOOK_URLS", "")
549
+ env_secret = os.environ.get("MINDFORGE_WEBHOOK_SECRET", "")
550
+ if env_urls:
551
+ for url in [u.strip() for u in env_urls.split(",") if u.strip()]:
552
+ bus.register_webhook(url, secret=env_secret)
553
+
554
+ # 从配置字典加载
555
+ if config:
556
+ if config.get("enabled") is False:
557
+ bus.enabled = False
558
+ for wh in config.get("webhooks", []) or []:
559
+ if isinstance(wh, str):
560
+ bus.register_webhook(wh, secret=config.get("webhook_secret", ""))
561
+ elif isinstance(wh, dict):
562
+ bus.register_webhook(
563
+ url=wh.get("url", ""),
564
+ events=wh.get("events"),
565
+ secret=wh.get("secret", config.get("webhook_secret", "")),
566
+ timeout=wh.get("timeout", 10.0),
567
+ max_retries=wh.get("max_retries", 3),
568
+ )
569
+ for url in config.get("webhook_urls", []) or []:
570
+ bus.register_webhook(url, secret=config.get("webhook_secret", ""))
571
+
572
+ return bus