Files
Hy-Memory/hy_memory/server.py
T
alvinfeiandClaude Opus 5.5 16da11f788 feat(sdk): prompts in 11 languages and an explicit language option
Move every LLM prompt that had a zh variant into one registry,
hy_memory/prompts/, with one module per language (en, zh-Hans, zh-Hant,
ja, es, de, pt, fr, ko, ru, id). Call sites look prompts up with
get_prompt(name, language), so a new language needs only a new module.
The en and zh-Hans strings are moved verbatim, a test pins their sha256,
and the old names (prompts_zh.*_ZH, extractor.EXTRACT_SYSTEM_PROMPT, ...)
still import.

Add an optional language: MemoryConfig.language (env MEMORY_LANGUAGE)
as the client default, plus a per-call language= on add / async_add,
add_extracted, finalize_session, evolve and sweep. It goes through
extract and basic profile, reconcile and search query, the coding
extractor and both coding reconcilers with the chain summary, the
episode and topic summaries, and System 2. Codes are validated, with
aliases (zh, zh-CN, zh-TW, zh-HK, pt-BR); an unknown code raises
ValueError. With an explicit language, memories are written in that
language. Without one, the zh/en auto-detection is unchanged. The
server accepts language on /add and /add_extracted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 21:29:59 +08:00

638 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
HY Memory - Lightweight HTTP Server
Zero-dependency HTTP server (stdlib only) that wraps HyMemoryClient
for local use by the OpenClaw plugin.
Usage:
python -m hy_memory.server # default port 19527
python -m hy_memory.server --port 19528
HY_MEMORY_SERVER_PORT=19528 python -m hy_memory.server
API Endpoints:
POST /api/v1/add — Write memory
POST /api/v1/search — Search memories
POST /api/v1/user_profile — Get assembled user profile
GET /api/v1/memories/:id — Get single memory
POST /api/v1/list — List memories
PUT /api/v1/memories/:id — Update memory
DELETE /api/v1/memories/:id — Delete memory
POST /api/v1/delete_all — Delete all user memories
GET /healthz — Health check (static, process alive)
GET /health — Liveness probe (pings internal event loop; 503 if loop blocked)
GET /info — Server info
/add and /add_extracted accept an optional "language" (en, zh-Hans, zh-Hant, ja,
es, de, pt, fr, ko, ru, id, or an alias such as zh-CN / zh-TW / pt-BR). An
unknown code returns 400. /search has no language option: the search-time
judge / rewrite prompts are English-only and keep the query's own language.
"""
import asyncio
import json
import logging
import os
import re
import signal
import threading
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict, Optional
from urllib.parse import parse_qs, urlparse
from .retrieval import AmbiguousMemoryIdError
logger = logging.getLogger("hy_memory.server")
def _namespace_args(values: Dict[str, Any]) -> Dict[str, Any]:
"""kind / variant from a query string (lists) or a JSON body (scalars).
They disambiguate an id that exists in more than one namespace (chat,
experience, fact; working or coding). Absent means "resolve it".
"""
args: Dict[str, Any] = {}
for key in ("kind", "variant"):
value = values.get(key)
if isinstance(value, list):
value = value[0] if value else None
if value:
args[key] = str(value)
return args
# Lazy-init global client
_client = None
_client_lock = None
def _get_client():
"""Lazy-initialize the HyMemoryClient singleton."""
global _client
if _client is not None:
return _client
from .client import HyMemoryClient
from .config import MemoryConfig
logger.info("[server] Initializing HyMemoryClient...")
config = MemoryConfig.from_env()
# ---- 胶水: thinking mode + temperature 强制 ----
# 当环境变量 HY_MEMORY_THINKING_MODE 设置时,对支持 thinking 的模型注入控制参数
thinking_mode = os.getenv("HY_MEMORY_THINKING_MODE")
if thinking_mode:
_llm_model = (config.llm.model or "").lower()
# 需要 extra_body.thinking 控制的模型
_needs_thinking_body = (
"kimi" in _llm_model
or "deepseek" in _llm_model
or "qwen" in _llm_model
or "hy3" in _llm_model
or "hunyuan" in _llm_model
)
if _needs_thinking_body:
extra = config.llm.extra_body or {}
extra["thinking"] = {"type": thinking_mode}
config.llm.extra_body = extra
# kimi/deepseek 有温度硬约束
if "kimi" in _llm_model or "deepseek" in _llm_model:
required_temp = 0.6 if thinking_mode == "disabled" else 1.0
config.llm.temperature = required_temp
logger.info(f"[server] thinking adapter: model={config.llm.model}, thinking={thinking_mode}")
_client = HyMemoryClient(config=config)
logger.info("[server] HyMemoryClient ready")
return _client
def _language_arg(handler: BaseHTTPRequestHandler, body: Dict, kwargs: Dict) -> bool:
"""Validate optional ``language`` and add it to kwargs; False after a 400 reply."""
if body.get("language") in (None, ""):
return True
from .prompts import normalize_language
try:
kwargs["language"] = normalize_language(body["language"])
except ValueError as e:
_json_response(handler, 400, {"error": str(e)})
return False
return True
def _json_response(handler: BaseHTTPRequestHandler, status: int, data: Any):
"""Send a JSON response."""
body = json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Access-Control-Allow-Origin", "*")
handler.end_headers()
handler.wfile.write(body)
def _read_json_body(handler: BaseHTTPRequestHandler) -> Optional[Dict]:
"""Read and parse JSON request body."""
content_length = int(handler.headers.get("Content-Length", 0))
if content_length == 0:
return {}
raw = handler.rfile.read(content_length)
try:
return json.loads(raw.decode("utf-8"))
except Exception:
return None
class MemoryHTTPHandler(BaseHTTPRequestHandler):
"""HTTP request handler for HY Memory API."""
# Suppress default access log
def log_message(self, format, *args):
logger.debug(f"[http] {args[0] if args else ''}")
def do_OPTIONS(self):
"""Handle CORS preflight."""
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.send_header("Access-Control-Max-Age", "86400")
self.end_headers()
def do_GET(self):
path = self.path.split("?")[0].rstrip("/")
try:
if path == "/healthz":
_json_response(self, 200, {"status": "ok"})
return
if path == "/health":
self._handle_health_liveness()
return
if path == "/api/v1/status":
self._handle_healthz()
return
if path == "/info":
from . import __version__
_json_response(self, 200, {
"name": "hy-memory-server",
"version": __version__,
"status": "running",
})
return
# GET /api/v1/metrics?minutes=5
if path == "/api/v1/metrics":
minutes = 5
if "?" in self.path:
qs = parse_qs(urlparse(self.path).query)
minutes = int(qs.get("minutes", ["5"])[0])
client = _get_client()
result = client.get_metrics(minutes=minutes)
_json_response(self, 200, result)
return
# GET /api/v1/memories/:id
m = re.match(r"^/api/v1/memories/([^/]+)$", path)
if m:
memory_id = m.group(1)
client = _get_client()
query = parse_qs(urlparse(self.path).query)
result = client.get(memory_id, **_namespace_args(query))
if result is None:
_json_response(self, 404, {"error": "not_found", "memory_id": memory_id})
else:
_json_response(self, 200, result)
return
_json_response(self, 404, {"error": "not_found", "path": path})
except AmbiguousMemoryIdError as e:
_json_response(self, 409, {"error": "ambiguous_memory_id", "detail": str(e)})
except ValueError as e:
_json_response(self, 400, {"error": str(e)})
except Exception as e:
logger.error(f"[server] GET {path} error: {e}", exc_info=True)
_json_response(self, 500, {"error": str(e)})
def do_POST(self):
path = self.path.rstrip("/")
body = _read_json_body(self)
if body is None:
_json_response(self, 400, {"error": "invalid JSON body"})
return
try:
if path == "/api/v1/add":
self._handle_add(body)
return
if path == "/api/v1/add_extracted":
self._handle_add_extracted(body)
return
if path == "/api/v1/search":
self._handle_search(body)
return
if path == "/api/v1/user_profile":
self._handle_user_profile(body)
return
if path == "/api/v1/list":
self._handle_list(body)
return
if path == "/api/v1/delete_all":
self._handle_delete_all(body)
return
_json_response(self, 404, {"error": "not_found", "path": path})
except Exception as e:
logger.error(f"[server] POST {path} error: {e}", exc_info=True)
_json_response(self, 500, {"error": str(e)})
def do_PUT(self):
path = self.path.split("?")[0].rstrip("/")
body = _read_json_body(self)
if body is None:
_json_response(self, 400, {"error": "invalid JSON body"})
return
try:
m = re.match(r"^/api/v1/memories/([^/]+)$", path)
if m:
memory_id = m.group(1)
content = body.get("content", "")
if not content:
_json_response(self, 400, {"error": "content is required"})
return
client = _get_client()
result = client.update(
memory_id,
content,
**_namespace_args(body),
**({"task": body["task"]} if body.get("task") else {}),
)
_json_response(self, 200, result)
return
_json_response(self, 404, {"error": "not_found", "path": path})
except AmbiguousMemoryIdError as e:
_json_response(self, 409, {"error": "ambiguous_memory_id", "detail": str(e)})
except ValueError as e:
_json_response(self, 400, {"error": str(e)})
except Exception as e:
logger.error(f"[server] PUT {path} error: {e}", exc_info=True)
_json_response(self, 500, {"error": str(e)})
def do_DELETE(self):
path = self.path.split("?")[0].rstrip("/")
try:
m = re.match(r"^/api/v1/memories/([^/]+)$", path)
if m:
memory_id = m.group(1)
client = _get_client()
query = parse_qs(urlparse(self.path).query)
result = client.delete(memory_id, **_namespace_args(query))
_json_response(self, 200, result)
return
_json_response(self, 404, {"error": "not_found", "path": path})
except AmbiguousMemoryIdError as e:
_json_response(self, 409, {"error": "ambiguous_memory_id", "detail": str(e)})
except ValueError as e:
_json_response(self, 400, {"error": str(e)})
except Exception as e:
logger.error(f"[server] DELETE {path} error: {e}", exc_info=True)
_json_response(self, 500, {"error": str(e)})
# ================================================================
# Route handlers
# ================================================================
def _handle_health_liveness(self):
"""轻量存活探针:探测内部 event loop(_LoopThread)是否还活着。
与 /api/v1/status(深度检查 VDB/embed/LLM,且自己临时开新 loop)不同,本
endpoint 专门探测真正承载所有业务协程的那个 _LoopThread loop 是否被阻塞/死锁。
做法:往 _loop_thread 的 loop 投一个几乎零成本的 ping 协程,带短超时等待结果。
- loop 正常调度 → 快速返回 200 {"status":"alive"}
- loop 卡死(如被同步阻塞)→ 超时 → 503 {"status":"loop_blocked"}
绝不调用任何业务方法、绝不碰 VDB/LLM,也不给任何业务请求加超时。这样外部探活
能明确区分「进程活着」与「loop 活着」,据此重启 sidecar,而不会误伤正常长写入。
"""
try:
client = _get_client()
except Exception as e:
_json_response(self, 503, {"status": "error", "error": f"client init failed: {e}"})
return
loop_thread = getattr(client, "_loop_thread", None)
loop = getattr(loop_thread, "_loop", None)
if loop is None:
_json_response(self, 503, {"status": "error", "error": "no internal loop"})
return
if not loop.is_running():
_json_response(self, 503, {"status": "loop_stopped"})
return
async def _ping():
return True
try:
fut = asyncio.run_coroutine_threadsafe(_ping(), loop)
# 短超时:loop 正常时 ping 是微秒级;卡死则超时判定为不可用
fut.result(timeout=2.0)
_json_response(self, 200, {"status": "alive"})
except Exception:
# 超时或调度失败 → loop 被阻塞/死锁
try:
fut.cancel()
except Exception:
pass
_json_response(self, 503, {"status": "loop_blocked"})
def _handle_healthz(self):
"""Deep health check: verify VDB, embed, and LLM connectivity."""
checks = {"status": "ok", "vdb": "ok", "embed": "ok", "llm": "ok"}
has_error = False
try:
client = _get_client()
except Exception as e:
_json_response(self, 503, {"status": "error", "error": f"client init failed: {e}"})
return
# 1. VDB check — get stats
try:
loop = asyncio.new_event_loop()
stats = loop.run_until_complete(client._vector_store.get_stats())
loop.close()
checks["vdb"] = "ok"
checks["vdb_provider"] = client._config.vector_store.provider
checks["vdb_collection"] = client._vector_store._collection_name
checks["vdb_points"] = stats.get("points_count", stats.get("vectors_count", "?"))
except Exception as e:
checks["vdb"] = f"error: {e}"
checks["vdb_provider"] = client._config.vector_store.provider
has_error = True
# 2. Embed check — embed a single test string
try:
loop = asyncio.new_event_loop()
vectors = loop.run_until_complete(client._embed_service.embed("health check"))
loop.close()
if vectors and len(vectors) > 0:
checks["embed"] = "ok"
checks["embed_dims"] = len(vectors)
else:
checks["embed"] = "error: empty response"
has_error = True
except Exception as e:
checks["embed"] = f"error: {e}"
has_error = True
# 3. LLM check — simple completion
try:
from openai import OpenAI
llm_cfg = client._config.llm
llm_client = OpenAI(
api_key=llm_cfg.api_key,
base_url=llm_cfg.base_url or None,
timeout=10,
)
resp = llm_client.chat.completions.create(
model=llm_cfg.model,
messages=[{"role": "user", "content": "hi"}],
max_tokens=1,
)
if resp.choices:
checks["llm"] = "ok"
else:
checks["llm"] = "error: no choices"
has_error = True
except Exception as e:
checks["llm"] = f"error: {e}"
has_error = True
checks["status"] = "degraded" if has_error else "ok"
status_code = 200 if not has_error else 503
_json_response(self, status_code, checks)
def _handle_add(self, body: Dict):
"""POST /api/v1/add"""
data = body.get("data")
if data is None:
# Support both "data" (str or messages list) and "messages" key
messages = body.get("messages")
text = body.get("text", "")
if messages:
data = messages
elif text:
data = text
else:
_json_response(self, 400, {"error": "data, text, or messages is required"})
return
client = _get_client()
kwargs = {}
for key in ("user_id", "agent_id", "session_id", "metadata"):
if key in body:
kwargs[key] = body[key]
# memory_at
if body.get("memory_at"):
try:
kwargs["memory_at"] = datetime.fromisoformat(body["memory_at"])
except Exception:
pass
# language: explicit prompt / memory language (see HyMemoryClient.add)
if not _language_arg(self, body, kwargs):
return
result = client.add(data, **kwargs)
_json_response(self, 200, result)
def _handle_add_extracted(self, body: Dict):
"""POST /api/v1/add_extracted — 写入已抽取好的记忆(跳过 extract,直接 reconcile)"""
# 接受 contents (str 或 list) / content / text
contents = body.get("contents")
if contents is None:
single = body.get("content") or body.get("text")
if single:
contents = single
if not contents:
_json_response(self, 400, {"error": "contents, content, or text is required"})
return
client = _get_client()
kwargs = {}
for key in ("user_id", "agent_id", "session_id", "metadata"):
if key in body:
kwargs[key] = body[key]
if body.get("memory_at"):
try:
kwargs["memory_at"] = datetime.fromisoformat(body["memory_at"])
except Exception:
pass
if not _language_arg(self, body, kwargs):
return
result = client.add_extracted(contents, **kwargs)
_json_response(self, 200, result)
def _handle_search(self, body: Dict):
"""POST /api/v1/search"""
query = body.get("query", "")
if not query:
_json_response(self, 400, {"error": "query is required"})
return
client = _get_client()
kwargs = {}
# user_ids: required by SDK, but for openclaw convenience
# accept both user_ids (list) and user_id (str)
user_ids = body.get("user_ids")
if not user_ids:
user_id = body.get("user_id", "")
if user_id:
user_ids = [user_id]
if not user_ids:
_json_response(self, 400, {"error": "user_id or user_ids is required"})
return
kwargs["user_ids"] = user_ids
for key in ("agent_ids", "session_ids"):
if key in body:
kwargs[key] = body[key]
for key in ("limit", "min_score", "schema_min_score", "schema_limit", "reader",
"rerank"):
if key in body:
kwargs[key] = body[key]
result = client.search(query, **kwargs)
_json_response(self, 200, result)
def _handle_user_profile(self, body: Dict):
"""POST /api/v1/user_profile"""
user_id = str(body.get("user_id") or "").strip()
if not user_id:
_json_response(self, 400, {"error": "user_id is required"})
return
client = _get_client()
result = client.get_user_profile(
user_id=user_id,
)
_json_response(self, 200, result)
def _handle_list(self, body: Dict):
"""POST /api/v1/list"""
client = _get_client()
kwargs = {}
for key in ("user_id", "agent_id", "limit", "offset", "order"):
if key in body:
kwargs[key] = body[key]
result = client.list_memories(**kwargs)
_json_response(self, 200, result)
def _handle_delete_all(self, body: Dict):
"""POST /api/v1/delete_all"""
client = _get_client()
kwargs = {}
for key in ("user_id", "agent_ids", "session_ids"):
if key in body:
kwargs[key] = body[key]
result = client.delete_all(**kwargs)
_json_response(self, 200, result)
def _request_shutdown(server: ThreadingHTTPServer) -> None:
"""Ask ``serve_forever`` to stop without blocking the calling thread.
``BaseServer.shutdown()`` waits for the ``serve_forever`` loop to exit. A
signal handler runs on the very thread executing that loop, so calling it
there deadlocks forever. Hand the call to a helper thread instead.
"""
threading.Thread(target=server.shutdown, daemon=True).start()
def run_server(port: int = 19527, host: str = "127.0.0.1"):
"""Start the HTTP server (blocking)."""
# Configure logging — basicConfig for root; hy_memory level set after client init
log_level = getattr(logging, os.getenv("MEMORY_LOG_LEVEL", "INFO").upper(), logging.INFO)
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
# Pre-initialize client to fail fast (this calls setup_logging internally)
logger.info(f"[server] Starting HY Memory Server on {host}:{port}")
_get_client()
# After setup_logging has run, force correct levels
hy_logger = logging.getLogger("hy_memory")
hy_logger.setLevel(log_level)
for h in hy_logger.handlers:
h.setLevel(log_level)
# Suppress noisy third-party debug logs (must be after setup_logging)
for noisy in ("openai", "httpx", "httpcore"):
logging.getLogger(noisy).setLevel(logging.WARNING)
# Use ThreadingHTTPServer so /healthz is never blocked by long-running requests
server = ThreadingHTTPServer((host, port), MemoryHTTPHandler)
server.daemon_threads = True # Threads die with main process
logger.info(f"[server] Listening on http://{host}:{port}")
logger.info(f"[server] Health check: http://{host}:{port}/healthz")
# Graceful shutdown. The signal handler only asks a helper thread to stop
# the loop; cleanup runs after serve_forever() returns so it cannot race
# the request threads or deadlock the serving thread.
def _shutdown(signum, frame):
logger.info("[server] Shutting down...")
_request_shutdown(server)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
try:
server.serve_forever()
except KeyboardInterrupt:
_shutdown(None, None)
finally:
server.server_close()
if _client is not None:
_client.close()
def main():
"""Entry point for `python -m hy_memory.server`."""
import argparse
parser = argparse.ArgumentParser(description="HY Memory HTTP Server")
parser.add_argument(
"--port", type=int,
default=int(os.getenv("HY_MEMORY_SERVER_PORT", "19527")),
help="Port to listen on (default: 19527)",
)
parser.add_argument(
"--host", type=str,
default=os.getenv("HY_MEMORY_SERVER_HOST", "127.0.0.1"),
help="Host to bind to (default: 127.0.0.1)",
)
args = parser.parse_args()
run_server(port=args.port, host=args.host)
if __name__ == "__main__":
main()