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.
- MindForge.py +20 -0
- mindforge/__init__.py +111 -0
- mindforge/adapters/__init__.py +18 -0
- mindforge/adapters/claude_adapter.py +84 -0
- mindforge/adapters/crewai_memory.py +76 -0
- mindforge/adapters/generic_api.py +183 -0
- mindforge/adapters/langgraph_store.py +463 -0
- mindforge/adapters/openclaw_adapter.py +91 -0
- mindforge/api/__init__.py +1 -0
- mindforge/api/server.py +1567 -0
- mindforge/cli/__init__.py +1 -0
- mindforge/cli/main.py +13607 -0
- mindforge/core/__init__.py +37 -0
- mindforge/core/embedding.py +847 -0
- mindforge/core/encryption.py +597 -0
- mindforge/core/indexer.py +363 -0
- mindforge/core/mindforge.py +6298 -0
- mindforge/core/paths.py +43 -0
- mindforge/core/query.py +501 -0
- mindforge/core/storage.py +18656 -0
- mindforge/core/types.py +314 -0
- mindforge/core/version.py +56 -0
- mindforge/daemon/__init__.py +0 -0
- mindforge/daemon/server.py +277 -0
- mindforge/mcp/__init__.py +4 -0
- mindforge/mcp/server.py +2559 -0
- mindforge/modules/__init__.py +138 -0
- mindforge/modules/categorizer.py +151 -0
- mindforge/modules/conflict_detector.py +356 -0
- mindforge/modules/connectors.py +331 -0
- mindforge/modules/context_window.py +337 -0
- mindforge/modules/event_bus.py +572 -0
- mindforge/modules/evolution.py +210 -0
- mindforge/modules/experience.py +402 -0
- mindforge/modules/federated.py +722 -0
- mindforge/modules/federated_acl.py +393 -0
- mindforge/modules/follow_up.py +315 -0
- mindforge/modules/hybrid_search.py +565 -0
- mindforge/modules/importer.py +374 -0
- mindforge/modules/integrator.py +196 -0
- mindforge/modules/intent_router.py +359 -0
- mindforge/modules/knowledge_graph.py +395 -0
- mindforge/modules/memory_compressor.py +592 -0
- mindforge/modules/memory_decay.py +474 -0
- mindforge/modules/memory_passport.py +250 -0
- mindforge/modules/multi_agent.py +534 -0
- mindforge/modules/multimodal.py +304 -0
- mindforge/modules/outcome_learning.py +186 -0
- mindforge/modules/personality.py +275 -0
- mindforge/modules/principled_forgetting.py +258 -0
- mindforge/modules/privacy.py +736 -0
- mindforge/modules/recall.py +263 -0
- mindforge/modules/session_focus.py +305 -0
- mindforge/modules/share_conflict.py +521 -0
- mindforge/modules/skill_extractor.py +340 -0
- mindforge/modules/skill_pack.py +205 -0
- mindforge/modules/timeline_viz.py +206 -0
- mindforge/modules/v6.py +931 -0
- mindforge_memory-6.4.1.dist-info/METADATA +281 -0
- mindforge_memory-6.4.1.dist-info/RECORD +64 -0
- mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
- mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
- mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
- mindforge_memory-6.4.1.dist-info/top_level.txt +2 -0
mindforge/api/server.py
ADDED
|
@@ -0,0 +1,1567 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MindForge REST API Server
|
|
3
|
+
================================
|
|
4
|
+
|
|
5
|
+
标准 REST API,让非 Python 应用(JS、Go、移动端)也能直接调用 MindForge。
|
|
6
|
+
|
|
7
|
+
基于 Python 内置 http.server,无需额外依赖(FastAPI/Flask 可选)。
|
|
8
|
+
|
|
9
|
+
路由表(v5.7.0 P2 补充,便于调试;v6.1.2 校正口径)
|
|
10
|
+
----------------------------------------------------
|
|
11
|
+
手动分发:BaseHTTPRequestHandler.do_GET / do_POST / do_PUT / do_DELETE
|
|
12
|
+
中以 if/elif 按 path 匹配(无装饰器路由)。限流(v5.7.0 P2 分级):
|
|
13
|
+
读 100 req/60s/IP、写 30、批量导入 10(_check_rate_limit(write/heavy));
|
|
14
|
+
认证:除 /api/health 与 / 外均需 _check_auth。
|
|
15
|
+
|
|
16
|
+
**本表是摘选,不是权威清单。** 当前实现的分发点共 **60** 个
|
|
17
|
+
(按方法:GET 37 / POST 21 / PUT 1 / DELETE 1,口径见
|
|
18
|
+
`scripts/check_docs_consistency.py` 第 [8] 组——它直接解析 `do_*` 方法体,
|
|
19
|
+
数字漂移会被 CI 拦下)。此前这里写着「路由表」却只列了 24 条,
|
|
20
|
+
读代码的人据此估算接口规模,会低估一半以上。
|
|
21
|
+
|
|
22
|
+
Method Path 说明 分发位置
|
|
23
|
+
------ ---------------------- ------------------------ --------------
|
|
24
|
+
GET / 最小化元信息(未授权可读) do_GET
|
|
25
|
+
GET /api/health 健康状态(未授权可读) do_GET
|
|
26
|
+
GET /api/stats 统计信息(需认证) do_GET
|
|
27
|
+
GET /api/tags 标签列表(需认证) do_GET
|
|
28
|
+
GET /api/search 搜索(?q=&limit=&explain=) do_GET
|
|
29
|
+
GET /api/memories 列出(?limit=&offset=&…) do_GET
|
|
30
|
+
GET /api/memories/{id} 获取单条(需认证) do_GET
|
|
31
|
+
GET /api/export 导出 JSON(上限 5000 条) do_GET
|
|
32
|
+
GET /api/valid-at 按事实有效时间查询(v5.7.6) do_GET
|
|
33
|
+
GET /api/connectors 列出可用数据连接器(v5.7.7) do_GET
|
|
34
|
+
POST /api/agent/forget Agent 自主遗忘(v5.7.7) do_POST
|
|
35
|
+
POST /api/conflicts/reconcile 冲突自动调和(v5.7.7) do_POST
|
|
36
|
+
POST /api/memories 添加记忆(JSON body) do_POST
|
|
37
|
+
POST /api/import 批量导入(≤10000 条) do_POST
|
|
38
|
+
PUT /api/memories/{id} 更新记忆(JSON body) do_PUT
|
|
39
|
+
DELETE /api/memories/{id} 删除记忆 do_DELETE
|
|
40
|
+
|
|
41
|
+
# ---- v6.0.0 正式版:v6 端口 + 6G AI 记忆预览 ----
|
|
42
|
+
GET /api/v6/status v6 端口总览(多 Agent + 6G) do_GET
|
|
43
|
+
GET /api/preview/channels 6G 预览通道列表 do_GET
|
|
44
|
+
GET /api/preview/index 6G 增量索引统计 do_GET
|
|
45
|
+
GET /api/preview/timeline 6G 单记忆时间线(?memory_id=) do_GET
|
|
46
|
+
GET /api/forget/stats 遗忘引擎统计 do_GET
|
|
47
|
+
POST /api/preview/open 开启 6G 预览通道 do_POST
|
|
48
|
+
POST /api/preview/close 关闭并清理 6G 预览通道 do_POST
|
|
49
|
+
POST /api/preview/snapshot 快照记忆进预览通道 do_POST
|
|
50
|
+
POST /api/preview/distill 6G 上下文蒸馏 do_POST
|
|
51
|
+
POST /api/forget/run 有原则遗忘 GC(v6.1.2 起支持
|
|
52
|
+
body.days 冷却窗口) do_POST
|
|
53
|
+
# 其余(时间线、技能包、护照、跟进、画像、联邦、审计等)见 do_* 分发。
|
|
54
|
+
|
|
55
|
+
启动方式:
|
|
56
|
+
MindForge serve --api
|
|
57
|
+
MindForge serve --api --port 9000
|
|
58
|
+
MindForge serve --api --host 0.0.0.0
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
import json
|
|
62
|
+
import logging
|
|
63
|
+
import re
|
|
64
|
+
import sys
|
|
65
|
+
import os
|
|
66
|
+
import time
|
|
67
|
+
import hmac
|
|
68
|
+
import threading
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
71
|
+
from urllib.parse import urlparse, parse_qs
|
|
72
|
+
from collections import defaultdict
|
|
73
|
+
from typing import Dict, List, Optional, Any
|
|
74
|
+
from mindforge import __version__ as MF_VERSION
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
from mindforge.core.encryption import SecurityError
|
|
78
|
+
except ImportError:
|
|
79
|
+
|
|
80
|
+
class SecurityError(Exception):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# 确保项目根目录在 path 中
|
|
85
|
+
_PROJECT_ROOT = Path(__file__).parent.parent
|
|
86
|
+
if str(_PROJECT_ROOT) not in sys.path:
|
|
87
|
+
sys.path.insert(0, str(_PROJECT_ROOT))
|
|
88
|
+
|
|
89
|
+
logger = logging.getLogger(__name__)
|
|
90
|
+
|
|
91
|
+
# v5.4.8 安全修复:请求体大小限制(10MB)
|
|
92
|
+
MAX_BODY_SIZE = 10 * 1024 * 1024
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# v5.4.7 修复 H-7:简单速率限制器
|
|
96
|
+
class _RateLimiter:
|
|
97
|
+
"""基于 IP 的请求速率限制
|
|
98
|
+
|
|
99
|
+
v5.6.3 稳定性修复:
|
|
100
|
+
- 清理过期记录后,若某 IP 的计数已归零则删除该键,避免冷 IP 永久驻留内存;
|
|
101
|
+
- 对跟踪的 IP 总数设上限(默认 10000),超出时丢弃最久未活动的 IP,
|
|
102
|
+
防止长期运行(数月)后字典无界增长导致内存泄漏。
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
_MAX_TRACKED_IPS = 10000
|
|
106
|
+
|
|
107
|
+
def __init__(self, max_requests=100, window_seconds=60):
|
|
108
|
+
self.max_requests = max_requests
|
|
109
|
+
self.window = window_seconds
|
|
110
|
+
self._requests = defaultdict(list)
|
|
111
|
+
self._lock = threading.Lock()
|
|
112
|
+
|
|
113
|
+
def check(self, client_ip: str) -> bool:
|
|
114
|
+
"""返回 True 表示允许,False 表示限流
|
|
115
|
+
|
|
116
|
+
v5.7.1 修复:原实现在 append 后才判断空键(此时 times 必非空,
|
|
117
|
+
清理逻辑是死代码)。改为过滤后立即清理空键——某 IP 的历史请求
|
|
118
|
+
全部过期时删除该键,避免冷 IP 永久驻留字典。
|
|
119
|
+
"""
|
|
120
|
+
now = time.time()
|
|
121
|
+
with self._lock:
|
|
122
|
+
times = [t for t in self._requests[client_ip] if now - t < self.window]
|
|
123
|
+
# 过滤后为空 → 该 IP 历史已过期,删除空键避免常驻
|
|
124
|
+
if not times:
|
|
125
|
+
self._requests.pop(client_ip, None)
|
|
126
|
+
else:
|
|
127
|
+
self._requests[client_ip] = times
|
|
128
|
+
if len(times) >= self.max_requests:
|
|
129
|
+
return False
|
|
130
|
+
times.append(now)
|
|
131
|
+
self._requests[client_ip] = times
|
|
132
|
+
# IP 总数上限:丢弃最久未活动的键
|
|
133
|
+
if len(self._requests) > self._MAX_TRACKED_IPS:
|
|
134
|
+
excess = len(self._requests) - self._MAX_TRACKED_IPS
|
|
135
|
+
for stale_ip in list(self._requests.keys())[:excess]:
|
|
136
|
+
self._requests.pop(stale_ip, None)
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_rate_limiter = _RateLimiter(max_requests=100, window_seconds=60)
|
|
141
|
+
# v5.7.0 P2:写路径配额更严(30 req/60s/IP);批量/导入端点再降一档(10 req/60s/IP)
|
|
142
|
+
_write_limiter = _RateLimiter(max_requests=30, window_seconds=60)
|
|
143
|
+
_import_limiter = _RateLimiter(max_requests=10, window_seconds=60)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _normalize_ip(ip: str) -> str:
|
|
147
|
+
"""将 IP 规范化为限流 key
|
|
148
|
+
|
|
149
|
+
P2 #15 修复:IPv6 主机可生成大量临时地址绕过限流。
|
|
150
|
+
对 IPv6 使用 /64 子网前缀作为限流 key(同一子网共享计数)。
|
|
151
|
+
IPv4 直接使用原地址。
|
|
152
|
+
"""
|
|
153
|
+
import ipaddress
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
addr = ipaddress.ip_address(ip)
|
|
157
|
+
except ValueError:
|
|
158
|
+
return ip # 解析失败直接用原值(fail-open,但仅用于限流 key)
|
|
159
|
+
if isinstance(addr, ipaddress.IPv6Address):
|
|
160
|
+
# 取 /64 前缀
|
|
161
|
+
network = ipaddress.IPv6Network((addr, 64), strict=False)
|
|
162
|
+
return str(network.network_address) + "/64"
|
|
163
|
+
return ip
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# P0-003: 限制最大并发线程数的 HTTP 服务器
|
|
167
|
+
class BoundedThreadingHTTPServer(ThreadingHTTPServer):
|
|
168
|
+
"""带最大并发线程数限制的 HTTP 服务器
|
|
169
|
+
|
|
170
|
+
防止大量并发请求耗尽服务器资源(线程、内存、文件描述符)。
|
|
171
|
+
超过限制时返回 503 Service Unavailable。
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
max_threads = 50
|
|
175
|
+
daemon_threads = True
|
|
176
|
+
|
|
177
|
+
def __init__(self, *args, **kwargs):
|
|
178
|
+
super().__init__(*args, **kwargs)
|
|
179
|
+
self._active_threads = 0
|
|
180
|
+
self._thread_lock = threading.Lock()
|
|
181
|
+
self._max_threads = self.__class__.max_threads
|
|
182
|
+
|
|
183
|
+
def process_request(self, request, client_address):
|
|
184
|
+
"""覆盖 process_request 以限制并发线程数"""
|
|
185
|
+
with self._thread_lock:
|
|
186
|
+
if self._active_threads >= self._max_threads:
|
|
187
|
+
# 超过并发限制,返回 503
|
|
188
|
+
try:
|
|
189
|
+
body = b'{"error": "Too many concurrent requests"}'
|
|
190
|
+
request.sendall(
|
|
191
|
+
b"HTTP/1.1 503 Service Unavailable\r\n"
|
|
192
|
+
b"Content-Type: application/json\r\n"
|
|
193
|
+
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
|
|
194
|
+
b"\r\n" + body
|
|
195
|
+
)
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
request.close()
|
|
199
|
+
return
|
|
200
|
+
self._active_threads += 1
|
|
201
|
+
|
|
202
|
+
# 在新线程中处理请求
|
|
203
|
+
t = threading.Thread(
|
|
204
|
+
target=self._process_request_thread,
|
|
205
|
+
args=(request, client_address),
|
|
206
|
+
daemon=True,
|
|
207
|
+
)
|
|
208
|
+
# v5.5.8 修复:t.start() 失败(如线程资源耗尽)时计数不会回退,
|
|
209
|
+
# 并发槽位会逐步泄漏直至服务永久返回 503。
|
|
210
|
+
try:
|
|
211
|
+
t.start()
|
|
212
|
+
except RuntimeError:
|
|
213
|
+
with self._thread_lock:
|
|
214
|
+
self._active_threads -= 1
|
|
215
|
+
try:
|
|
216
|
+
body = b'{"error": "Failed to start worker thread"}'
|
|
217
|
+
request.sendall(
|
|
218
|
+
b"HTTP/1.1 503 Service Unavailable\r\n"
|
|
219
|
+
b"Content-Type: application/json\r\n"
|
|
220
|
+
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
|
|
221
|
+
b"\r\n" + body
|
|
222
|
+
)
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
request.close()
|
|
226
|
+
|
|
227
|
+
def _process_request_thread(self, request, client_address):
|
|
228
|
+
try:
|
|
229
|
+
self.finish_request(request, client_address)
|
|
230
|
+
except Exception:
|
|
231
|
+
self.handle_error(request, client_address)
|
|
232
|
+
finally:
|
|
233
|
+
self.shutdown_request(request)
|
|
234
|
+
# v5.7.1:请求线程结束时显式关闭本线程的 SQLite 连接,
|
|
235
|
+
# 避免 ThreadingHTTPServer 每请求新建线程导致连接对象堆积。
|
|
236
|
+
try:
|
|
237
|
+
mf = MindForgeAPIHandler.mindforge
|
|
238
|
+
if mf is not None and hasattr(mf, "storage"):
|
|
239
|
+
mf.storage.close_thread_conn()
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
with self._thread_lock:
|
|
243
|
+
self._active_threads -= 1
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _safe_int(value, default=10, min_val=1, max_val=10000):
|
|
247
|
+
"""v5.4.7 修复 H-1:安全解析整数参数"""
|
|
248
|
+
try:
|
|
249
|
+
v = int(value)
|
|
250
|
+
return max(min_val, min(max_val, v))
|
|
251
|
+
except (ValueError, TypeError):
|
|
252
|
+
return default
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _safe_float(value, default=0.3, min_val=0.0, max_val=1.0):
|
|
256
|
+
"""v5.4.7 修复 H-1:安全解析浮点参数"""
|
|
257
|
+
try:
|
|
258
|
+
v = float(value)
|
|
259
|
+
return max(min_val, min(max_val, v))
|
|
260
|
+
except (ValueError, TypeError):
|
|
261
|
+
return default
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
_TAG_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _sanitize_tag(tag) -> str:
|
|
268
|
+
"""P2-04 安全修复:归一化标签字符串后再聚合/返回。
|
|
269
|
+
|
|
270
|
+
/api/tags 的两条解析路径(JSON 与逗号分隔 fallback)都可能带回未经校验的
|
|
271
|
+
标签值:非字符串对象、含控制字符或超长二进制串。统一转成 str、去除首尾空白、
|
|
272
|
+
删除 ASCII 控制字符并截断到 64 字符,避免异常标签进入 JSON 响应。
|
|
273
|
+
"""
|
|
274
|
+
if not isinstance(tag, str):
|
|
275
|
+
tag = str(tag)
|
|
276
|
+
tag = _TAG_CONTROL_RE.sub("", tag).strip()
|
|
277
|
+
return tag[:64]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _sanitize_category(value, default=None) -> Optional[str]:
|
|
281
|
+
"""v5.7.0 P2:分类参数校验——必须是字符串,剔除控制字符后截断,空值回落默认。"""
|
|
282
|
+
if value is None:
|
|
283
|
+
return default
|
|
284
|
+
if not isinstance(value, str):
|
|
285
|
+
return default
|
|
286
|
+
cat = _TAG_CONTROL_RE.sub("", value).strip()
|
|
287
|
+
if not cat:
|
|
288
|
+
return default
|
|
289
|
+
return cat[:64]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _validate_tags(value) -> List[str]:
|
|
293
|
+
"""v5.7.0 P2:标签参数校验——必须是字符串列表,逐项消毒并限制数量(≤50)。"""
|
|
294
|
+
if value is None:
|
|
295
|
+
return []
|
|
296
|
+
if not isinstance(value, list):
|
|
297
|
+
raise ValueError("Field 'tags' must be a list of strings")
|
|
298
|
+
cleaned: List[str] = []
|
|
299
|
+
for t in value:
|
|
300
|
+
if not isinstance(t, str):
|
|
301
|
+
raise ValueError("Field 'tags' must be a list of strings")
|
|
302
|
+
ct = _sanitize_tag(t)
|
|
303
|
+
if ct:
|
|
304
|
+
cleaned.append(ct)
|
|
305
|
+
if len(cleaned) >= 50:
|
|
306
|
+
break
|
|
307
|
+
return cleaned
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
_IMPORTANCE_VALUES = {"LOW", "MEDIUM", "HIGH", "CRITICAL"}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _validate_importance(value):
|
|
314
|
+
"""v5.7.0 P2:importance 必须是合法枚举值(大小写不敏感),返回规范大写。"""
|
|
315
|
+
if value is None:
|
|
316
|
+
return None
|
|
317
|
+
if not isinstance(value, str):
|
|
318
|
+
raise ValueError("Field 'importance' must be one of LOW/MEDIUM/HIGH/CRITICAL")
|
|
319
|
+
up = value.strip().upper()
|
|
320
|
+
if up not in _IMPORTANCE_VALUES:
|
|
321
|
+
raise ValueError("Field 'importance' must be one of LOW/MEDIUM/HIGH/CRITICAL")
|
|
322
|
+
return up
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _validate_starred(value):
|
|
326
|
+
"""v5.7.0 P2:starred 必须是布尔(或 0/1)。"""
|
|
327
|
+
if value is None:
|
|
328
|
+
return None
|
|
329
|
+
if isinstance(value, bool):
|
|
330
|
+
return int(value)
|
|
331
|
+
if isinstance(value, int) and value in (0, 1):
|
|
332
|
+
return value
|
|
333
|
+
raise ValueError("Field 'starred' must be a boolean")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _validate_source_agent(value) -> str:
|
|
337
|
+
"""v5.7.0 P2:source_agent 必须是字符串,截断到 128。"""
|
|
338
|
+
if value is None:
|
|
339
|
+
return ""
|
|
340
|
+
if not isinstance(value, str):
|
|
341
|
+
raise ValueError("Field 'source_agent' must be a string")
|
|
342
|
+
return value[:128]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _validate_float_ts(value) -> float:
|
|
346
|
+
"""v5.7.6:valid_from/valid_to 时间戳校验(None/0 → 0.0,非法抛 ValueError)。"""
|
|
347
|
+
if value is None:
|
|
348
|
+
return 0.0
|
|
349
|
+
if isinstance(value, bool):
|
|
350
|
+
raise ValueError("Field 'valid_from'/'valid_to' must be a number")
|
|
351
|
+
try:
|
|
352
|
+
f = float(value)
|
|
353
|
+
except (TypeError, ValueError):
|
|
354
|
+
raise ValueError("Field 'valid_from'/'valid_to' must be a number")
|
|
355
|
+
if f < 0:
|
|
356
|
+
raise ValueError("Field 'valid_from'/'valid_to' must be >= 0")
|
|
357
|
+
return f
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _log_unhandled_api_error(exc: Exception) -> None:
|
|
361
|
+
"""P3-03:统一记录未捕获异常,避免 traceback 把内部文件路径写进常规日志。
|
|
362
|
+
|
|
363
|
+
logger.exception 会把完整回溯(含绝对路径、帧信息)打到 INFO/ERROR 级日志里。
|
|
364
|
+
这里降级:常规级别只记异常类型名,完整回溯仅在 DEBUG 级按需输出。
|
|
365
|
+
HTTP 响应始终是通用的 Internal server error,不含任何异常详情。
|
|
366
|
+
"""
|
|
367
|
+
logger.error("API error: %s", type(exc).__name__)
|
|
368
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
369
|
+
logger.debug("API error traceback", exc_info=exc)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class MindForgeAPIHandler(BaseHTTPRequestHandler):
|
|
373
|
+
"""REST API 请求处理器"""
|
|
374
|
+
|
|
375
|
+
# MindForge 实例由 server 注入
|
|
376
|
+
mindforge = None
|
|
377
|
+
|
|
378
|
+
# v5.7.2 安全加固:服务端 TLS 启用时下发 HSTS(由 start_api_server 设置)
|
|
379
|
+
_tls_enabled = False
|
|
380
|
+
|
|
381
|
+
# HTTPS 反向代理场景:显式信任代理(MINDFORGE_TRUST_PROXY=1)后,
|
|
382
|
+
# 请求带 X-Forwarded-Proto: https 时同样下发 HSTS(由 start_api_server 设置)。
|
|
383
|
+
# 默认关闭——纯 HTTP 直连绝不因客户端伪造的 X-Forwarded-Proto 下发 HSTS,
|
|
384
|
+
# 避免 HSTS pinning 攻击把浏览器钉死在某个不支持 HTTPS 的主机上。
|
|
385
|
+
_trust_proxy = False
|
|
386
|
+
|
|
387
|
+
def _send_json(self, data, status=200):
|
|
388
|
+
body = json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
|
|
389
|
+
self.send_response(status)
|
|
390
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
391
|
+
self.send_header("Content-Length", str(len(body)))
|
|
392
|
+
# v5.7.2 安全加固:统一安全响应头
|
|
393
|
+
# - X-Content-Type-Options: nosniff 防止 MIME 嗅探
|
|
394
|
+
# - X-Frame-Options: DENY 防止点击劫持
|
|
395
|
+
# - Referrer-Policy: no-referrer 不泄漏来源 URL
|
|
396
|
+
# - Cache-Control: no-store 敏感记忆数据禁止落入浏览器/中间缓存
|
|
397
|
+
# - Strict-Transport-Security: 仅服务端 TLS 启用时下发(见 start_api_server)
|
|
398
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
399
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
400
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
401
|
+
self.send_header("Cache-Control", "no-store")
|
|
402
|
+
# 用 getattr 兜底:响应桩/测试对象可能不是 MindForgeAPIHandler 实例。
|
|
403
|
+
# HSTS 下发条件(二选一):
|
|
404
|
+
# 1) 服务端自启 TLS(_tls_enabled,由 ssl_certfile 启用);
|
|
405
|
+
# 2) 显式信任 HTTPS 反代(MINDFORGE_TRUST_PROXY=1)且请求经
|
|
406
|
+
# X-Forwarded-Proto: https 到达——纯 HTTP 直连绝不因伪造头下发。
|
|
407
|
+
if getattr(self, "_tls_enabled", False):
|
|
408
|
+
self.send_header("Strict-Transport-Security", "max-age=31536000")
|
|
409
|
+
elif (
|
|
410
|
+
getattr(self, "_trust_proxy", False)
|
|
411
|
+
and self.headers.get("X-Forwarded-Proto", "").lower() == "https"
|
|
412
|
+
):
|
|
413
|
+
self.send_header("Strict-Transport-Security", "max-age=31536000")
|
|
414
|
+
# v5.4.8 安全修复:CORS 限制为配置的源(默认仅允许同源)
|
|
415
|
+
allowed_origin = os.environ.get("MINDFORGE_CORS_ORIGIN", "")
|
|
416
|
+
if allowed_origin:
|
|
417
|
+
self.send_header("Access-Control-Allow-Origin", allowed_origin)
|
|
418
|
+
# v5.6.5 P2 #15:Allow-Methods/Headers 仅在显式配置 Allow-Origin
|
|
419
|
+
# (即确实允许跨域)时才下发,避免未开启 CORS 却无条件暴露跨域策略。
|
|
420
|
+
self.send_header(
|
|
421
|
+
"Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"
|
|
422
|
+
)
|
|
423
|
+
self.send_header(
|
|
424
|
+
"Access-Control-Allow-Headers", "Content-Type, Authorization"
|
|
425
|
+
)
|
|
426
|
+
self.end_headers()
|
|
427
|
+
self.wfile.write(body)
|
|
428
|
+
|
|
429
|
+
def _read_body(self):
|
|
430
|
+
# v5.6.2 安全修复:Content-Length 非数值时返回 None,不抛异常
|
|
431
|
+
try:
|
|
432
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
433
|
+
except (ValueError, TypeError):
|
|
434
|
+
return None
|
|
435
|
+
if content_length == 0:
|
|
436
|
+
# v5.5.3 fix: 返回 None 让调用方统一处理错误响应,避免双重写入
|
|
437
|
+
return None
|
|
438
|
+
# v5.4.8 安全修复:请求体大小限制
|
|
439
|
+
if content_length > MAX_BODY_SIZE:
|
|
440
|
+
# v5.9.0 P3-3:有界 drain 后响应 413——客户端真实在传 body 时
|
|
441
|
+
# 读掉有限量剩余数据(避免 Broken pipe),但声明大 body 却不
|
|
442
|
+
# 发送的客户端(挂起攻击)在短超时后立即得到 413,不阻塞。
|
|
443
|
+
drained = 0
|
|
444
|
+
try:
|
|
445
|
+
self.connection.settimeout(2.0)
|
|
446
|
+
cap = min(content_length, MAX_BODY_SIZE + 1_048_576)
|
|
447
|
+
while drained < cap:
|
|
448
|
+
chunk = self.rfile.read(min(65536, cap - drained))
|
|
449
|
+
if not chunk:
|
|
450
|
+
break
|
|
451
|
+
drained += len(chunk)
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
finally:
|
|
455
|
+
try:
|
|
456
|
+
self.connection.settimeout(None)
|
|
457
|
+
except Exception:
|
|
458
|
+
pass
|
|
459
|
+
self._send_json(
|
|
460
|
+
{
|
|
461
|
+
"error": f"Request body too large (max {MAX_BODY_SIZE // 1024 // 1024}MB)"
|
|
462
|
+
},
|
|
463
|
+
413,
|
|
464
|
+
)
|
|
465
|
+
return None
|
|
466
|
+
if content_length < 0:
|
|
467
|
+
return None
|
|
468
|
+
raw = self.rfile.read(content_length)
|
|
469
|
+
try:
|
|
470
|
+
# P2 #23 修复:外部输入用安全 JSON 解析(深度+大小限制)
|
|
471
|
+
from mindforge.core.storage import _safe_json_loads
|
|
472
|
+
|
|
473
|
+
return _safe_json_loads(raw.decode("utf-8"))
|
|
474
|
+
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
def _extract_mem_id(self, path: str, prefix: str = "/api/memories/") -> str:
|
|
478
|
+
"""从路径中安全提取记忆 ID(v5.6.2 安全修复:防路径遍历/注入)
|
|
479
|
+
|
|
480
|
+
拒绝包含路径分隔符、空字节、超长值等可疑输入。
|
|
481
|
+
"""
|
|
482
|
+
if not path.startswith(prefix):
|
|
483
|
+
return ""
|
|
484
|
+
mem_id = path[len(prefix) :]
|
|
485
|
+
# v5.6.2 安全校验:拒绝路径遍历字符、空字节、过长 ID
|
|
486
|
+
if not mem_id or len(mem_id) > 128:
|
|
487
|
+
return ""
|
|
488
|
+
if "\x00" in mem_id or "/" in mem_id or "\\" in mem_id:
|
|
489
|
+
return ""
|
|
490
|
+
if ".." in mem_id:
|
|
491
|
+
return ""
|
|
492
|
+
return mem_id
|
|
493
|
+
|
|
494
|
+
def _client_identity_trusted(self) -> bool:
|
|
495
|
+
"""v5.6.5 P1 #9:是否信任客户端自报身份(X-Agent-Id / agent)。
|
|
496
|
+
|
|
497
|
+
``X-Agent-Id`` 可被任意客户端伪造,默认**不信任**,防止通过冒充其他
|
|
498
|
+
Agent 身份绕过 list/export 的隐私过滤。仅当运维显式设置
|
|
499
|
+
``MINDFORGE_TRUST_AGENT_HEADER=1`` 且已配置网关级 Bearer 认证
|
|
500
|
+
(MINDFORGE_API_KEY)时才接受该头——适用于身份由可信网关鉴权后
|
|
501
|
+
透传的部署。完整的端到端 Agent 身份联邦属于 v6.0 方案。
|
|
502
|
+
"""
|
|
503
|
+
if not os.environ.get("MINDFORGE_API_KEY", ""):
|
|
504
|
+
return False # 无网关认证的裸 noauth 模式绝不信任自报身份
|
|
505
|
+
flag = os.environ.get("MINDFORGE_TRUST_AGENT_HEADER", "").strip().lower()
|
|
506
|
+
return flag in ("1", "true", "yes", "on")
|
|
507
|
+
|
|
508
|
+
def _extract_actor(self, qs: Dict[str, list]) -> str:
|
|
509
|
+
"""提取用于 list/export 隐私过滤的调用者身份(F1,v5.6.5 P1 #9 加固)。
|
|
510
|
+
|
|
511
|
+
- 可信部署(见 _client_identity_trusted):``X-Agent-Id`` 头 > ``agent`` 查询参数;
|
|
512
|
+
- 默认可信度不足:忽略客户端自报身份,回落到服务端固定的
|
|
513
|
+
``MINDFORGE_AGENT_ID``(未配置则为 "",即本地单属主不过滤),
|
|
514
|
+
杜绝伪造 Agent 身份跨租户读取。
|
|
515
|
+
"""
|
|
516
|
+
if self._client_identity_trusted():
|
|
517
|
+
try:
|
|
518
|
+
header_actor = (self.headers.get("X-Agent-Id") or "").strip()
|
|
519
|
+
except Exception:
|
|
520
|
+
header_actor = ""
|
|
521
|
+
if header_actor:
|
|
522
|
+
return header_actor[:128]
|
|
523
|
+
query_actor = (qs.get("agent", [""])[0] or "").strip()
|
|
524
|
+
return query_actor[:128]
|
|
525
|
+
# 默认:不采信任何客户端自报身份
|
|
526
|
+
server_agent = (os.environ.get("MINDFORGE_AGENT_ID", "") or "").strip()[:128]
|
|
527
|
+
if server_agent:
|
|
528
|
+
return server_agent
|
|
529
|
+
# v5.7.3 安全修复:已配置 API Key 的部署中,未声明的调用者一律按
|
|
530
|
+
# anonymous 处理(fail-closed),仅可访问 PUBLIC 级记忆;杜绝空身份
|
|
531
|
+
# 绕过 _filter_by_privacy 全量读取 PRIVATE/STRICT 记忆。
|
|
532
|
+
if (os.environ.get("MINDFORGE_API_KEY", "") or "").strip():
|
|
533
|
+
return "anonymous"
|
|
534
|
+
# 本地单用户无 API Key 模式:保留空身份(本机全量可见),兼容 CLI 场景
|
|
535
|
+
return ""
|
|
536
|
+
|
|
537
|
+
def _check_auth(self):
|
|
538
|
+
"""验证 Bearer Token(通过 MINDFORGE_API_KEY 环境变量配置)
|
|
539
|
+
|
|
540
|
+
v5.6.3 安全增强(P1 #12 修复):
|
|
541
|
+
- MINDFORGE_ALLOW_NOAUTH 默认 "0"(fail-closed),多用户主机安全。
|
|
542
|
+
单用户本地使用需显式设 MINDFORGE_ALLOW_NOAUTH=1 开启无认证模式。
|
|
543
|
+
- 设置 MINDFORGE_API_KEY 后,所有非 /api/health 端点必须携带正确 Bearer Token。
|
|
544
|
+
- 非 localhost 绑定且无 API Key 时,启动阶段直接拒绝(fail-closed)。
|
|
545
|
+
"""
|
|
546
|
+
api_key = os.environ.get("MINDFORGE_API_KEY", "")
|
|
547
|
+
if not api_key:
|
|
548
|
+
# P1 修复:默认 fail-closed,显式设置 ALLOW_NOAUTH=1 才开放本地无认证
|
|
549
|
+
allow_noauth = os.environ.get("MINDFORGE_ALLOW_NOAUTH", "0").strip().lower()
|
|
550
|
+
if allow_noauth not in ("1", "true", "yes", "on"):
|
|
551
|
+
self._send_json(
|
|
552
|
+
{"error": "Unauthorized (MINDFORGE_API_KEY required)"}, 401
|
|
553
|
+
)
|
|
554
|
+
return False
|
|
555
|
+
# 显式开启的无认证模式:记录一次性告警
|
|
556
|
+
if not getattr(self.__class__, "_auth_warned", False):
|
|
557
|
+
logger.warning(
|
|
558
|
+
"MINDFORGE_API_KEY not set — API is open to all LOCAL requests. "
|
|
559
|
+
"Set MINDFORGE_API_KEY to require authentication, or set "
|
|
560
|
+
"MINDFORGE_ALLOW_NOAUTH=0 to force auth on all interfaces. "
|
|
561
|
+
"Binding to a non-localhost host without MINDFORGE_API_KEY is refused at startup."
|
|
562
|
+
)
|
|
563
|
+
self.__class__._auth_warned = True
|
|
564
|
+
return True
|
|
565
|
+
auth_header = self.headers.get("Authorization", "")
|
|
566
|
+
if auth_header.startswith("Bearer "):
|
|
567
|
+
token = auth_header[7:]
|
|
568
|
+
if hmac.compare_digest(token, api_key):
|
|
569
|
+
return True
|
|
570
|
+
self._send_json({"error": "Unauthorized"}, 401)
|
|
571
|
+
return False
|
|
572
|
+
|
|
573
|
+
def do_OPTIONS(self):
|
|
574
|
+
# v5.6.2 安全修复:OPTIONS 也走限流,防止 CORS Preflight DoS
|
|
575
|
+
if not self._check_rate_limit():
|
|
576
|
+
return
|
|
577
|
+
self._send_json({"status": "ok"})
|
|
578
|
+
|
|
579
|
+
def _check_rate_limit(self, write: bool = False, heavy: bool = False) -> bool:
|
|
580
|
+
"""v5.6.1 安全修复:统一限流检查(所有 HTTP 方法共用)。
|
|
581
|
+
返回 True 表示允许,False 表示已返回 429。
|
|
582
|
+
|
|
583
|
+
v5.7.0 P2 分级限流:
|
|
584
|
+
- 读路径:全局 100 req/60s/IP;
|
|
585
|
+
- 写路径(POST/PUT/DELETE):额外 30 req/60s/IP;
|
|
586
|
+
- 批量/导入端点:额外 10 req/60s/IP。
|
|
587
|
+
"""
|
|
588
|
+
client_ip = self.client_address[0]
|
|
589
|
+
rate_key = _normalize_ip(client_ip)
|
|
590
|
+
if not _rate_limiter.check(rate_key):
|
|
591
|
+
self._send_json({"error": "Rate limit exceeded. Try again later."}, 429)
|
|
592
|
+
return False
|
|
593
|
+
if write and not _write_limiter.check(rate_key):
|
|
594
|
+
self._send_json({"error": "Rate limit exceeded for write operations."}, 429)
|
|
595
|
+
return False
|
|
596
|
+
if heavy and not _import_limiter.check(rate_key):
|
|
597
|
+
self._send_json({"error": "Rate limit exceeded for bulk operations."}, 429)
|
|
598
|
+
return False
|
|
599
|
+
return True
|
|
600
|
+
|
|
601
|
+
def do_GET(self):
|
|
602
|
+
parsed = urlparse(self.path)
|
|
603
|
+
path = parsed.path.rstrip("/") or "/"
|
|
604
|
+
qs = parse_qs(parsed.query)
|
|
605
|
+
|
|
606
|
+
# v5.4.7 修复 H-7:速率限制
|
|
607
|
+
if not self._check_rate_limit():
|
|
608
|
+
return
|
|
609
|
+
|
|
610
|
+
if path != "/api/health" and not self._check_auth():
|
|
611
|
+
return
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
if path == "/api/health":
|
|
615
|
+
try:
|
|
616
|
+
result = self.mindforge.health_check()
|
|
617
|
+
except Exception as he:
|
|
618
|
+
# v5.6.5 P3 #22:存储不可用属服务暂时不可用,返回 503 而非 500
|
|
619
|
+
logger.error("health_check storage failure: %s", he)
|
|
620
|
+
self._send_json(
|
|
621
|
+
{"status": "unhealthy", "error": "storage unavailable"}, 503
|
|
622
|
+
)
|
|
623
|
+
return
|
|
624
|
+
# P2-02 安全修复:/api/health 全程无需认证(见 do_GET 的分发条件),
|
|
625
|
+
# 因此无论是否配置 MINDFORGE_API_KEY,都只回最小化状态,绝不外泄
|
|
626
|
+
# total_memories / db_size_bytes / 缺失索引名 / recommendations 等指纹信息。
|
|
627
|
+
# 需要详细健康诊断请使用已认证的 /api/stats 等端点。
|
|
628
|
+
self._send_json({"status": result.get("status", "unknown")})
|
|
629
|
+
|
|
630
|
+
elif path == "/api/stats":
|
|
631
|
+
result = self.mindforge.stats()
|
|
632
|
+
self._send_json(result)
|
|
633
|
+
|
|
634
|
+
elif path == "/api/meta/report":
|
|
635
|
+
# v5.9.0 记忆健康报告:仅统计与元数据,不含明文,位于鉴权网关之后
|
|
636
|
+
self._send_json(self.mindforge.memory_report())
|
|
637
|
+
|
|
638
|
+
elif path == "/api/meta/privacy":
|
|
639
|
+
# v5.9.2 隐私健康体检:仅统计与元数据,不含明文,位于鉴权网关之后
|
|
640
|
+
self._send_json(self.mindforge.privacy_report())
|
|
641
|
+
|
|
642
|
+
elif path == "/api/meta/graph":
|
|
643
|
+
# v5.9.3 知识图谱导出(不含记忆明文,max_privacy 隐私过滤)
|
|
644
|
+
mp = (qs.get("max_privacy", ["INTERNAL"])[0] or "INTERNAL").upper()
|
|
645
|
+
if mp not in ("PUBLIC", "INTERNAL", "PRIVATE", "STRICT"):
|
|
646
|
+
mp = "INTERNAL"
|
|
647
|
+
self._send_json(self.mindforge.export_graph(max_privacy=mp))
|
|
648
|
+
|
|
649
|
+
elif path == "/api/audit":
|
|
650
|
+
# v5.9.0 审计日志导出(GDPR/取证):limit/since/actor 过滤
|
|
651
|
+
limit = _safe_int(
|
|
652
|
+
qs.get("limit", ["100"])[0], default=100, min_val=1, max_val=10000
|
|
653
|
+
)
|
|
654
|
+
since = 0.0
|
|
655
|
+
try:
|
|
656
|
+
since = float(qs.get("since", ["0"])[0])
|
|
657
|
+
except (ValueError, TypeError):
|
|
658
|
+
since = 0.0
|
|
659
|
+
actor = qs.get("actor", [""])[0] or ""
|
|
660
|
+
self._send_json({
|
|
661
|
+
"count": limit,
|
|
662
|
+
"entries": self.mindforge.export_audit(
|
|
663
|
+
limit=limit, since=since, actor=actor),
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
elif path == "/api/tags":
|
|
667
|
+
conn = self.mindforge.storage._get_conn()
|
|
668
|
+
rows = conn.execute(
|
|
669
|
+
"SELECT tags FROM memories WHERE tags IS NOT NULL AND tags != '' AND tags != '[]' LIMIT 10000" # P1-002: 限制查询行数
|
|
670
|
+
).fetchall()
|
|
671
|
+
tag_counts = {}
|
|
672
|
+
for row in rows:
|
|
673
|
+
try:
|
|
674
|
+
tags = (
|
|
675
|
+
json.loads(row[0])
|
|
676
|
+
if row[0].strip().startswith("[")
|
|
677
|
+
else [t.strip() for t in row[0].split(",") if t.strip()]
|
|
678
|
+
)
|
|
679
|
+
except (json.JSONDecodeError, TypeError):
|
|
680
|
+
continue
|
|
681
|
+
if not isinstance(tags, list):
|
|
682
|
+
continue
|
|
683
|
+
for tag in tags:
|
|
684
|
+
# P2-04:JSON 与逗号分隔两条路径统一消毒后再计数
|
|
685
|
+
clean = _sanitize_tag(tag)
|
|
686
|
+
if not clean:
|
|
687
|
+
continue
|
|
688
|
+
tag_counts[clean] = tag_counts.get(clean, 0) + 1
|
|
689
|
+
self._send_json(
|
|
690
|
+
{
|
|
691
|
+
"tags": sorted(
|
|
692
|
+
tag_counts.items(), key=lambda x: x[1], reverse=True
|
|
693
|
+
)
|
|
694
|
+
}
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
elif path == "/api/search":
|
|
698
|
+
q = qs.get("q", [""])[0]
|
|
699
|
+
if not q:
|
|
700
|
+
self._send_json({"error": "Missing query parameter 'q'"}, 400)
|
|
701
|
+
return
|
|
702
|
+
# v5.4.7 修复 H-1:安全解析参数
|
|
703
|
+
limit = _safe_int(
|
|
704
|
+
qs.get("limit", ["10"])[0], default=10, min_val=1, max_val=1000
|
|
705
|
+
)
|
|
706
|
+
min_relevance = _safe_float(
|
|
707
|
+
qs.get("min_relevance", ["0.3"])[0],
|
|
708
|
+
default=0.3,
|
|
709
|
+
min_val=0.0,
|
|
710
|
+
max_val=1.0,
|
|
711
|
+
)
|
|
712
|
+
categories = qs.get("categories", None)
|
|
713
|
+
# v6.3.2: explain=1/true 时返回每条结果的得分分解
|
|
714
|
+
explain_raw = (qs.get("explain", ["false"])[0] or "false").strip().lower()
|
|
715
|
+
explain = explain_raw in ("1", "true", "yes", "on")
|
|
716
|
+
result = self.mindforge.search(
|
|
717
|
+
query=q,
|
|
718
|
+
max_results=limit,
|
|
719
|
+
min_relevance=min_relevance,
|
|
720
|
+
categories=categories,
|
|
721
|
+
# v5.7.3 安全修复:透传调用者身份,search 内部按隐私等级过滤
|
|
722
|
+
agent_id=self._extract_actor(qs),
|
|
723
|
+
session_id=(qs.get("session", [""])[0] or ""),
|
|
724
|
+
explain=explain,
|
|
725
|
+
)
|
|
726
|
+
chunks = []
|
|
727
|
+
if hasattr(result, "chunks"):
|
|
728
|
+
for chunk in result.chunks:
|
|
729
|
+
item = {
|
|
730
|
+
"id": chunk.memory_id,
|
|
731
|
+
"content": chunk.content,
|
|
732
|
+
"category": chunk.category,
|
|
733
|
+
"relevance_score": chunk.relevance_score,
|
|
734
|
+
"tags": chunk.tags if hasattr(chunk, "tags") else [],
|
|
735
|
+
}
|
|
736
|
+
# v6.3.2: 仅 explain=true 时携带(不请求则响应形状与旧版完全一致)
|
|
737
|
+
if explain:
|
|
738
|
+
item["score_breakdown"] = getattr(
|
|
739
|
+
chunk, "score_breakdown", None
|
|
740
|
+
)
|
|
741
|
+
chunks.append(item)
|
|
742
|
+
self._send_json(
|
|
743
|
+
{
|
|
744
|
+
"query": q,
|
|
745
|
+
"results": chunks,
|
|
746
|
+
"total": len(chunks),
|
|
747
|
+
"approximate": getattr(result, "approximate", False),
|
|
748
|
+
}
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
elif path == "/api/memories":
|
|
752
|
+
# v5.4.7 修复 H-1:安全解析参数
|
|
753
|
+
limit = _safe_int(
|
|
754
|
+
qs.get("limit", ["50"])[0], default=50, min_val=1, max_val=10000
|
|
755
|
+
)
|
|
756
|
+
offset = _safe_int(
|
|
757
|
+
qs.get("offset", ["0"])[0], default=0, min_val=0, max_val=1000000
|
|
758
|
+
)
|
|
759
|
+
# v5.7.0 P2:分类参数消毒
|
|
760
|
+
category = _sanitize_category(
|
|
761
|
+
qs.get("category", [None])[0], default=None
|
|
762
|
+
)
|
|
763
|
+
# F1 修复:列表接口接入隐私过滤(X-Agent-Id 头 / agent 查询参数)
|
|
764
|
+
entries = self.mindforge.list(
|
|
765
|
+
category=category,
|
|
766
|
+
limit=limit,
|
|
767
|
+
offset=offset,
|
|
768
|
+
actor=self._extract_actor(qs),
|
|
769
|
+
session_id=(qs.get("session", [""])[0] or ""),
|
|
770
|
+
)
|
|
771
|
+
memories = []
|
|
772
|
+
for e in entries:
|
|
773
|
+
memories.append(e.to_dict() if hasattr(e, "to_dict") else vars(e))
|
|
774
|
+
self._send_json(
|
|
775
|
+
{
|
|
776
|
+
"memories": memories,
|
|
777
|
+
"total": len(memories),
|
|
778
|
+
"limit": limit,
|
|
779
|
+
"offset": offset,
|
|
780
|
+
}
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
elif path.startswith("/api/memories/"):
|
|
784
|
+
mem_id = self._extract_mem_id(path)
|
|
785
|
+
if not mem_id:
|
|
786
|
+
self._send_json({"error": "Invalid memory ID"}, 400)
|
|
787
|
+
return
|
|
788
|
+
entry = self.mindforge.get(
|
|
789
|
+
mem_id,
|
|
790
|
+
# v5.7.3 安全修复:透传调用者身份,单条读取同样校验隐私等级
|
|
791
|
+
actor=self._extract_actor(qs),
|
|
792
|
+
session_id=(qs.get("session", [""])[0] or ""),
|
|
793
|
+
)
|
|
794
|
+
if entry:
|
|
795
|
+
self._send_json(
|
|
796
|
+
entry.to_dict() if hasattr(entry, "to_dict") else vars(entry)
|
|
797
|
+
)
|
|
798
|
+
else:
|
|
799
|
+
self._send_json({"error": "Memory not found"}, 404)
|
|
800
|
+
|
|
801
|
+
elif path == "/api/export":
|
|
802
|
+
max_export_limit = 5000 # P1-001: 导出上限 5000 条
|
|
803
|
+
# F1 修复:导出同样是记忆读取入口,必须带上调用者身份做隐私过滤
|
|
804
|
+
entries = self.mindforge.list(
|
|
805
|
+
limit=max_export_limit,
|
|
806
|
+
actor=self._extract_actor(qs),
|
|
807
|
+
session_id=(qs.get("session", [""])[0] or ""),
|
|
808
|
+
)
|
|
809
|
+
memories = [
|
|
810
|
+
e.to_dict() if hasattr(e, "to_dict") else vars(e) for e in entries
|
|
811
|
+
]
|
|
812
|
+
# P3-3: 截断时添加 truncated 标志
|
|
813
|
+
truncated = len(entries) >= max_export_limit
|
|
814
|
+
self._send_json(
|
|
815
|
+
{
|
|
816
|
+
"version": MF_VERSION,
|
|
817
|
+
"total": len(memories),
|
|
818
|
+
"truncated": truncated,
|
|
819
|
+
"max_limit": max_export_limit if truncated else None,
|
|
820
|
+
"memories": memories,
|
|
821
|
+
}
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
elif path == "/api/valid-at":
|
|
825
|
+
# v5.7.6 新增:Bi-temporal as-of 查询(事实有效时间)
|
|
826
|
+
ts_raw = qs.get("ts", [None])[0]
|
|
827
|
+
ts = None
|
|
828
|
+
if ts_raw is not None:
|
|
829
|
+
try:
|
|
830
|
+
ts = float(ts_raw)
|
|
831
|
+
except (TypeError, ValueError):
|
|
832
|
+
self._send_json({"error": "Invalid 'ts' parameter"}, 400)
|
|
833
|
+
return
|
|
834
|
+
limit = _safe_int(
|
|
835
|
+
qs.get("limit", ["50"])[0], default=50, min_val=1, max_val=10000
|
|
836
|
+
)
|
|
837
|
+
category = _sanitize_category(
|
|
838
|
+
qs.get("category", [None])[0], default=None
|
|
839
|
+
)
|
|
840
|
+
entries = self.mindforge.valid_at(
|
|
841
|
+
timestamp=ts,
|
|
842
|
+
category=category,
|
|
843
|
+
limit=limit,
|
|
844
|
+
actor=self._extract_actor(qs),
|
|
845
|
+
session_id=(qs.get("session", [""])[0] or ""),
|
|
846
|
+
)
|
|
847
|
+
memories = [
|
|
848
|
+
e.to_dict() if hasattr(e, "to_dict") else vars(e) for e in entries
|
|
849
|
+
]
|
|
850
|
+
self._send_json(
|
|
851
|
+
{"as_of": ts, "total": len(memories), "memories": memories}
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
elif path == "/api/embedding/status":
|
|
856
|
+
# v5.7.8:嵌入向量状态(后端/模型/维度/已嵌入数)
|
|
857
|
+
try:
|
|
858
|
+
st = self.mindforge.get_embedding_status()
|
|
859
|
+
self._send_json(st)
|
|
860
|
+
except Exception as e:
|
|
861
|
+
_log_unhandled_api_error(e)
|
|
862
|
+
self._send_json({"error": "Internal server error"}, 500)
|
|
863
|
+
elif path == "/api/skills/stats":
|
|
864
|
+
self._send_json(self.mindforge.skill_stats())
|
|
865
|
+
elif path == "/api/skills/match":
|
|
866
|
+
query = qs.get("query", [""])[0]
|
|
867
|
+
if not query:
|
|
868
|
+
self._send_json({"error": "Missing 'query' param"}, 400)
|
|
869
|
+
return
|
|
870
|
+
try:
|
|
871
|
+
limit = int(qs.get("limit", ["5"])[0] or 5)
|
|
872
|
+
except ValueError:
|
|
873
|
+
limit = 5
|
|
874
|
+
self._send_json({"query": query,
|
|
875
|
+
"skills": self.mindforge.skill_match(query, limit=limit)})
|
|
876
|
+
elif path == "/api/skills":
|
|
877
|
+
try:
|
|
878
|
+
limit = int(qs.get("limit", ["50"])[0] or 50)
|
|
879
|
+
except ValueError:
|
|
880
|
+
limit = 50
|
|
881
|
+
self._send_json({"skills": self.mindforge.skill_list(limit=limit)})
|
|
882
|
+
elif path == "/api/skills/render":
|
|
883
|
+
name = qs.get("name", [""])[0]
|
|
884
|
+
if not name:
|
|
885
|
+
self._send_json({"error": "Missing 'name' param"}, 400)
|
|
886
|
+
return
|
|
887
|
+
params = {}
|
|
888
|
+
for p in qs.get("param", []):
|
|
889
|
+
if ":" in p:
|
|
890
|
+
k, v = p.split(":", 1)
|
|
891
|
+
params[k] = v
|
|
892
|
+
out = self.mindforge.skill_render(name, **params)
|
|
893
|
+
if out is None:
|
|
894
|
+
self._send_json({"error": f"skill not found: {name}"}, 404)
|
|
895
|
+
return
|
|
896
|
+
self._send_json({"name": name, "rendered": out})
|
|
897
|
+
elif path == "/api/experience/cases":
|
|
898
|
+
try:
|
|
899
|
+
limit = int(qs.get("limit", ["20"])[0] or 20)
|
|
900
|
+
except ValueError:
|
|
901
|
+
limit = 20
|
|
902
|
+
self._send_json({"cases": self.mindforge.skill_cases(
|
|
903
|
+
limit=limit, outcome=qs.get("outcome", [""])[0])})
|
|
904
|
+
elif path == "/api/graph/stats":
|
|
905
|
+
self._send_json(self.mindforge.graph_stats())
|
|
906
|
+
elif path == "/api/graph/related":
|
|
907
|
+
entity = qs.get("entity", [""])[0]
|
|
908
|
+
if not entity:
|
|
909
|
+
self._send_json({"error": "Missing 'entity' param"}, 400)
|
|
910
|
+
return
|
|
911
|
+
try:
|
|
912
|
+
depth = int(qs.get("depth", ["2"])[0] or 2)
|
|
913
|
+
except ValueError:
|
|
914
|
+
depth = 2
|
|
915
|
+
self._send_json({"entity": entity, "depth": depth,
|
|
916
|
+
"related": self.mindforge.graph_related(entity, depth=depth)})
|
|
917
|
+
elif path == "/api/graph/path":
|
|
918
|
+
frm = qs.get("from", [""])[0]
|
|
919
|
+
to = qs.get("to", [""])[0]
|
|
920
|
+
if not frm or not to:
|
|
921
|
+
self._send_json({"error": "Missing 'from'/'to' params"}, 400)
|
|
922
|
+
return
|
|
923
|
+
self._send_json(self.mindforge.graph_path(frm, to) or {"path": None})
|
|
924
|
+
elif path == "/api/connectors":
|
|
925
|
+
from mindforge.modules.connectors import list_connectors
|
|
926
|
+
self._send_json({"connectors": list_connectors()})
|
|
927
|
+
elif path == "/api/timeline":
|
|
928
|
+
# v6.0.0 功能 2:记忆时间线可视化(聚合统计,不含明文)
|
|
929
|
+
try:
|
|
930
|
+
days = int(qs.get("days", ["365"])[0] or 365)
|
|
931
|
+
except (ValueError, TypeError):
|
|
932
|
+
days = 365
|
|
933
|
+
self._send_json(self.mindforge.timeline_visual(
|
|
934
|
+
category=qs.get("category", [""])[0] or None, days=days))
|
|
935
|
+
elif path == "/api/skill-pack/export":
|
|
936
|
+
# v6.0.0 功能 3:导出技能包
|
|
937
|
+
self._send_json(self.mindforge.skill_pack_export())
|
|
938
|
+
elif path == "/api/passport/export":
|
|
939
|
+
# v6.0.0 功能 6:导出记忆护照
|
|
940
|
+
self._send_json(self.mindforge.passport_export())
|
|
941
|
+
elif path == "/api/passport/status":
|
|
942
|
+
# v6.0.0 功能 6:护照状态
|
|
943
|
+
self._send_json(self.mindforge.passport_status())
|
|
944
|
+
elif path == "/api/evolution/stats":
|
|
945
|
+
# v6.0.0 功能 4:演变统计
|
|
946
|
+
self._send_json(self.mindforge.evolution_stats())
|
|
947
|
+
elif path == "/api/evolution/history":
|
|
948
|
+
# v6.0.0 功能 4:演变历史
|
|
949
|
+
mid = qs.get("memory_id", [""])[0]
|
|
950
|
+
if not mid:
|
|
951
|
+
self._send_json({"error": "Missing 'memory_id' param"}, 400)
|
|
952
|
+
return
|
|
953
|
+
try:
|
|
954
|
+
limit = int(qs.get("limit", ["50"])[0] or 50)
|
|
955
|
+
except (ValueError, TypeError):
|
|
956
|
+
limit = 50
|
|
957
|
+
self._send_json({"memory_id": mid,
|
|
958
|
+
"evolutions": self.mindforge.list_evolutions(mid, limit=limit)})
|
|
959
|
+
elif path == "/api/outcome/stats":
|
|
960
|
+
# v6.0.0 功能 5:结果学习统计
|
|
961
|
+
self._send_json(self.mindforge.outcome_stats())
|
|
962
|
+
elif path == "/api/forget/stats":
|
|
963
|
+
# v6.0.0 功能 7:遗忘统计
|
|
964
|
+
self._send_json(self.mindforge.forget_stats())
|
|
965
|
+
elif path == "/api/forget/explain":
|
|
966
|
+
# v6.0.0 功能 7:解释遗忘
|
|
967
|
+
mid = qs.get("memory_id", [""])[0]
|
|
968
|
+
if not mid:
|
|
969
|
+
self._send_json({"error": "Missing 'memory_id' param"}, 400)
|
|
970
|
+
return
|
|
971
|
+
self._send_json(self.mindforge.forget_explain(mid))
|
|
972
|
+
elif path == "/api/follow-ups/due":
|
|
973
|
+
# v6.0.0 功能 8:到期跟进
|
|
974
|
+
try:
|
|
975
|
+
limit = int(qs.get("limit", ["20"])[0] or 20)
|
|
976
|
+
except (ValueError, TypeError):
|
|
977
|
+
limit = 20
|
|
978
|
+
self._send_json({"due": self.mindforge.follow_up_due(limit=limit)})
|
|
979
|
+
elif path == "/api/follow-ups":
|
|
980
|
+
# v6.0.0 功能 8:跟进列表
|
|
981
|
+
try:
|
|
982
|
+
limit = int(qs.get("limit", ["50"])[0] or 50)
|
|
983
|
+
except (ValueError, TypeError):
|
|
984
|
+
limit = 50
|
|
985
|
+
include_resolved = qs.get("include_resolved", ["false"])[0].lower() == "true"
|
|
986
|
+
self._send_json({"follow_ups": self.mindforge.follow_up_list(
|
|
987
|
+
include_resolved=include_resolved, limit=limit)})
|
|
988
|
+
|
|
989
|
+
elif path == "/api/v6/status":
|
|
990
|
+
# v6.0.0 正式版:v6 端口总览(多 Agent 协作 + 6G AI 记忆预览)
|
|
991
|
+
self._send_json(self.mindforge.v6_status())
|
|
992
|
+
elif path == "/api/preview/channels":
|
|
993
|
+
# v6.0.0 正式版:6G 预览通道列表
|
|
994
|
+
include_closed = qs.get("include_closed", ["false"])[0].lower() == "true"
|
|
995
|
+
self._send_json({
|
|
996
|
+
"channels": self.mindforge.memory_preview.list_preview_channels(
|
|
997
|
+
include_closed=include_closed),
|
|
998
|
+
})
|
|
999
|
+
elif path == "/api/preview/index":
|
|
1000
|
+
# v6.0.0 正式版:6G 增量索引统计
|
|
1001
|
+
self._send_json(self.mindforge.memory_preview.incremental_index_stats())
|
|
1002
|
+
elif path == "/api/preview/timeline":
|
|
1003
|
+
# v6.0.0 正式版:6G 单记忆时间线
|
|
1004
|
+
mid = qs.get("memory_id", [""])[0]
|
|
1005
|
+
if not mid:
|
|
1006
|
+
self._send_json({"error": "Missing 'memory_id' param"}, 400)
|
|
1007
|
+
return
|
|
1008
|
+
self._send_json(self.mindforge.memory_preview.timeline(mid))
|
|
1009
|
+
|
|
1010
|
+
elif path == "/":
|
|
1011
|
+
# v5.6.5 P2 #16:根路径不再枚举完整 API 端点清单,
|
|
1012
|
+
# 避免在信息探测阶段向未授权方暴露攻击面。
|
|
1013
|
+
self._send_json(
|
|
1014
|
+
{
|
|
1015
|
+
"name": "MindForge REST API",
|
|
1016
|
+
"version": MF_VERSION,
|
|
1017
|
+
"health": "/api/health",
|
|
1018
|
+
}
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
else:
|
|
1022
|
+
self._send_json({"error": "Not found"}, 404)
|
|
1023
|
+
|
|
1024
|
+
except Exception as e:
|
|
1025
|
+
_log_unhandled_api_error(e)
|
|
1026
|
+
self._send_json({"error": "Internal server error"}, 500)
|
|
1027
|
+
|
|
1028
|
+
def do_POST(self):
|
|
1029
|
+
parsed = urlparse(self.path)
|
|
1030
|
+
path = parsed.path.rstrip("/") or "/"
|
|
1031
|
+
|
|
1032
|
+
# v5.6.1: 写操作也加限流;v5.7.0 P2:写路径走更严配额
|
|
1033
|
+
if not self._check_rate_limit(write=True):
|
|
1034
|
+
return
|
|
1035
|
+
|
|
1036
|
+
if not self._check_auth():
|
|
1037
|
+
return
|
|
1038
|
+
|
|
1039
|
+
try:
|
|
1040
|
+
body = self._read_body()
|
|
1041
|
+
if body is None:
|
|
1042
|
+
self._send_json({"error": "Invalid JSON body"}, 400)
|
|
1043
|
+
return
|
|
1044
|
+
# v5.5.8: 校验 JSON body 必须是 dict(非数组/字符串/数字)
|
|
1045
|
+
if not isinstance(body, dict):
|
|
1046
|
+
self._send_json({"error": "Request body must be a JSON object"}, 400)
|
|
1047
|
+
return
|
|
1048
|
+
|
|
1049
|
+
if path == "/api/memories":
|
|
1050
|
+
content = body.get("content", "")
|
|
1051
|
+
# v5.6.3 安全修复:content 必须是字符串,否则下游 add() 会抛
|
|
1052
|
+
# ValueError 并最终返回 500。在此提前返回 400,给出明确错误。
|
|
1053
|
+
if not isinstance(content, str):
|
|
1054
|
+
self._send_json({"error": "Field 'content' must be a string"}, 400)
|
|
1055
|
+
return
|
|
1056
|
+
if not content:
|
|
1057
|
+
self._send_json({"error": "Missing 'content' field"}, 400)
|
|
1058
|
+
return
|
|
1059
|
+
# v5.7.0 P2:统一参数校验层
|
|
1060
|
+
try:
|
|
1061
|
+
category = _sanitize_category(
|
|
1062
|
+
body.get("category", "general"), default="general"
|
|
1063
|
+
)
|
|
1064
|
+
tags = _validate_tags(body.get("tags", []))
|
|
1065
|
+
importance = _validate_importance(body.get("importance"))
|
|
1066
|
+
starred = _validate_starred(body.get("starred"))
|
|
1067
|
+
source_agent = _validate_source_agent(body.get("source_agent", ""))
|
|
1068
|
+
valid_from = _validate_float_ts(body.get("valid_from"))
|
|
1069
|
+
valid_to = _validate_float_ts(body.get("valid_to"))
|
|
1070
|
+
except ValueError as ve:
|
|
1071
|
+
self._send_json({"error": str(ve)}, 400)
|
|
1072
|
+
return
|
|
1073
|
+
entry = self.mindforge.add(
|
|
1074
|
+
content=content,
|
|
1075
|
+
category=category,
|
|
1076
|
+
tags=tags,
|
|
1077
|
+
importance=importance,
|
|
1078
|
+
starred=starred,
|
|
1079
|
+
source_agent=source_agent,
|
|
1080
|
+
valid_from=valid_from,
|
|
1081
|
+
valid_to=valid_to,
|
|
1082
|
+
)
|
|
1083
|
+
self._send_json(
|
|
1084
|
+
entry.to_dict() if hasattr(entry, "to_dict") else vars(entry), 201
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
elif path == "/api/agent/forget":
|
|
1088
|
+
mid = body.get("id", "")
|
|
1089
|
+
if not isinstance(mid, str) or not mid:
|
|
1090
|
+
self._send_json({"error": "Missing 'id' field"}, 400)
|
|
1091
|
+
return
|
|
1092
|
+
ok = self.mindforge.agent_forget(
|
|
1093
|
+
mid, reason=body.get("reason", ""),
|
|
1094
|
+
actor=body.get("actor", ""),
|
|
1095
|
+
session_id=body.get("session_id", ""))
|
|
1096
|
+
if not ok:
|
|
1097
|
+
self._send_json({"error": "memory not found or not accessible"}, 404)
|
|
1098
|
+
return
|
|
1099
|
+
self._send_json({"status": "forgotten", "id": mid})
|
|
1100
|
+
elif path == "/api/conflicts/reconcile":
|
|
1101
|
+
auto = body.get("auto", True)
|
|
1102
|
+
if not isinstance(auto, bool):
|
|
1103
|
+
self._send_json({"error": "Field 'auto' must be a boolean"}, 400)
|
|
1104
|
+
return
|
|
1105
|
+
memory_ids = body.get("memory_ids")
|
|
1106
|
+
if memory_ids is not None and not isinstance(memory_ids, list):
|
|
1107
|
+
self._send_json({"error": "'memory_ids' must be a list"}, 400)
|
|
1108
|
+
return
|
|
1109
|
+
result = self.mindforge.reconcile_conflicts(
|
|
1110
|
+
memory_ids=memory_ids, auto=auto)
|
|
1111
|
+
self._send_json({"status": "ok", **result})
|
|
1112
|
+
elif path == "/api/experience":
|
|
1113
|
+
task = body.get("task", "")
|
|
1114
|
+
if not isinstance(task, str) or not task.strip():
|
|
1115
|
+
self._send_json({"error": "Field 'task' must be a non-empty string"}, 400)
|
|
1116
|
+
return
|
|
1117
|
+
content = body.get("content", "")
|
|
1118
|
+
result = body.get("result", "")
|
|
1119
|
+
outcome = body.get("outcome", "success")
|
|
1120
|
+
tags = body.get("tags")
|
|
1121
|
+
source_ids = body.get("source_memory_ids")
|
|
1122
|
+
if tags is not None and not isinstance(tags, list):
|
|
1123
|
+
self._send_json({"error": "'tags' must be a list"}, 400)
|
|
1124
|
+
return
|
|
1125
|
+
if source_ids is not None and not isinstance(source_ids, list):
|
|
1126
|
+
self._send_json({"error": "'source_memory_ids' must be a list"}, 400)
|
|
1127
|
+
return
|
|
1128
|
+
try:
|
|
1129
|
+
case = self.mindforge.record_experience(
|
|
1130
|
+
task=task, content=content, result=result, outcome=outcome,
|
|
1131
|
+
duration_seconds=float(body.get("duration_seconds", 0.0) or 0.0),
|
|
1132
|
+
tags=tags, source_memory_ids=source_ids,
|
|
1133
|
+
agent_id=str(body.get("agent_id", "") or ""))
|
|
1134
|
+
except (ValueError, TypeError) as ve:
|
|
1135
|
+
self._send_json({"error": str(ve)}, 400)
|
|
1136
|
+
return
|
|
1137
|
+
self._send_json(case, 201)
|
|
1138
|
+
elif path == "/api/skills/distill":
|
|
1139
|
+
try:
|
|
1140
|
+
mc = int(body.get("min_cluster_size", 2) or 2)
|
|
1141
|
+
except (ValueError, TypeError):
|
|
1142
|
+
mc = 2
|
|
1143
|
+
self._send_json({"status": "ok",
|
|
1144
|
+
**self.mindforge.distill_skills(min_cluster_size=mc)})
|
|
1145
|
+
elif path == "/api/graph/extract":
|
|
1146
|
+
mid = body.get("memory_id", "")
|
|
1147
|
+
if mid is not None and not isinstance(mid, str):
|
|
1148
|
+
self._send_json({"error": "'memory_id' must be a string"}, 400)
|
|
1149
|
+
return
|
|
1150
|
+
try:
|
|
1151
|
+
stats = (self.mindforge.extract_graph(memory_id=mid) if mid
|
|
1152
|
+
else self.mindforge.extract_graph())
|
|
1153
|
+
except ValueError as ve:
|
|
1154
|
+
self._send_json({"error": str(ve)}, 400)
|
|
1155
|
+
return
|
|
1156
|
+
self._send_json({"status": "ok", **stats})
|
|
1157
|
+
elif path == "/api/import":
|
|
1158
|
+
memories = body.get("memories", [])
|
|
1159
|
+
# v5.7.0 P2:批量导入端点额外限流(10 req/60s/IP)
|
|
1160
|
+
if not self._check_rate_limit(heavy=True):
|
|
1161
|
+
return
|
|
1162
|
+
# v5.4.8 安全修复:导入批次大小限制
|
|
1163
|
+
MAX_IMPORT_BATCH = 10000
|
|
1164
|
+
# v5.7.0 P2:memories 必须是列表,否则 400 而非 500
|
|
1165
|
+
if not isinstance(memories, list):
|
|
1166
|
+
self._send_json({"error": "Field 'memories' must be a list"}, 400)
|
|
1167
|
+
return
|
|
1168
|
+
if len(memories) > MAX_IMPORT_BATCH:
|
|
1169
|
+
self._send_json(
|
|
1170
|
+
{"error": f"Too many memories (max {MAX_IMPORT_BATCH})"}, 400
|
|
1171
|
+
)
|
|
1172
|
+
return
|
|
1173
|
+
imported = 0
|
|
1174
|
+
failed = 0
|
|
1175
|
+
for mem in memories:
|
|
1176
|
+
# v5.7.3 修复:非 dict 条目计入 failed,不再整批抛 AttributeError->500
|
|
1177
|
+
if not isinstance(mem, dict):
|
|
1178
|
+
failed += 1
|
|
1179
|
+
continue
|
|
1180
|
+
# v5.7.0 P2:逐条参数校验,非法条目计入 failed
|
|
1181
|
+
try:
|
|
1182
|
+
content = mem.get("content", "")
|
|
1183
|
+
if not isinstance(content, str) or not content.strip():
|
|
1184
|
+
failed += 1
|
|
1185
|
+
continue
|
|
1186
|
+
category = _sanitize_category(
|
|
1187
|
+
mem.get("category", "general"), default="general"
|
|
1188
|
+
)
|
|
1189
|
+
tags = _validate_tags(mem.get("tags", []))
|
|
1190
|
+
importance = _validate_importance(mem.get("importance"))
|
|
1191
|
+
starred = _validate_starred(mem.get("starred"))
|
|
1192
|
+
source_agent = _validate_source_agent(
|
|
1193
|
+
mem.get("source_agent", "")
|
|
1194
|
+
)
|
|
1195
|
+
except ValueError:
|
|
1196
|
+
failed += 1
|
|
1197
|
+
continue
|
|
1198
|
+
try:
|
|
1199
|
+
self.mindforge.add(
|
|
1200
|
+
content=content,
|
|
1201
|
+
category=category,
|
|
1202
|
+
tags=tags,
|
|
1203
|
+
importance=importance,
|
|
1204
|
+
starred=starred,
|
|
1205
|
+
source_agent=source_agent,
|
|
1206
|
+
)
|
|
1207
|
+
imported += 1
|
|
1208
|
+
except Exception:
|
|
1209
|
+
failed += 1
|
|
1210
|
+
self._send_json({"imported": imported, "failed": failed})
|
|
1211
|
+
|
|
1212
|
+
elif path == "/api/import-chat":
|
|
1213
|
+
# v6.0.0 功能 1:一键导入 ChatGPT/Claude 对话
|
|
1214
|
+
data = body.get("data")
|
|
1215
|
+
if not isinstance(data, dict):
|
|
1216
|
+
self._send_json({"error": "Field 'data' must be a JSON object"}, 400)
|
|
1217
|
+
return
|
|
1218
|
+
if not self._check_rate_limit(heavy=True):
|
|
1219
|
+
return
|
|
1220
|
+
self._send_json(self.mindforge.import_chat_history(
|
|
1221
|
+
data, dry_run=bool(body.get("dry_run", False))))
|
|
1222
|
+
elif path == "/api/skill-pack/import":
|
|
1223
|
+
# v6.0.0 功能 3:导入技能包
|
|
1224
|
+
pack = body.get("pack")
|
|
1225
|
+
if not isinstance(pack, dict):
|
|
1226
|
+
self._send_json({"error": "Field 'pack' must be a JSON object"}, 400)
|
|
1227
|
+
return
|
|
1228
|
+
mode = body.get("mode", "merge")
|
|
1229
|
+
if mode not in ("merge", "replace"):
|
|
1230
|
+
self._send_json({"error": "mode must be 'merge' or 'replace'"}, 400)
|
|
1231
|
+
return
|
|
1232
|
+
v = self.mindforge.skill_pack_validate(pack)
|
|
1233
|
+
if not v.get("valid"):
|
|
1234
|
+
self._send_json({"error": "Invalid skill pack",
|
|
1235
|
+
"details": v.get("errors")}, 400)
|
|
1236
|
+
return
|
|
1237
|
+
self._send_json(self.mindforge.skill_pack_import(pack, mode=mode))
|
|
1238
|
+
elif path == "/api/passport/import":
|
|
1239
|
+
# v6.0.0 功能 6:导入记忆护照
|
|
1240
|
+
pp = body.get("passport")
|
|
1241
|
+
if not isinstance(pp, dict):
|
|
1242
|
+
self._send_json({"error": "Field 'passport' must be a JSON object"}, 400)
|
|
1243
|
+
return
|
|
1244
|
+
mode = body.get("mode", "merge")
|
|
1245
|
+
if mode not in ("merge", "replace"):
|
|
1246
|
+
self._send_json({"error": "mode must be 'merge' or 'replace'"}, 400)
|
|
1247
|
+
return
|
|
1248
|
+
self._send_json(self.mindforge.passport_import(pp, mode=mode))
|
|
1249
|
+
elif path == "/api/passport/revoke":
|
|
1250
|
+
# v6.0.0 功能 6:撤销记忆护照
|
|
1251
|
+
pid = body.get("passport_id", "")
|
|
1252
|
+
if not isinstance(pid, str) or not pid:
|
|
1253
|
+
self._send_json({"error": "Missing 'passport_id' field"}, 400)
|
|
1254
|
+
return
|
|
1255
|
+
self._send_json(self.mindforge.passport_revoke(pid))
|
|
1256
|
+
elif path == "/api/evolve":
|
|
1257
|
+
# v6.0.0 功能 4:记忆演变
|
|
1258
|
+
mid = body.get("memory_id", "")
|
|
1259
|
+
if not isinstance(mid, str) or not mid:
|
|
1260
|
+
self._send_json({"error": "Missing 'memory_id' field"}, 400)
|
|
1261
|
+
return
|
|
1262
|
+
self._send_json(self.mindforge.evolve_memory(
|
|
1263
|
+
mid, content=body.get("content"), reason=body.get("reason", "")))
|
|
1264
|
+
elif path == "/api/learn-outcome":
|
|
1265
|
+
# v6.0.0 功能 5:按结果学习
|
|
1266
|
+
mid = body.get("memory_id", "")
|
|
1267
|
+
if not isinstance(mid, str) or not mid:
|
|
1268
|
+
self._send_json({"error": "Missing 'memory_id' field"}, 400)
|
|
1269
|
+
return
|
|
1270
|
+
outcome = body.get("outcome", "success")
|
|
1271
|
+
if outcome not in ("success", "partial", "failure"):
|
|
1272
|
+
self._send_json({"error": "outcome must be success/partial/failure"}, 400)
|
|
1273
|
+
return
|
|
1274
|
+
self._send_json(self.mindforge.learn_from_outcome(
|
|
1275
|
+
mid, outcome=outcome, note=body.get("note", "")))
|
|
1276
|
+
elif path == "/api/forget/run":
|
|
1277
|
+
# v6.0.0 功能 7:有原则遗忘 GC
|
|
1278
|
+
# v6.1.2:days(冷却窗口)终于被实现真正使用,这里一并接通
|
|
1279
|
+
self._send_json(self.mindforge.forget_run(
|
|
1280
|
+
dry_run=bool(body.get("dry_run", False)),
|
|
1281
|
+
days=_safe_int(body.get("days", 30), default=30,
|
|
1282
|
+
min_val=0, max_val=3650)))
|
|
1283
|
+
elif path == "/api/follow-ups":
|
|
1284
|
+
# v6.0.0 功能 8:登记跟进
|
|
1285
|
+
mid = body.get("memory_id", "")
|
|
1286
|
+
if not isinstance(mid, str) or not mid:
|
|
1287
|
+
self._send_json({"error": "Missing 'memory_id' field"}, 400)
|
|
1288
|
+
return
|
|
1289
|
+
self._send_json(self.mindforge.follow_up_create(
|
|
1290
|
+
mid, note=body.get("note", "")))
|
|
1291
|
+
elif path == "/api/follow-ups/resolve":
|
|
1292
|
+
# v6.0.0 功能 8:办结跟进
|
|
1293
|
+
fid = body.get("follow_up_id", "")
|
|
1294
|
+
if not isinstance(fid, str) or not fid:
|
|
1295
|
+
self._send_json({"error": "Missing 'follow_up_id' field"}, 400)
|
|
1296
|
+
return
|
|
1297
|
+
self._send_json(self.mindforge.follow_up_resolve(fid))
|
|
1298
|
+
elif path == "/api/follow-ups/nag":
|
|
1299
|
+
# v6.1.1 功能 8:主动催办到期跟进并生成回执
|
|
1300
|
+
try:
|
|
1301
|
+
interval = float(body.get("interval_hours", 24.0) or 24.0)
|
|
1302
|
+
except (ValueError, TypeError):
|
|
1303
|
+
interval = 24.0
|
|
1304
|
+
try:
|
|
1305
|
+
max_nags = int(body.get("max_nags", 3) or 3)
|
|
1306
|
+
except (ValueError, TypeError):
|
|
1307
|
+
max_nags = 3
|
|
1308
|
+
self._send_json(self.mindforge.follow_up_nag(
|
|
1309
|
+
interval_hours=interval, max_nags=max_nags))
|
|
1310
|
+
|
|
1311
|
+
elif path == "/api/preview/open":
|
|
1312
|
+
# v6.0.0 正式版:开启 6G 预览通道(与稳定版并行,互不污染)
|
|
1313
|
+
cfg = body.get("config")
|
|
1314
|
+
if cfg is not None and not isinstance(cfg, dict):
|
|
1315
|
+
self._send_json({"error": "Field 'config' must be an object"}, 400)
|
|
1316
|
+
return
|
|
1317
|
+
self._send_json(self.mindforge.memory_preview.open_preview_channel(cfg))
|
|
1318
|
+
elif path == "/api/preview/close":
|
|
1319
|
+
# v6.0.0 正式版:关闭并清理 6G 预览通道
|
|
1320
|
+
cid = body.get("channel_id", "")
|
|
1321
|
+
if not isinstance(cid, str) or not cid:
|
|
1322
|
+
self._send_json({"error": "Missing 'channel_id' field"}, 400)
|
|
1323
|
+
return
|
|
1324
|
+
# v6.1.2:purge 参数接通(默认 true = 删除独立库文件;
|
|
1325
|
+
# false 只标记关闭、保留文件供查看)
|
|
1326
|
+
self._send_json(
|
|
1327
|
+
self.mindforge.memory_preview.close_preview_channel(
|
|
1328
|
+
cid, purge=bool(body.get("purge", True))))
|
|
1329
|
+
elif path == "/api/preview/snapshot":
|
|
1330
|
+
# v6.0.0 正式版:把主库记忆快照进预览通道(不修改主库)
|
|
1331
|
+
cid = body.get("channel_id", "")
|
|
1332
|
+
mid = body.get("memory_id", "")
|
|
1333
|
+
if not isinstance(cid, str) or not cid:
|
|
1334
|
+
self._send_json({"error": "Missing 'channel_id' field"}, 400)
|
|
1335
|
+
return
|
|
1336
|
+
if not isinstance(mid, str) or not mid:
|
|
1337
|
+
self._send_json({"error": "Missing 'memory_id' field"}, 400)
|
|
1338
|
+
return
|
|
1339
|
+
self._send_json(self.mindforge.memory_preview.snapshot_memory(cid, mid))
|
|
1340
|
+
elif path == "/api/preview/distill":
|
|
1341
|
+
# v6.0.0 正式版:6G 上下文蒸馏(分层摘要 + Token 压缩)
|
|
1342
|
+
mids = body.get("memory_ids", [])
|
|
1343
|
+
if not isinstance(mids, list) or not mids:
|
|
1344
|
+
self._send_json({"error": "Missing 'memory_ids' array field"}, 400)
|
|
1345
|
+
return
|
|
1346
|
+
try:
|
|
1347
|
+
max_tokens = int(body.get("max_tokens", 2000))
|
|
1348
|
+
except (ValueError, TypeError):
|
|
1349
|
+
max_tokens = 2000
|
|
1350
|
+
self._send_json(self.mindforge.memory_preview.distill_context(
|
|
1351
|
+
mids, max_tokens=max_tokens))
|
|
1352
|
+
|
|
1353
|
+
else:
|
|
1354
|
+
self._send_json({"error": "Not found"}, 404)
|
|
1355
|
+
|
|
1356
|
+
except Exception as e:
|
|
1357
|
+
_log_unhandled_api_error(e)
|
|
1358
|
+
self._send_json({"error": "Internal server error"}, 500)
|
|
1359
|
+
|
|
1360
|
+
def do_PUT(self):
|
|
1361
|
+
parsed = urlparse(self.path)
|
|
1362
|
+
path = parsed.path.rstrip("/") or "/"
|
|
1363
|
+
qs = parse_qs(parsed.query)
|
|
1364
|
+
|
|
1365
|
+
# v5.6.1: 写操作也加限流;v5.7.0 P2:写路径走更严配额
|
|
1366
|
+
if not self._check_rate_limit(write=True):
|
|
1367
|
+
return
|
|
1368
|
+
|
|
1369
|
+
if not self._check_auth():
|
|
1370
|
+
return
|
|
1371
|
+
|
|
1372
|
+
try:
|
|
1373
|
+
body = self._read_body()
|
|
1374
|
+
if body is None:
|
|
1375
|
+
self._send_json({"error": "Invalid JSON body"}, 400)
|
|
1376
|
+
return
|
|
1377
|
+
# v5.5.8: 校验 JSON body 必须是 dict
|
|
1378
|
+
if not isinstance(body, dict):
|
|
1379
|
+
self._send_json({"error": "Request body must be a JSON object"}, 400)
|
|
1380
|
+
return
|
|
1381
|
+
|
|
1382
|
+
if path.startswith("/api/memories/"):
|
|
1383
|
+
mem_id = self._extract_mem_id(path)
|
|
1384
|
+
if not mem_id:
|
|
1385
|
+
self._send_json({"error": "Invalid memory ID"}, 400)
|
|
1386
|
+
return
|
|
1387
|
+
content = body.get("content")
|
|
1388
|
+
if content is not None and not isinstance(content, str):
|
|
1389
|
+
self._send_json({"error": "Field 'content' must be a string"}, 400)
|
|
1390
|
+
return
|
|
1391
|
+
# v5.7.0 P2:统一参数校验层
|
|
1392
|
+
try:
|
|
1393
|
+
category = _sanitize_category(body.get("category"), default=None)
|
|
1394
|
+
tags = (
|
|
1395
|
+
_validate_tags(body.get("tags"))
|
|
1396
|
+
if body.get("tags") is not None
|
|
1397
|
+
else None
|
|
1398
|
+
)
|
|
1399
|
+
importance = _validate_importance(body.get("importance"))
|
|
1400
|
+
starred = _validate_starred(body.get("starred"))
|
|
1401
|
+
valid_from = (
|
|
1402
|
+
_validate_float_ts(body.get("valid_from"))
|
|
1403
|
+
if "valid_from" in body else None
|
|
1404
|
+
)
|
|
1405
|
+
valid_to = (
|
|
1406
|
+
_validate_float_ts(body.get("valid_to"))
|
|
1407
|
+
if "valid_to" in body else None
|
|
1408
|
+
)
|
|
1409
|
+
except ValueError as ve:
|
|
1410
|
+
self._send_json({"error": str(ve)}, 400)
|
|
1411
|
+
return
|
|
1412
|
+
success = self.mindforge.update(
|
|
1413
|
+
memory_id=mem_id,
|
|
1414
|
+
content=content,
|
|
1415
|
+
category=category,
|
|
1416
|
+
tags=tags,
|
|
1417
|
+
importance=importance,
|
|
1418
|
+
starred=starred,
|
|
1419
|
+
valid_from=valid_from,
|
|
1420
|
+
valid_to=valid_to,
|
|
1421
|
+
# v5.7.3 安全修复:写路径透传调用者身份,
|
|
1422
|
+
# update 内部 check_access 按来源校验,杜绝持 Key 越权改删
|
|
1423
|
+
actor=self._extract_actor(qs),
|
|
1424
|
+
session_id=(qs.get("session", [""])[0] or ""),
|
|
1425
|
+
)
|
|
1426
|
+
if success:
|
|
1427
|
+
self._send_json({"status": "updated", "id": mem_id})
|
|
1428
|
+
else:
|
|
1429
|
+
self._send_json({"error": "Update failed or memory not found"}, 404)
|
|
1430
|
+
else:
|
|
1431
|
+
self._send_json({"error": "Not found"}, 404)
|
|
1432
|
+
|
|
1433
|
+
except Exception as e:
|
|
1434
|
+
_log_unhandled_api_error(e)
|
|
1435
|
+
self._send_json({"error": "Internal server error"}, 500)
|
|
1436
|
+
|
|
1437
|
+
def do_DELETE(self):
|
|
1438
|
+
parsed = urlparse(self.path)
|
|
1439
|
+
path = parsed.path.rstrip("/") or "/"
|
|
1440
|
+
qs = parse_qs(parsed.query)
|
|
1441
|
+
|
|
1442
|
+
# v5.6.1: 写操作也加限流;v5.7.0 P2:写路径走更严配额
|
|
1443
|
+
if not self._check_rate_limit(write=True):
|
|
1444
|
+
return
|
|
1445
|
+
|
|
1446
|
+
if not self._check_auth():
|
|
1447
|
+
return
|
|
1448
|
+
|
|
1449
|
+
try:
|
|
1450
|
+
if path.startswith("/api/memories/"):
|
|
1451
|
+
mem_id = self._extract_mem_id(path)
|
|
1452
|
+
if not mem_id:
|
|
1453
|
+
self._send_json({"error": "Invalid memory ID"}, 400)
|
|
1454
|
+
return
|
|
1455
|
+
success = self.mindforge.delete(
|
|
1456
|
+
mem_id,
|
|
1457
|
+
# v5.7.3 安全修复:删除路径透传调用者身份,按来源校验属主,
|
|
1458
|
+
# 杜绝持 Key 越权删除他人 PRIVATE/STRICT 记忆
|
|
1459
|
+
actor=self._extract_actor(qs),
|
|
1460
|
+
session_id=(qs.get("session", [""])[0] or ""),
|
|
1461
|
+
)
|
|
1462
|
+
if success:
|
|
1463
|
+
self._send_json({"status": "deleted", "id": mem_id})
|
|
1464
|
+
else:
|
|
1465
|
+
self._send_json({"error": "Delete failed or memory not found"}, 404)
|
|
1466
|
+
else:
|
|
1467
|
+
self._send_json({"error": "Not found"}, 404)
|
|
1468
|
+
|
|
1469
|
+
except Exception as e:
|
|
1470
|
+
_log_unhandled_api_error(e)
|
|
1471
|
+
self._send_json({"error": "Internal server error"}, 500)
|
|
1472
|
+
|
|
1473
|
+
def log_message(self, format, *args):
|
|
1474
|
+
# v5.7.3 隐私加固:访问日志剥离 query string 与路径中的记忆 ID,
|
|
1475
|
+
# 避免搜索词/记忆 ID 落盘。
|
|
1476
|
+
try:
|
|
1477
|
+
safe_args = list(args)
|
|
1478
|
+
if len(safe_args) >= 2 and isinstance(safe_args[1], str):
|
|
1479
|
+
line = safe_args[1]
|
|
1480
|
+
# 拆分请求行,仅保留 method + path(query 丢弃)
|
|
1481
|
+
parts = line.split()
|
|
1482
|
+
if len(parts) >= 2 and parts[1].startswith("/"):
|
|
1483
|
+
path = urlparse(parts[1]).path or "/"
|
|
1484
|
+
else:
|
|
1485
|
+
path = urlparse(line).path or "/"
|
|
1486
|
+
# 记忆 ID 这类路径参数统一脱敏为占位符
|
|
1487
|
+
path = re.sub(r"/api/memories/[^/?#]+", "/api/memories/{id}", path)
|
|
1488
|
+
safe_args[1] = f"{parts[0]} {path}" if parts else path
|
|
1489
|
+
logger.info("%s - %s", self.address_string(), format % tuple(safe_args))
|
|
1490
|
+
except Exception:
|
|
1491
|
+
logger.info("%s - %s", self.address_string(), format % args)
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def start_api_server(
|
|
1495
|
+
mindforge_instance,
|
|
1496
|
+
host="127.0.0.1",
|
|
1497
|
+
port=8080,
|
|
1498
|
+
ssl_certfile: str = "",
|
|
1499
|
+
ssl_keyfile: str = "",
|
|
1500
|
+
):
|
|
1501
|
+
"""启动 REST API 服务器
|
|
1502
|
+
|
|
1503
|
+
Args:
|
|
1504
|
+
mindforge_instance: MindForge 实例
|
|
1505
|
+
host: 绑定地址
|
|
1506
|
+
port: 端口
|
|
1507
|
+
ssl_certfile: TLS 证书文件路径(启用 HTTPS)
|
|
1508
|
+
ssl_keyfile: TLS 私钥文件路径(启用 HTTPS)
|
|
1509
|
+
|
|
1510
|
+
v5.6.1 安全修复:新增 TLS 支持,通过 ssl_certfile / ssl_keyfile 启用 HTTPS,
|
|
1511
|
+
避免 Bearer Token 和记忆数据明文传输。
|
|
1512
|
+
"""
|
|
1513
|
+
MindForgeAPIHandler.mindforge = mindforge_instance
|
|
1514
|
+
|
|
1515
|
+
# v5.6.1 安全修复:非 localhost 绑定且未设置 API Key 时拒绝启动
|
|
1516
|
+
api_key = os.environ.get("MINDFORGE_API_KEY", "")
|
|
1517
|
+
is_localhost = host in ("127.0.0.1", "localhost", "::1", "0:0:0:0:0:0:0:1")
|
|
1518
|
+
# v5.7.2 安全加固:API Key 强度校验(<16 字符给警告)
|
|
1519
|
+
if api_key and len(api_key) < 16:
|
|
1520
|
+
logger.warning(
|
|
1521
|
+
"MINDFORGE_API_KEY 长度仅 %d 字符,建议至少 16 字符("
|
|
1522
|
+
"建议: python -c 'import secrets; print(secrets.token_urlsafe(32))'",
|
|
1523
|
+
len(api_key),
|
|
1524
|
+
)
|
|
1525
|
+
if not is_localhost and not api_key:
|
|
1526
|
+
raise SecurityError(
|
|
1527
|
+
"拒绝在非 localhost 地址上启动无认证的 API。"
|
|
1528
|
+
"请设置 MINDFORGE_API_KEY 环境变量,或仅绑定到 127.0.0.1。"
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
# P0-003: 使用带并发限制的多线程服务器
|
|
1532
|
+
server = BoundedThreadingHTTPServer((host, port), MindForgeAPIHandler)
|
|
1533
|
+
|
|
1534
|
+
# v5.6.1: TLS 支持
|
|
1535
|
+
use_https = bool(ssl_certfile)
|
|
1536
|
+
# v5.7.2 安全加固:TLS 启用时下发 HSTS;纯 HTTP 本地部署不下发(避免误伤)
|
|
1537
|
+
MindForgeAPIHandler._tls_enabled = use_https
|
|
1538
|
+
# HTTPS 反向代理场景:MINDFORGE_TRUST_PROXY=1 显式信任代理后,
|
|
1539
|
+
# 请求带 X-Forwarded-Proto: https 时同样下发 HSTS(见 _send_json)。
|
|
1540
|
+
MindForgeAPIHandler._trust_proxy = os.environ.get(
|
|
1541
|
+
"MINDFORGE_TRUST_PROXY", "0"
|
|
1542
|
+
).strip().lower() in ("1", "true", "yes", "on")
|
|
1543
|
+
if use_https:
|
|
1544
|
+
import ssl as _ssl
|
|
1545
|
+
|
|
1546
|
+
if not ssl_keyfile:
|
|
1547
|
+
ssl_keyfile = ssl_certfile
|
|
1548
|
+
ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_SERVER)
|
|
1549
|
+
ctx.minimum_version = _ssl.TLSVersion.TLSv1_2
|
|
1550
|
+
ctx.load_cert_chain(certfile=ssl_certfile, keyfile=ssl_keyfile)
|
|
1551
|
+
server.socket = ctx.wrap_socket(server.socket, server_side=True)
|
|
1552
|
+
proto = "https"
|
|
1553
|
+
else:
|
|
1554
|
+
proto = "http"
|
|
1555
|
+
|
|
1556
|
+
print(f"MindForge REST API serving on {proto}://{host}:{port}")
|
|
1557
|
+
print(f" Max concurrent threads: {BoundedThreadingHTTPServer.max_threads}")
|
|
1558
|
+
if use_https:
|
|
1559
|
+
print(f" TLS: enabled (cert: {ssl_certfile})")
|
|
1560
|
+
print(" Endpoints: /api/memories, /api/search, /api/stats, /api/health, ...")
|
|
1561
|
+
print(" Press Ctrl+C to stop")
|
|
1562
|
+
|
|
1563
|
+
try:
|
|
1564
|
+
server.serve_forever()
|
|
1565
|
+
except KeyboardInterrupt:
|
|
1566
|
+
print("\nAPI server stopped.")
|
|
1567
|
+
server.server_close()
|