From b1f93588f1fad6400d035033ea7e5e9dd39d0315 Mon Sep 17 00:00:00 2001 From: zqf Date: Mon, 3 Aug 2026 04:53:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=EF=BC=9A=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E7=B3=BB=E7=BB=9F=E6=BA=90=E4=BB=A3=E7=A0=81=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=EF=BC=88=E5=B7=B2=E8=84=B1=E6=95=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 排除 .env / *.bak / 内部运维文档(README_INTERNAL.html) - config.py 默认密码已替换为占位符 CHANGE_ME_* - init_db.sql 移除生产数据库用户 GRANT 段 - README.html 数据库用户名已脱敏 - 保留:源码 + 公网 API 文档 + 建表 SQL(无授权语句) --- .gitignore | 19 + README.html | 353 +++++++ api/__init__.py | 1 + api/routes.py | 489 +++++++++ app.py | 120 +++ auth/__init__.py | 2 + auth/api_auth.py | 214 ++++ config.py | 51 + doc.png | Bin 0 -> 25000 bytes docs/doc.png | Bin 0 -> 25000 bytes docs/index.html | 1809 ++++++++++++++++++++++++++++++++ embedding_service.py | 67 ++ gunicorn.conf.py | 15 + init_db.sql | 140 +++ lifecycle/__init__.py | 1 + lifecycle/aggregator.py | 92 ++ lifecycle/cleaner.py | 43 + lifecycle/compressor.py | 89 ++ lifecycle/fact_extractor.py | 101 ++ lifecycle/llm_parse.py | 344 ++++++ lifecycle/persona_generator.py | 80 ++ lifecycle/pipeline.py | 136 +++ llm_client.py | 156 +++ memory_system.py | 423 ++++++++ requirements.txt | 7 + storage/__init__.py | 1 + storage/mysql_store.py | 367 +++++++ storage/redis_cache.py | 68 ++ storage/vector_search.py | 113 ++ tests/test_memory.py | 290 +++++ 30 files changed, 5591 insertions(+) create mode 100644 .gitignore create mode 100644 README.html create mode 100644 api/__init__.py create mode 100644 api/routes.py create mode 100644 app.py create mode 100644 auth/__init__.py create mode 100644 auth/api_auth.py create mode 100644 config.py create mode 100644 doc.png create mode 100644 docs/doc.png create mode 100644 docs/index.html create mode 100644 embedding_service.py create mode 100644 gunicorn.conf.py create mode 100644 init_db.sql create mode 100644 lifecycle/__init__.py create mode 100644 lifecycle/aggregator.py create mode 100644 lifecycle/cleaner.py create mode 100644 lifecycle/compressor.py create mode 100644 lifecycle/fact_extractor.py create mode 100644 lifecycle/llm_parse.py create mode 100644 lifecycle/persona_generator.py create mode 100644 lifecycle/pipeline.py create mode 100644 llm_client.py create mode 100644 memory_system.py create mode 100644 requirements.txt create mode 100644 storage/__init__.py create mode 100644 storage/mysql_store.py create mode 100644 storage/redis_cache.py create mode 100644 storage/vector_search.py create mode 100644 tests/test_memory.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a88b515 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Environment & secrets +.env +.env.* +*.bak +*.bak.* + +# Python +__pycache__/ +*.py[cod] +venv/ +.venv/ + +# IDE +.idea/ +.vscode/ + +# Runtime +*.log +*.pid diff --git a/README.html b/README.html new file mode 100644 index 0000000..0b225e2 --- /dev/null +++ b/README.html @@ -0,0 +1,353 @@ + + + + + +多智能体记忆系统 — 内部运维文档 + + + + +

🧠 多智能体记忆系统 — 内部运维文档

+

+ 服务器: 43.156.57.141 (新加坡腾讯云)  |  + 域名: memory.lsz.name  |  + 版本: 3.0.2  |  + 最后更新: 2026-06-22 +

+ +
+ ✅ 系统状态: 所有组件运行正常
+ MySQL 8.0.46 · Redis 6.0.16 · BGE-small-zh-v1.5 (512维) · Gunicorn + Flask · Nginx + SSL (Let's Encrypt)
+ 2 个团队 · 4 个 Agent · 32+ 个人记忆 · 4 条团队记忆 · v3.0.2 性能优化(LLM thinking mode 禁用) +
+ +

🏗️ 架构概览

+ + + + + + + +
组件技术端口说明
API 服务Flask + Gunicorn8081 (local)HTTP JSON API
Nginxnginx 1.18443 (SSL)反向代理 + SSL 终止
持久存储MySQL 8.0.463306记忆数据 + 向量 BLOB
缓存层Redis 6.0.166379工作记忆 (TTL 24h)
EmbeddingBGE-small-zh-v1.58080 (local)512维语义向量
+ +

🔐 API 认证 HMAC-SHA256

+

GET /health/admin/* 外,所有 API 端点均需以下 HTTP 头:

+ + + + + +
Header说明
X-API-KeyAPI Key(以 msk_ 前缀开头)
X-TimestampUnix 时间戳(秒,5分钟内有效)
X-SignatureHMAC-SHA256(secret, timestamp + request_body)
+ +

认证流程

+
    +
  1. 获取 API Key 和 Secret(通过 /admin/api-keys 端点,仅限 localhost)
  2. +
  3. 构造请求体(JSON),取当前 Unix 时间戳
  4. +
  5. 计算签名:signature = HMAC-SHA256(secret, str(timestamp) + json_body)
  6. +
  7. 在请求头中附加三个认证字段
  8. +
+ +

Python 认证示例

+
import time, hmac, hashlib, json, requests
+
+API_KEY = "msk_xxx..."
+SECRET = "xxx..."
+BASE = "https://memory.lsz.name"
+
+def api(method, path, body=None):
+    ts = str(time.time())
+    body_bytes = json.dumps(body).encode() if body else b""
+    sig = hmac.new(
+        SECRET.encode(), ts.encode() + body_bytes, hashlib.sha256
+    ).hexdigest()
+    headers = {
+        "Content-Type": "application/json",
+        "X-API-Key": API_KEY,
+        "X-Timestamp": ts,
+        "X-Signature": sig,
+    }
+    resp = requests.request(method, BASE + path, data=body_bytes, headers=headers)
+    return resp.json()
+
+# 使用示例
+result = api("POST", "/memories/personal/search", {
+    "agent_id": "yihui",
+    "query": "策略回测",
+    "limit": 5
+})
+ +
+ ⚠️ 注意: Secret 仅在创建时显示一次,不会再次展示。请妥善保管。
+ Admin 端点仅限 127.0.0.1 访问,外部请求会被拒绝(403)。 +
+ +

👥 多团队隔离

+

所有记忆数据通过 team_id 字段实现硬隔离。查询时强制 WHERE team_id = ?。每个团队的 API Key 绑定固定的 team_id,跨团队数据完全不可见。

+ +

当前团队

+ + + + +
Team ID名称Agent 数API Key
hermes1策略研究部2 (弈回, 云策)msk_a192...85
wangcheng王成2 (Hermes Agent, OpenClaw Main)msk_6804...18
+ +

📋 记忆类型

+ + + + + +
类型存储生命周期说明
个人长期记忆MySQL持久化每个 Agent 独立的持久记忆,支持语义检索
个人工作记忆Redis ListTTL 24h短期上下文缓存
团队共享记忆MySQL持久化同团队内所有 Agent 可访问的知识库
+ +

📡 API 端点

+ +

公开端点(无需认证)

+ + + +
方法路径说明
GET/health健康检查
+ +

团队管理

+ + + + + +
方法路径说明
POST/teams创建团队
GET/teams/{team_id}获取团队信息
DELETE/teams/{team_id}删除团队(级联删除所有关联数据)
+ +

Agent 管理

+ + + + +
方法路径说明
POST/agents创建 Agent
GET/agents/{agent_id}获取 Agent 信息
+ +

个人记忆

+ + + + + + + + +
方法路径说明
POST/memories/personal添加个人记忆(自动生成 embedding)
POST/memories/personal/search语义检索个人记忆(团队隔离)
GET/memories/personal/recent/{agent_id}获取最近个人记忆
GET/memories/personal/{memory_id}获取单条个人记忆
PUT/memories/personal/{memory_id}更新个人记忆
DELETE/memories/personal/{memory_id}删除个人记忆
+ +

工作记忆

+ + + + + +
方法路径说明
POST/memories/working添加工作记忆(Redis,TTL 24h)
GET/memories/working/{agent_id}获取工作记忆
DELETE/memories/working/{agent_id}清空工作记忆
+ +

团队记忆

+ + + + + + + +
方法路径说明
POST/memories/team添加团队共享记忆
POST/memories/team/search语义检索团队记忆
GET/memories/team/recent/{team_id}获取最近团队记忆
PUT/memories/team/{memory_id}更新团队记忆
DELETE/memories/team/{memory_id}删除团队记忆
+ +

生命周期管理

+ + + + + + + + + + +
方法路径说明
POST/lifecycle/compress压缩工作记忆到长期记忆
POST/lifecycle/cleanup清理过期/低重要性记忆
POST/lifecycle/extract-facts原子事实提取(LLM, 5-15s)
POST/lifecycle/aggregate-scenes场景聚合(LLM, 5-15s)
POST/lifecycle/generate-persona画像生成(LLM, 5-15s)
POST/lifecycle/auto-configPipeline 自动化配置
GET/lifecycle/scenarios/{agent_id}获取场景块
GET/lifecycle/persona/{agent_id}获取用户画像
+ +

统计

+ + + +
方法路径说明
GET/stats?team_id=xxx获取系统统计信息
+ +

Admin 端点 仅 localhost

+ + + + + +
方法路径说明
POST/admin/api-keys创建 API Key(需 team_id, name)
GET/admin/api-keys列出所有 API Key
DELETE/admin/api-keys/{key_id}禁用 API Key
+ +

⚡ 性能指标(2026-06-08 验证)

+ + + + + + + + +
指标实测值目标状态
写入延迟 P5014.4ms
写入延迟 P9916.3ms< 100ms✅ PASS
检索延迟 P5017.9ms
检索延迟 P9927.5ms< 50ms✅ PASS
进程 RSS51 MB< 1.5 GiB✅ PASS
团队隔离100%100%✅ PASS
+ +

🗄️ 数据库表结构

+ + + + + + + + + + +
表名说明
teams团队信息(id, name, description, config JSON)
agentsAgent 信息(id, team_id FK, name, role)
personal_memories个人长期记忆(agent_id, team_id, content, embedding BLOB, importance, metadata JSON)
team_memories团队共享记忆(team_id, content, embedding BLOB, importance, category, metadata JSON)
api_keysAPI 认证密钥(team_id, api_key, secret, is_active)
pipeline_configPipeline 自动化配置(compress_every_n, cleanup_idle_days, warmup 等)
memory_scenarios场景块(team_id, agent_id, name, summary, memory_ids JSON)
user_personas用户画像(team_id, agent_id, preferences/habits/expertise JSON, summary)
+ +

🔧 运维操作

+ +

服务管理

+
# 查看服务状态
+sudo systemctl status memory-system
+
+# 重启服务
+sudo systemctl restart memory-system
+
+# 查看日志
+sudo journalctl -u memory-system -f --no-pager -n 100
+
+# Nginx 配置测试
+sudo nginx -t
+
+# 重载 Nginx
+sudo systemctl reload nginx
+ +

项目路径

+
/opt/memory-system/              # 项目根目录
+/opt/memory-system/venv/         # Python 虚拟环境
+/opt/memory-system/config.py     # 配置文件
+/opt/memory-system/app.py        # Flask 入口 + JSON 序列化
+/opt/memory-system/api/routes.py # API 路由
+/opt/memory-system/auth/api_auth.py  # HMAC 认证中间件
+/opt/memory-system/storage/      # MySQL + Redis + 向量检索
+/opt/memory-system/lifecycle/    # 记忆生命周期管理
+/opt/memory-system/tests/        # 测试
+/opt/memory-system/docs/         # 公网文档
+ +

关键配置

+
# config.py
+MYSQL_HOST = "127.0.0.1"
+MYSQL_PORT = 3306
+MYSQL_USER = "your_db_user"
+MYSQL_DATABASE = "memory_system"
+MYSQL_UNIX_SOCKET = "/var/run/mysqld/mysqld.sock"
+REDIS_HOST = "127.0.0.1"
+REDIS_PORT = 6379
+EMBEDDING_SERVICE_URL = "http://127.0.0.1:8080"
+EMBEDDING_DIM = 512
+API_PORT = 8081
+WORKING_MEMORY_TTL = 86400  # 24小时
+ +

创建新 API Key(SSH 到服务器)

+
# 通过 localhost 访问 admin 端点
+curl -X POST http://127.0.0.1:8081/admin/api-keys \
+  -H 'Content-Type: application/json' \
+  -d '{"team_id": "your_team_id", "name": "Your Key Name"}'
+
+# 响应中会包含 api_key 和 secret(仅显示一次)
+ +

SSL 证书

+
# 自动续期已配置,手动测试:
+sudo certbot renew --dry-run
+
+# 证书路径:
+/etc/letsencrypt/live/memory.lsz.name/fullchain.pem
+/etc/letsencrypt/live/memory.lsz.name/privkey.pem
+ +

🔒 安全说明

+ + +

🧪 测试验证

+
# 运行测试
+cd /opt/memory-system
+./venv/bin/python tests/test_memory.py
+

测试覆盖: 创建团队/Agent → 写入记忆 → 语义检索 → 跨团队隔离 → 工作记忆 → 性能基准

+ +
+✅ 2026-06-08 验证结果:
+14/14 项测试全部通过,包括:
+• 健康检查 · 统计端点 · 团队信息查询
+• 个人记忆语义检索 · 团队记忆语义检索
+• 写入+搜索+更新+删除全流程
+• 跨团队隔离验证(wangcheng 看不到 hermes1 数据)
+• 工作记忆 CRUD · 认证拒绝测试 +
+ + +

📋 变更日志

+ +

v3.0.2 (2026-06-22) — 性能优化

+
+核心修复:禁用 DeepSeek thinking mode
+根因:DeepSeek v4-flash 默认开启思考模式,每次 LLM 调用输出 3000-7500 字符中文推理链,token 预算被吃光
+修复:llm_client.py 新增 thinking 参数透传;llm_parse.py 所有 JSON 生成任务添加 thinking={"type": "disabled"}
+实测提速:aggregate-scenes 7.9x, generate-persona 4.9x, extract-facts 2.0s +
+ +

v3.0.1-bugfix (2026-06-22) — Bug 修复

+
+• BGE 长文本双保险截断(服务端 512 tokens + 客户端 2000 字符)
+• 空 query fallback 到 get_recent
+• embedding 失败返回友好错误,不暴露内部 URL
+• GET /memories/personal/{id} 405 → 补上 GET 路由
+• aggregate-scenes JSON 解析失败 → thinking mode 禁用 +
+ +

v3.0.0 (2026-06-08) — 生命周期管理

+
+• Pipeline 自动化引擎(自动压缩、清理、暖机)
+• 原子事实提取(/lifecycle/extract-facts)
+• 场景聚合(/lifecycle/aggregate-scenes)
+• 用户画像生成(/lifecycle/generate-persona) +
+ + + diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..9c7f58e --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +# API module diff --git a/api/routes.py b/api/routes.py new file mode 100644 index 0000000..3149124 --- /dev/null +++ b/api/routes.py @@ -0,0 +1,489 @@ +"""Flask API Routes for Memory System""" +import logging +from flask import Blueprint, request, current_app, g + +logger = logging.getLogger(__name__) +api = Blueprint("api", __name__) + + +def _get_ms(): + """Get MemorySystem instance from app context.""" + return current_app.memory_system + + +def _ok(data=None, status=200): + return current_app.json_response({"ok": True, "data": data}, status=status) + + +def _err(msg, status=400, code=None): + body = {"ok": False, "error": msg} + if code: + body["code"] = code + return current_app.json_response(body, status=status) + + +# ── Health ─────────────────────────────────────────────────────── + +@api.route("/health", methods=["GET"]) +def health(): + return _ok({"status": "ok"}) + + +# ── Team management ───────────────────────────────────────────── + +@api.route("/teams", methods=["POST"]) +def create_team(): + d = request.json or {} + team_id = d.get("team_id") + name = d.get("name") + if not team_id or not name: + return _err("team_id and name required", code="VALIDATION_ERROR") + try: + team = _get_ms().create_team(team_id, name, d.get("description", ""), d.get("config")) + return _ok(team) + except Exception as e: + return _err(str(e)) + + +@api.route("/teams/", methods=["GET"]) +def get_team(team_id): + team = _get_ms().get_team(team_id) + if not team: + return _err("Team not found", 404, code="TEAM_NOT_FOUND") + return _ok(team) + + +@api.route("/teams/", methods=["DELETE"]) +def delete_team(team_id): + _get_ms().delete_team(team_id) + return _ok() + + +# ── Agent management ──────────────────────────────────────────── + +@api.route("/agents", methods=["POST"]) +def create_agent(): + d = request.json or {} + agent_id = d.get("agent_id") + name = d.get("name") + if not agent_id or not name: + return _err("agent_id and name required", code="VALIDATION_ERROR") + try: + agent = _get_ms().create_agent(agent_id, g.team_id, name, d.get("role", "")) + return _ok(agent) + except Exception as e: + return _err(str(e)) + + +@api.route("/agents/", methods=["GET"]) +def list_agents(team_id): + if team_id != g.team_id: + return _err("Access denied: cannot view other team's agents", 403, code="FORBIDDEN_CROSS_TEAM") + agents = _get_ms().get_agents_by_team(team_id) + return _ok(agents) + + +@api.route("/agents//", methods=["GET"]) +def get_agent(team_id, agent_id): + if team_id != g.team_id: + return _err("Access denied: cannot view other team's agents", 403, code="FORBIDDEN_CROSS_TEAM") + agent = _get_ms().get_agent(agent_id) + if not agent: + return _err("Agent not found", 404, code="AGENT_NOT_FOUND") + return _ok(agent) + + +@api.route("/agents//", methods=["DELETE"]) +def delete_agent(team_id, agent_id): + if team_id != g.team_id: + return _err("Access denied: cannot delete other team's agents", 403, code="FORBIDDEN_CROSS_TEAM") + _get_ms().delete_agent(agent_id) + return _ok() + + +# ── Personal memories ─────────────────────────────────────────── + +@api.route("/memories/personal", methods=["POST"]) +def add_personal_memory(): + d = request.json or {} + agent_id = d.get("agent_id") + content = d.get("content") + if not agent_id or not content: + return _err("agent_id and content required", code="VALIDATION_ERROR") + try: + mem = _get_ms().add_personal_memory( + agent_id, content, + importance=d.get("importance", 0.5), + metadata=d.get("metadata"), + enable_dedup=d.get("enable_dedup", True), + dedup_threshold=d.get("dedup_threshold", 0.85), + ) + status = 200 if mem.get("dedup_skipped") else 201 + return _ok(mem, status) + except Exception as e: + return _err(str(e)) + + +@api.route("/memories/personal/search", methods=["POST"]) +def search_personal_memories(): + d = request.json or {} + agent_id = d.get("agent_id") + query = d.get("query") + if not agent_id: + return _err("agent_id required") + # Empty query: fallback to recent memories + if not query: + limit = d.get("limit", 10) + results = _get_ms().get_recent_personal_memories(agent_id, limit) + return _ok(results) + try: + results = _get_ms().search_personal_memories( + agent_id, query, + limit=d.get("limit", 10), + min_score=d.get("min_score", 0.3), + max_chars_per_memory=d.get("max_chars_per_memory", 0), + max_total_chars=d.get("max_total_chars", 0), + ) + return _ok(results) + except Exception as e: + return _err(str(e)) + + +@api.route("/memories/personal/recent/", methods=["GET"]) +def get_recent_personal_memories(agent_id): + limit = request.args.get("limit", 20, type=int) + results = _get_ms().get_recent_personal_memories(agent_id, limit) + return _ok(results) + + +@api.route("/memories/personal/", methods=["GET"]) +def get_personal_memory(memory_id): + mem = _get_ms().get_personal_memory(memory_id) + if not mem: + return _err("Memory not found", 404, code="MEMORY_NOT_FOUND") + return _ok(mem) + +@api.route("/memories/personal/", methods=["PUT"]) +def update_personal_memory(memory_id): + d = request.json or {} + try: + ok = _get_ms().update_personal_memory( + memory_id, + content=d.get("content"), + importance=d.get("importance"), + metadata=d.get("metadata"), + ) + if ok: + mem = _get_ms().get_personal_memory(memory_id) + return _ok(mem) + return _err("Memory not found", 404, code="MEMORY_NOT_FOUND") + except Exception as e: + return _err(str(e)) + + +@api.route("/memories/personal/", methods=["DELETE"]) +def delete_personal_memory(memory_id): + _get_ms().delete_personal_memory(memory_id) + return _ok() + + +# ── Working memory ────────────────────────────────────────────── + +@api.route("/memories/working", methods=["POST"]) +def add_working_memory(): + d = request.json or {} + agent_id = d.get("agent_id") + content = d.get("content") + if not agent_id or not content: + return _err("agent_id and content required", code="VALIDATION_ERROR") + item = _get_ms().add_working_memory(agent_id, content, ttl=d.get("ttl")) + compress_result = _get_ms().check_auto_compress(agent_id) + if compress_result: + item["auto_compressed"] = compress_result + return _ok(item, 201) + + +@api.route("/memories/working/", methods=["GET"]) +def get_working_memories(agent_id): + limit = request.args.get("limit", 20, type=int) + results = _get_ms().get_working_memories(agent_id, limit) + return _ok(results) + + +@api.route("/memories/working/", methods=["DELETE"]) +def clear_working_memory(agent_id): + _get_ms().clear_working_memory(agent_id) + return _ok() + + +# ── Team memories ─────────────────────────────────────────────── + +@api.route("/memories/team", methods=["POST"]) +def add_team_memory(): + d = request.json or {} + content = d.get("content") + if not content: + return _err("content required", code="VALIDATION_ERROR") + try: + mem = _get_ms().add_team_memory( + g.team_id, content, + importance=d.get("importance", 0.5), + category=d.get("category", "general"), + metadata=d.get("metadata"), + enable_dedup=d.get("enable_dedup", True), + dedup_threshold=d.get("dedup_threshold", 0.85), + ) + status = 200 if mem.get("dedup_skipped") else 201 + return _ok(mem, status) + except Exception as e: + return _err(str(e)) + + +@api.route("/memories/team/search", methods=["POST"]) +def search_team_memories(): + d = request.json or {} + query = d.get("query") + # Empty query: fallback to recent memories + if not query: + limit = d.get("limit", 10) + results = _get_ms().get_recent_team_memories(g.team_id, limit) + return _ok(results) + try: + results = _get_ms().search_team_memories( + g.team_id, query, + limit=d.get("limit", 10), + min_score=d.get("min_score", 0.3), + max_chars_per_memory=d.get("max_chars_per_memory", 0), + max_total_chars=d.get("max_total_chars", 0), + ) + return _ok(results) + except Exception as e: + return _err(str(e)) + + +@api.route("/memories/team/recent", methods=["GET"]) +def get_recent_team_memories(): + limit = request.args.get("limit", 20, type=int) + results = _get_ms().get_recent_team_memories(g.team_id, limit) + return _ok(results) + + +@api.route("/memories/team/", methods=["GET"]) +def get_team_memory(memory_id): + mem = _get_ms().get_team_memory(memory_id) + if not mem: + return _err("Memory not found", 404, code="MEMORY_NOT_FOUND") + return _ok(mem) + + +@api.route("/memories/team/", methods=["PUT"]) +def update_team_memory(memory_id): + d = request.json or {} + try: + ok = _get_ms().update_team_memory( + memory_id, + content=d.get("content"), + importance=d.get("importance"), + ) + if ok: + mem = _get_ms().get_team_memory(memory_id) + return _ok(mem) + return _err("Memory not found", 404, code="MEMORY_NOT_FOUND") + except Exception as e: + return _err(str(e)) + + +@api.route("/memories/team/", methods=["DELETE"]) +def delete_team_memory(memory_id): + _get_ms().delete_team_memory(memory_id) + return _ok() + + +# ── Lifecycle ─────────────────────────────────────────────────── + +@api.route("/lifecycle/compress", methods=["POST"]) +def compress_working_memories(): + d = request.json or {} + agent_id = d.get("agent_id") + if not agent_id: + return _err("agent_id required", code="VALIDATION_ERROR") + try: + result = _get_ms().compress_working_memories( + agent_id, max_items=d.get("target_count", 5), + ) + return _ok(result) + except Exception as e: + return _err(str(e)) + + +@api.route("/lifecycle/cleanup", methods=["POST"]) +def cleanup_memories(): + d = request.json or {} + result = _get_ms().cleanup_memories( + team_id=d.get("team_id"), + max_age_days=d.get("max_age_days", 90), + min_importance=d.get("min_importance", 0.2), + ) + return _ok(result) + + +# ── Stats ─────────────────────────────────────────────────────── + +@api.route("/stats", methods=["GET"]) +def get_stats(): + team_id = request.args.get("team_id") + if team_id and team_id != g.team_id: + return _err("Access denied: cannot view other team's stats", 403, code="FORBIDDEN_CROSS_TEAM") + stats = _get_ms().get_stats(g.team_id) + return _ok(stats) + + + +# -- Pipeline Automation -- + +@api.route("/lifecycle/auto-config", methods=["GET"]) +def get_pipeline_config(): + config = _get_ms().get_pipeline_config(g.team_id) + if config: + return _ok(config.to_dict()) + return _ok({"team_id": g.team_id, "compress_every_n": 0, "cleanup_idle_days": 0, "enabled": False}) + + +@api.route("/lifecycle/auto-config", methods=["POST"]) +def set_pipeline_config(): + d = request.json or {} + try: + config = _get_ms().set_pipeline_config( + team_id=g.team_id, + compress_every_n=d.get("compress_every_n"), + compress_target_count=d.get("compress_target_count"), + cleanup_idle_days=d.get("cleanup_idle_days"), + cleanup_min_importance=d.get("cleanup_min_importance"), + enabled=d.get("enabled"), + warmup_max_memories=d.get("warmup_max_memories", 0), + warmup_compress_every_n=d.get("warmup_compress_every_n", 1), + ) + return _ok(config.to_dict()) + except Exception as e: + return _err(str(e)) + + +@api.route("/lifecycle/auto-run", methods=["POST"]) +def run_pipeline_cleanup(): + d = request.json or {} + team_id = d.get("team_id", g.team_id) + result = _get_ms().run_auto_cleanup(team_id) + if result: + return _ok(result) + return _ok({"message": "No cleanup rules configured"}) + + +# -- Scenario Aggregation -- + +@api.route("/lifecycle/aggregate-scenes", methods=["POST"]) +def aggregate_scenes(): + """Aggregate personal memories into scenario blocks using LLM. + Optional body: {"max_memories": 30} + """ + d = request.json or {} + agent_id = d.get("agent_id", "") + if not agent_id: + return _err("agent_id required", code="VALIDATION_ERROR") + try: + result = _get_ms().aggregate_scenarios( + agent_id=agent_id, + team_id=g.team_id, + max_memories=d.get("max_memories", 50), + ) + except Exception as e: + logger.exception("aggregate-scenes failed") + return _err(str(e), 500, code="INTERNAL_ERROR") + if "error" in result: + return _err(result["error"]) + return _ok(result) + + +@api.route("/lifecycle/scenarios/", methods=["GET"]) +def get_scenes(agent_id): + """Get stored scenarios for an agent.""" + scenarios = _get_ms().get_scenarios(agent_id) + return _ok(scenarios) + + +@api.route("/lifecycle/scenarios/", methods=["DELETE"]) +def delete_scene(scenario_id): + """Delete a scenario.""" + ok = _get_ms().delete_scenario(scenario_id, g.team_id) + if ok: + return _ok({"deleted": scenario_id}) + return _err("Scenario not found or access denied", 404, code="NOT_FOUND") + + +# -- Persona Generation -- + +@api.route("/lifecycle/generate-persona", methods=["POST"]) +def generate_persona(): + """Generate user persona from memories and scenarios using LLM. + Body: {"agent_id": "...", "max_items": 30} + """ + d = request.json or {} + agent_id = d.get("agent_id", "") + if not agent_id: + return _err("agent_id required", code="VALIDATION_ERROR") + try: + result = _get_ms().generate_persona( + agent_id=agent_id, + team_id=g.team_id, + max_items=d.get("max_items", 30), + ) + except Exception as e: + logger.exception("generate-persona failed") + return _err(str(e), 500, code="INTERNAL_ERROR") + if "error" in result: + return _err(result["error"]) + return _ok(result) + + +@api.route("/lifecycle/persona/", methods=["GET"]) +def get_persona(agent_id): + """Get the latest persona for an agent.""" + persona = _get_ms().get_persona(agent_id) + if persona: + return _ok(persona) + return _ok({"message": "No persona found", "agent_id": agent_id}) + + +@api.route("/lifecycle/persona/", methods=["DELETE"]) +def delete_persona(persona_id): + """Delete a persona.""" + ok = _get_ms().delete_persona(persona_id, g.team_id) + if ok: + return _ok({"deleted": persona_id}) + return _err("Persona not found or access denied", 404, code="NOT_FOUND") + + +# -- Atomic Fact Extraction -- + +@api.route("/lifecycle/extract-facts", methods=["POST"]) +def extract_facts(): + """Extract structured atomic facts from working memory using LLM. + Body: {"agent_id": "...", "max_memories": 20, "delete_after": false} + Returns: {"facts": [{"id": "...", "content": "...", "importance": 0.8, "category": "..."}]} + """ + d = request.json or {} + agent_id = d.get("agent_id", "") + if not agent_id: + return _err("agent_id required", code="VALIDATION_ERROR") + try: + result = _get_ms().extract_facts( + agent_id=agent_id, + team_id=g.team_id, + max_memories=d.get("max_memories", 20), + delete_after=d.get("delete_after", False), + ) + except Exception as e: + logger.exception("extract-facts failed") + return _err(str(e), 500, code="INTERNAL_ERROR") + if "error" in result: + return _err(result["error"]) + return _ok(result) diff --git a/app.py b/app.py new file mode 100644 index 0000000..b6d4bb6 --- /dev/null +++ b/app.py @@ -0,0 +1,120 @@ +"""Flask Application Entry Point""" +import logging +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import json +from datetime import datetime, date +from flask import Flask, Response +from config import Config +from memory_system import MemorySystem +from api.routes import api +from auth.api_auth import require_auth, api_auth_bp + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +def _sanitize(obj): + """Recursively sanitize data for JSON serialization.""" + if isinstance(obj, dict): + return {k: _sanitize(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_sanitize(v) for v in obj] + if isinstance(obj, bytes): + return None + if isinstance(obj, (datetime, date)): + return obj.isoformat() + return obj + + +def json_response(data, status=200): + """Create a JSON response with proper serialization.""" + return Response( + json.dumps(_sanitize(data), ensure_ascii=False), + status=status, + mimetype="application/json", + ) + + + +LANDING_HTML = """ + + + + +Memory System - 多智能体记忆系统 + + + +
+

🧠 Memory System

+

多智能体记忆系统 —— 为 AI Agent 提供双层次记忆、语义检索和智能生命周期管理的基础设施。

+ + +
+ +""" + + +def create_app(): + app = Flask(__name__) + app.config["JSON_SORT_KEYS"] = False + app.json_response = json_response + app.sanitize = _sanitize + + # Initialize memory system + cfg = Config() + app.memory_system = MemorySystem(cfg) + logger.info("MemorySystem initialized") + + # Register auth before_request handler + app.before_request(require_auth) + logger.info("API Key auth middleware registered") + + # Register API blueprint + app.register_blueprint(api) + + # Register admin blueprint (localhost-only) + app.register_blueprint(api_auth_bp) + logger.info("Admin endpoints registered at /admin/*") + + return app + + +app = create_app() + + +@app.route("/") +def index(): + from flask import Response + return Response(LANDING_HTML, mimetype="text/html") + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=8081, debug=False) diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 0000000..39020a1 --- /dev/null +++ b/auth/__init__.py @@ -0,0 +1,2 @@ +"""Authentication module for Memory System API""" +from auth.api_auth import require_auth, api_auth_bp diff --git a/auth/api_auth.py b/auth/api_auth.py new file mode 100644 index 0000000..8c382b7 --- /dev/null +++ b/auth/api_auth.py @@ -0,0 +1,214 @@ +"""API Key + HMAC Signature Authentication for Memory System + +Auth flow: + 1. Client sends X-API-Key, X-Timestamp, X-Signature headers + 2. X-Signature = HMAC-SHA256(api_secret, timestamp + request_body) + 3. Server validates key exists, is active, timestamp within 5min, signature matches + 4. Authenticated team_id is injected into flask.g for downstream use +""" +import hashlib +import hmac +import json +import logging +import os +import secrets +import time +from datetime import datetime +from functools import wraps + +import pymysql +from flask import Blueprint, current_app, g, request + +logger = logging.getLogger(__name__) + +# Timestamp tolerance in seconds (5 minutes) +TIMESTAMP_TOLERANCE = 300 + +api_auth_bp = Blueprint("admin", __name__, url_prefix="/admin") + + +# Helpers + +def _get_db(): + ms = current_app.memory_system + return ms.mysql._get_conn() + + +def _verify_signature(api_secret, timestamp, body): + message = timestamp.encode() + body + return hmac.new(api_secret.encode(), message, hashlib.sha256).hexdigest() + + +def _lookup_api_key(api_key): + conn = _get_db() + try: + with conn.cursor(pymysql.cursors.DictCursor) as cur: + cur.execute( + "SELECT id, team_id, api_key, secret, name, is_active " + "FROM api_keys WHERE api_key = %s", + (api_key,), + ) + return cur.fetchone() + finally: + conn.close() + + +def _touch_api_key(key_id): + conn = _get_db() + try: + with conn.cursor() as cur: + cur.execute( + "UPDATE api_keys SET last_used = NOW() WHERE id = %s", + (key_id,), + ) + conn.commit() + finally: + conn.close() + + +# Auth middleware + +def require_auth(): + path = request.path + + # Skip health check, root landing page, and admin endpoints + if path in ("/", "/health") or path.startswith("/admin"): + return None + + api_key = request.headers.get("X-API-Key") + timestamp = request.headers.get("X-Timestamp") + signature = request.headers.get("X-Signature") + + if not api_key or not timestamp or not signature: + return current_app.json_response( + {"ok": False, "error": "Missing authentication headers. Required: X-API-Key, X-Timestamp, X-Signature", + "code": "AUTH_MISSING_HEADERS"}, 401) + + try: + ts = float(timestamp) + except (ValueError, TypeError): + return current_app.json_response( + {"ok": False, "error": "Invalid timestamp format", + "code": "AUTH_INVALID_TIMESTAMP"}, 401) + + now = time.time() + if abs(now - ts) > TIMESTAMP_TOLERANCE: + return current_app.json_response( + {"ok": False, "error": "Timestamp expired (5 min tolerance)", + "code": "AUTH_TIMESTAMP_EXPIRED"}, 401) + + key_record = _lookup_api_key(api_key) + if not key_record: + return current_app.json_response( + {"ok": False, "error": "Invalid API key", + "code": "AUTH_INVALID_KEY"}, 401) + + if not key_record["is_active"]: + return current_app.json_response( + {"ok": False, "error": "API key is disabled", + "code": "AUTH_KEY_DISABLED"}, 401) + + body = request.get_data() + expected = _verify_signature(key_record["secret"], timestamp, body) + if not hmac.compare_digest(signature, expected): + return current_app.json_response( + {"ok": False, "error": "Invalid signature", + "code": "AUTH_INVALID_SIGNATURE"}, 401) + + g.team_id = key_record["team_id"] + g.api_key_id = key_record["id"] + _touch_api_key(key_record["id"]) + return None + + +# Admin endpoints (localhost only) + +@api_auth_bp.before_request +def _restrict_admin_to_localhost(): + remote = request.remote_addr or "" + if remote not in ("127.0.0.1", "::1", "localhost"): + return current_app.json_response( + {"ok": False, "error": "Admin endpoints are localhost-only", + "code": "FORBIDDEN_ADMIN"}, 403) + + +@api_auth_bp.route("/api-keys", methods=["POST"]) +def create_api_key(): + d = request.json or {} + team_id = d.get("team_id") + name = d.get("name", "") + if not team_id: + return current_app.json_response( + {"ok": False, "error": "team_id required", + "code": "VALIDATION_ERROR"}, 400) + + if not current_app.memory_system.get_team(team_id): + return current_app.json_response( + {"ok": False, "error": f"Team {team_id} not found", + "code": "TEAM_NOT_FOUND"}, 404) + + key_id = secrets.token_hex(16) + api_key = "msk_" + secrets.token_hex(24) + secret = secrets.token_hex(32) + + conn = _get_db() + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO api_keys (id, team_id, api_key, secret, name, is_active) " + "VALUES (%s, %s, %s, %s, %s, TRUE)", + (key_id, team_id, api_key, secret, name), + ) + conn.commit() + finally: + conn.close() + + return current_app.json_response({ + "ok": True, + "data": { + "id": key_id, "team_id": team_id, "api_key": api_key, + "secret": secret, "name": name, + "message": "Store the secret securely. It will NOT be shown again.", + }, + }, 201) + + +@api_auth_bp.route("/api-keys", methods=["GET"]) +def list_api_keys(): + conn = _get_db() + try: + with conn.cursor(pymysql.cursors.DictCursor) as cur: + cur.execute( + "SELECT id, team_id, api_key, name, is_active, created_at, last_used " + "FROM api_keys ORDER BY created_at DESC") + rows = cur.fetchall() + finally: + conn.close() + + for row in rows: + for k, v in row.items(): + if isinstance(v, datetime): + row[k] = v.isoformat() + + return current_app.json_response({"ok": True, "data": rows}) + + +@api_auth_bp.route("/api-keys/", methods=["DELETE"]) +def disable_api_key(key_id): + conn = _get_db() + try: + with conn.cursor() as cur: + cur.execute( + "UPDATE api_keys SET is_active = FALSE WHERE id = %s", + (key_id,)) + conn.commit() + affected = cur.rowcount + finally: + conn.close() + + if affected == 0: + return current_app.json_response( + {"ok": False, "error": "API key not found", + "code": "NOT_FOUND"}, 404) + + return current_app.json_response({"ok": True, "message": "API key disabled"}) diff --git a/config.py b/config.py new file mode 100644 index 0000000..e67c9a5 --- /dev/null +++ b/config.py @@ -0,0 +1,51 @@ +"""Memory System Configuration""" +import os +import json + + +class Config: + # MySQL + MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1") + MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306")) + MYSQL_USER = os.getenv("MYSQL_USER", "your_db_user") + MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "CHANGE_ME_MYSQL_PASSWORD") + MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "memory_system") + MYSQL_CHARSET = "utf8mb4" + MYSQL_UNIX_SOCKET = os.getenv("MYSQL_UNIX_SOCKET", "/var/run/mysqld/mysqld.sock") + + # Redis + REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1") + REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) + REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "CHANGE_ME_REDIS_PASSWORD") + REDIS_DB = int(os.getenv("REDIS_DB", "0")) + + # BGE Embedding Service + EMBEDDING_SERVICE_URL = os.getenv("EMBEDDING_SERVICE_URL", "http://127.0.0.1:8080") + EMBEDDING_DIM = 512 + + # Working memory TTL (seconds) + WORKING_MEMORY_TTL = int(os.getenv("WORKING_MEMORY_TTL", "259200")) # 72h + + # Cleanup defaults + CLEANUP_MAX_AGE_DAYS = 90 + CLEANUP_MIN_IMPORTANCE = 0.2 + + # API + API_HOST = os.getenv("API_HOST", "127.0.0.1") + API_PORT = int(os.getenv("API_PORT", "8081")) + + # LLM providers (JSON list, priority=1 is primary, higher = fallback) + LLM_PROVIDERS_JSON = os.getenv("LLM_PROVIDERS", "[]") + + @property + def llm_providers(self) -> list: + try: + val = self.LLM_PROVIDERS_JSON + providers = json.loads(val) if val else [] + for p in providers: + key = p.get("api_key", "") + if isinstance(key, str) and key.startswith("${") and key.endswith("}"): + p["api_key"] = os.getenv(key[2:-1], "") or "" + return providers + except (json.JSONDecodeError, TypeError): + return [] diff --git a/doc.png b/doc.png new file mode 100644 index 0000000000000000000000000000000000000000..0fe9169e4808f61d5b8435b07fec34a47427f31b GIT binary patch literal 25000 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 MSJp-rD_REq4?GcH7XSbN literal 0 HcmV?d00001 diff --git a/docs/doc.png b/docs/doc.png new file mode 100644 index 0000000000000000000000000000000000000000..0fe9169e4808f61d5b8435b07fec34a47427f31b GIT binary patch literal 25000 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 MSJp-rD_REq4?GcH7XSbN literal 0 HcmV?d00001 diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..1df46f7 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,1809 @@ + + + + + + +多智能体记忆系统 | Memory System + + + + + + + + +
+ +

多智能体记忆系统

+

为 AI Agent 提供跨会话持久化记忆、团队知识沉淀与语义检索能力 | Memory System API v3.0.2

+ + +

1. 项目概述

+ +

1.1 背景与定位

+

在多智能体协作场景中,AI Agent 面临以下核心痛点:

+
    +
  • 无状态困境:Agent 每次对话都是从零开始,之前的交互经验无法积累
  • +
  • 会话间记忆丢失:对话结束后,上下文和决策依据全部消失
  • +
  • 团队知识孤岛:多个 Agent 各自独立工作,无法共享已学到的知识和经验
  • +
  • 检索效率低下:当记忆积累到一定规模,关键词匹配无法满足语义级别的回忆需求
  • +
  • 记忆无限膨胀:缺少自动化的清理和压缩机制,存储成本持续增长
  • +
  • 知识结构化不足:原始记忆是自由文本,缺少事实提取、场景聚合和画像生成等智能化处理能力
  • +
+ +

多智能体记忆系统正是为解决这些问题而设计的。它为 AI Agent 提供了一套双层次(工作记忆 + 长期记忆)、LLM 驱动的智能生命周期管理、多团队隔离、语义检索的记忆基础设施,让 Agent 能够像人类一样"记住"、"回忆"、"总结"和"遗忘"。

+ + +

1.2 核心能力

+
+
+

💾 持久化记忆

+

跨会话、跨部署的记忆存储。Agent 重启后依然能回忆之前学到的知识和经验。

+
+
+

🔍 语义检索

+

基于 BGE 向量模型的语义搜索,无需精确关键词匹配,用自然语言即可找到相关记忆。

+
+
+

👥 多团队隔离

+

团队间数据完全隔离,互不可见。每个团队拥有独立的记忆空间和 API Key。

+
+
+

🔄 生命周期管理

+

工作记忆自动过期、长期记忆智能压缩与清理。让记忆保持精简有价值。

+
+
+

📝 知识共享

+

团队级共享记忆,一个 Agent 的发现可以被整个团队复用,避免重复劳动。

+
+
+

⚡ 高性能架构

+

MySQL 持久化 + Redis 缓存 + BGE 向量检索,写入 P99 < 20ms,读取 P99 < 30ms。

+
+
+

🧠 智能生命周期

+

LLM 驱动的原子事实提取、场景聚合和用户画像生成,配合 Pipeline 自动化引擎实现记忆全生命周期管理。

+
+
+

🤖 Pipeline 自动化

+

可配置的自动压缩、清理规则和暖机机制。写入工作记忆时自动检查阈值,无需手动触发。

+
+
+ +

1.3 记忆层次说明

+

系统采用双层次记忆模型,模拟人类的短期记忆与长期记忆,同时支持基于 LLM 的场景聚合用户画像层:

+ + + + + + + + + + +
维度工作记忆 (Working Memory)长期记忆 (Long-term Memory)
存储介质RedisMySQL + BGE 向量
生命周期72 小时 TTL(可配置),或手动压缩后删除永久保存,直到被清理策略移除
检索方式按时间顺序获取最近条目语义向量检索(余弦相似度)
数据结构有序列表(Redis List)结构化记录 + 512 维向量
适用场景当前会话上下文、临时笔记、待处理任务经验总结、用户偏好、代码规范、决策记录
容量特点轻量、快速、自动过期大容量、可检索、支持重要性评分
持久化纯内存,服务重启丢失磁盘持久化,不怕重启
+ +

扩展数据类型

+ + + + + + +
数据类型说明生成方式持久化
场景块 (Scenarios)按主题聚合的记忆块,含摘要和关联记忆 ID 列表/lifecycle/aggregate-scenes(LLM 聚合)MySQL 持久化
用户画像 (Persona)结构化画像:偏好、习惯、擅长领域、沟通风格/lifecycle/generate-persona(LLM 提取)MySQL 持久化
原子事实 (Facts)从工作记忆提取的独立结构化事实,存为长期记忆/lifecycle/extract-facts(LLM 提取)MySQL 持久化(metadata 标记来源)
Pipeline 配置自动压缩、清理和暖机规则/lifecycle/auto-configMySQL 持久化
+ +
+

记忆流转路径

+

工作记忆 →(压缩/事实提取)→ 长期记忆 →(场景聚合/画像生成)→ 场景块/用户画像

+

长期记忆 →(清理)→ 归档/删除

+
    +
  1. 写入阶段:Agent 将当前交互的关键信息写入工作记忆(Redis),速度快、不阻塞
  2. +
  3. 沉淀阶段:通过 /lifecycle/compress 将工作记忆压缩为摘要,或通过 /lifecycle/extract-facts 提取结构化原子事实,写入长期记忆(MySQL)
  4. +
  5. 聚合阶段:通过 /lifecycle/aggregate-scenes 将相关记忆聚合为场景块,通过 /lifecycle/generate-persona 生成用户画像
  6. +
  7. 检索阶段:Agent 需要回忆时,通过语义搜索从长期记忆中找到相关条目
  8. +
  9. 清理阶段:通过 /lifecycle/cleanup/lifecycle/auto-config 配置的自动规则清理过期记忆
  10. +
+
+ +

1.4 架构流程图

+ +
+
+graph LR
+    subgraph Client["客户端"]
+        A[AI Agent
OpenClaw / Hermes / Claude Code / Codex] + end + + subgraph Gateway["接入层"] + B[Nginx
HTTPS + SSL] + C[Auth Middleware
HMAC-SHA256 签名验证] + end + + subgraph Core["核心服务 (Flask)"] + D[API Routes] + E[Memory System
业务逻辑] + F[Lifecycle Manager] + F1[Fact Extractor
原子事实提取] + F2[Scenario Aggregator
场景聚合] + F3[Persona Generator
画像生成] + F4[Pipeline Engine
自动化引擎] + end + + subgraph Storage["存储层"] + G[(MySQL
长期记忆 + 向量)] + H[(Redis
工作记忆缓存)] + I[BGE Embedding
512 维向量化] + end + + A -->|"HTTPS 请求
+ HMAC 签名"| B + B --> C + C -->|"认证通过"| D + C -.->|"认证失败 → 401"| A + D --> E + E -->|"写入记忆"| G + E -->|"缓存工作记忆"| H + E -->|"文本→向量"| I + E -->|"语义搜索"| G + F -->|"压缩: 工作→长期"| E + F -->|"清理: 过期记忆"| G + F1 -->|"提取事实→长期记忆"| E + F2 -->|"聚合场景"| G + F3 -->|"生成画像"| G + F4 -->|"自动触发"| F + F4 -->|"自动触发"| F1 + + style Client fill:#1a1f2e,stroke:#58a6ff,color:#c9d1d9 + style Gateway fill:#1a1f2e,stroke:#d29922,color:#c9d1d9 + style Core fill:#1a1f2e,stroke:#3fb950,color:#c9d1d9 + style Storage fill:#1a1f2e,stroke:#f85149,color:#c9d1d9 +
+
+ +
+

数据流说明

+ + + + + + + + + + + +
流程路径说明
写入记忆Agent → Nginx → Auth → Routes → Memory System → MySQL + BGE向量文本经 BGE 模型向量化后,连同元数据一起持久化到 MySQL
语义检索Agent → Nginx → Auth → Routes → Memory System → BGE向量 → MySQL向量比对查询文本向量化后,与存储向量计算余弦相似度,返回 Top-K 结果
工作记忆Agent → Nginx → Auth → Routes → Memory System → Redis List写入 Redis List(TTL 自动过期),读取时按时间倒序返回
压缩Lifecycle → 读取工作记忆 → 调用 LLM 摘要 → 写入长期记忆 → 删除工作记忆将多条工作记忆压缩为一条长期记忆摘要
事实提取Lifecycle → 读取工作记忆 → LLM 提取结构化事实 → 每条事实存为独立长期记忆从工作记忆中提取独立的、可检索的原子事实
场景聚合Lifecycle → 读取个人记忆 → LLM 按主题聚合 → 存储场景块将相关记忆聚合为场景块并生成摘要
画像生成Lifecycle → 读取记忆+场景 → LLM 分析 → 输出结构化画像从历史记忆中提取用户偏好、习惯、擅长领域
PipelineLifecycle → 每次写入工作记忆 → 检查阈值 → 自动触发压缩/清理可配置的自动压缩、清理和暖机规则
清理Lifecycle → 查询过期记忆 → 删除低重要性条目基于 last_accessed + importance 双维度清理
+
+ +

1.5 适用场景

+
+
+

🤖 多智能体协作开发

+

多个 AI Agent 协同编写代码,共享编码规范、设计决策和技术栈知识,避免重复踩坑。

+
+
+

💬 客服系统

+

客服 Agent 记住用户历史问题和偏好,提供个性化服务。团队共享常见问题解决方案。

+
+
+

🔬 研究团队

+

研究 Agent 积累文献笔记、实验结论和方法论,团队成员可互相检索和引用。

+
+
+

📊 个人助理

+

个人 Agent 记住你的偏好、习惯和待办事项,跨会话提供连续的个性化体验。

+
+
+

📈 量化策略

+

策略 Agent 积累回测经验、参数调优记录和市场观察,形成可检索的策略知识库。

+
+
+

🏗️ 项目管理

+

PM Agent 记录会议决策、进度更新和风险点,团队成员随时回顾项目上下文。

+
+
+ + +

2. 快速开始

+ +

2.1 前置条件

+ + + + + +
条件说明
HTTP 客户端curl / Python requests / 任何支持 HTTP 的语言
API Key + Secret由管理员签发,每个团队一对
时钟同步客户端时钟偏差需在 ±5 分钟以内(NTP 同步即可)
+ +

2.2 获取 API Key

+
+

API Key 由管理员统一分配。请联系系统管理员获取:

+
    +
  • X-API-Key:API 访问密钥,用于标识团队身份
  • +
  • Secret:签名密钥,用于计算 HMAC-SHA256 签名。仅在创建时展示一次,请妥善保管
  • +
+
每个 API Key 绑定一个团队(team_id),同团队下多个 Agent 共用同一个 Key。
+
+ +

2.3 第一个请求

+

获取 API Key 后,先调用 /health 验证连通性(无需认证):

+
curl -s https://memory.lsz.name/health
+# {"ok": true, "data": {"status": "ok"}}
+ +

然后用认证接口验证 Key 是否正常:

+
import hashlib, hmac, time, json, requests
+
+API_KEY = "your_api_key_here"
+SECRET  = "your_secret_here"
+BASE    = "https://memory.lsz.name"
+
+ts  = int(time.time())
+sig = hmac.new(SECRET.encode(), str(ts).encode(), hashlib.sha256).hexdigest()
+
+r = requests.get(f"{BASE}/stats", headers={
+    "X-API-Key": API_KEY,
+    "X-Timestamp": str(ts),
+    "X-Signature": sig
+})
+print(r.json())
+# {"ok": true, "data": {"personal_memories": 0, "team_memories": 0, "agents": 0, ...}}
+ +

2.4 Python SDK(MemoryClient)

+

推荐使用封装好的客户端类,所有签名细节自动处理:

+
import hashlib, hmac, time, json, requests
+
+class MemoryClient:
+    """Memory System API 客户端 — 自动处理 HMAC 签名"""
+
+    def __init__(self, api_key, secret, base_url="https://memory.lsz.name"):
+        self.api_key = api_key
+        self.secret = secret
+        self.base_url = base_url
+
+    def _sign(self, method, ts, body=None):
+        if method == "GET" or body is None:
+            msg = str(ts).encode()            # GET: body = b""
+        else:
+            msg = str(ts).encode() + json.dumps(body, ensure_ascii=True).encode()
+        return hmac.new(self.secret.encode(), msg, hashlib.sha256).hexdigest()
+
+    def _headers(self, method, ts, body=None):
+        return {
+            "X-API-Key": self.api_key,
+            "X-Timestamp": str(ts),
+            "X-Signature": self._sign(method, ts, body),
+        }
+
+    def get(self, path, params=None):
+        ts = int(time.time())
+        return requests.get(f"{self.base_url}{path}",
+                            headers=self._headers("GET", ts), params=params)
+
+    def post(self, path, body):
+        ts = int(time.time())
+        return requests.post(f"{self.base_url}{path}",
+                             json=body, headers=self._headers("POST", ts, body))
+
+    def put(self, path, body):
+        ts = int(time.time())
+        return requests.put(f"{self.base_url}{path}",
+                            json=body, headers=self._headers("PUT", ts, body))
+
+    def delete(self, path):
+        ts = int(time.time())
+        return requests.delete(f"{self.base_url}{path}",
+                               headers=self._headers("DELETE", ts))
+
+    # ---- 便捷方法 ----
+    def write_memory(self, agent_id, content, importance=0.5):
+        return self.post("/memories/personal",
+                         {"agent_id": agent_id, "content": content, "importance": importance})
+
+    def search(self, agent_id, query, limit=10, min_score=0.3, max_chars_per_memory=0, max_total_chars=0):
+        body = {"agent_id": agent_id, "query": query, "limit": limit, "min_score": min_score}
+        if max_chars_per_memory:
+            body["max_chars_per_memory"] = max_chars_per_memory
+        if max_total_chars:
+            body["max_total_chars"] = max_total_chars
+        return self.post("/memories/personal/search", body)
+
+    def get_recent(self, agent_id, limit=10):
+        return self.get(f"/memories/personal/recent/{agent_id}", params={"limit": limit})
+
+    def get_stats(self):
+        return self.get("/stats")
+
+    # ---- 生命周期方法 ----
+    def extract_facts(self, agent_id, max_memories=20, delete_after=False):
+        从工作记忆提取原子事实
+        return self.post("/lifecycle/extract-facts",
+                         {"agent_id": agent_id, "max_memories": max_memories, "delete_after": delete_after})
+
+    def aggregate_scenarios(self, agent_id, max_memories=50):
+        将记忆按主题聚合为场景块
+        return self.post("/lifecycle/aggregate-scenes",
+                         {"agent_id": agent_id, "max_memories": max_memories})
+
+    def get_scenarios(self, agent_id):
+        获取已存储的场景块
+        return self.get(f"/lifecycle/scenarios/{agent_id}")
+
+    def generate_persona(self, agent_id, max_items=30):
+        从记忆和场景中生成用户画像
+        return self.post("/lifecycle/generate-persona",
+                         {"agent_id": agent_id, "max_items": max_items})
+
+    def get_persona(self, agent_id):
+        获取最新用户画像
+        return self.get(f"/lifecycle/persona/{agent_id}")
+
+    def set_pipeline_config(self, **kwargs):
+        设置 Pipeline 自动化配置
+        return self.post("/lifecycle/auto-config", kwargs)
+
+    def get_pipeline_config(self):
+        获取 Pipeline 自动化配置
+        return self.get("/lifecycle/auto-config")
+
+    def compress(self, agent_id, target_count=5):
+        压缩工作记忆为长期记忆
+        return self.post("/lifecycle/compress",
+                         {"agent_id": agent_id, "target_count": target_count})
+
+
+# 使用示例
+client = MemoryClient("your_api_key_here", "your_secret_here")
+r = client.write_memory("assistant", "用户偏好简洁回复", importance=0.8)
+print(r.json())
+ + +

3. 认证机制

+ +

3.1 API Key 与签名

+

/health 外,所有接口均需认证。认证方式为 API Key + HMAC-SHA256 签名

+ +

认证模型

+
    +
  • 每个团队分配唯一的 API Key + Secret,Key 绑定到对应 team_id
  • +
  • 同团队下多个 Agent 共用同一个 Key
  • +
  • 跨团队数据隔离由系统强制保证
  • +
+ +

请求头

+ + + + + +
Header说明
X-API-KeyAPI 访问密钥
X-TimestampUTC Unix 时间戳(秒),误差范围 ±5 分钟
X-SignatureHMAC-SHA256 签名(十六进制)
+ +

3.2 签名算法详解

+ +

签名公式

+
GET 请求:  HMAC-SHA256(secret, str(timestamp))             # body 为空字节 b""
+POST 请求: HMAC-SHA256(secret, str(timestamp) + raw_body)  # body 为原始 JSON 字节
+ +

签名要点

+
    +
  • GET 请求使用空 body b"" 进行签名(不是 b"{}"
  • +
  • POST / PUT 请求使用实际发送的原始 JSON 字节。Python requests.post(url, json=body) 默认 ensure_ascii=True,中文字符会被转义(如 \u7b56\u7565),签名必须使用相同的序列化方式
  • +
  • 时间戳过期后不可直接重试:必须用新的当前时间戳重新计算签名
  • +
  • 时钟同步:客户端与服务器时钟偏差需在 ±5 分钟以内
  • +
+ +

3.3 错误码说明

+

认证失败时返回 {"ok": false, "error": "...", "code": "..."} 格式:

+ +

认证错误码

+ + + + + + + + +
HTTPcode说明
401AUTH_MISSING_HEADERS缺少 X-API-Key / X-Timestamp / X-Signature
401AUTH_INVALID_TIMESTAMP时间戳格式错误(非数字)
401AUTH_TIMESTAMP_EXPIRED时间戳过期(超出 ±5 分钟窗口)
401AUTH_INVALID_KEYAPI Key 不存在
401AUTH_KEY_DISABLEDAPI Key 已被禁用
401AUTH_INVALID_SIGNATURE签名不匹配
+ +

通用错误码

+ + + + + + + +
HTTPcode说明
400VALIDATION_ERROR缺少必填参数或参数格式错误
403FORBIDDEN_CROSS_TEAM无权访问其他团队的数据
404TEAM_NOT_FOUND团队不存在
404AGENT_NOT_FOUNDAgent 不存在
404NOT_FOUND资源不存在
+ +

3.4 跨语言签名示例

+ +

Python

+
import hashlib, hmac, time
+
+def sign(secret, timestamp, body=None):
+    """body=None 表示 GET 请求,使用空字节"""
+    if body is None:
+        msg = str(timestamp).encode()
+    else:
+        msg = str(timestamp).encode() + body  # body 已经是 bytes
+    return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
+
+ts = int(time.time())
+sig = sign("your_secret_here", ts)  # GET 请求
+
+ +

Node.js

+
const crypto = require('crypto');
+
+function sign(secret, timestamp, body = null) {
+    const msg = body
+        ? Buffer.concat([Buffer.from(String(timestamp)), body])
+        : Buffer.from(String(timestamp));
+    return crypto.createHmac('sha256', secret).update(msg).digest('hex');
+}
+
+const ts = Math.floor(Date.now() / 1000);
+const sig = sign('your_secret_here', ts);  // GET 请求
+
+ +

Go

+
import (
+    "crypto/hmac"
+    "crypto/sha256"
+    "encoding/hex"
+    "strconv"
+)
+
+func sign(secret string, timestamp int64, body []byte) string {
+    msg := strconv.FormatInt(timestamp, 10)
+    if body != nil {
+        msg += string(body)
+    }
+    mac := hmac.New(sha256.New, []byte(secret))
+    mac.Write([]byte(msg))
+    return hex.EncodeToString(mac.Sum(nil))
+}
+
+ts := time.Now().Unix()
+sig := sign("your_secret_here", ts, nil)  // GET 请求
+
+ + +

4. API 端点

+ +

4.1 系统

+ +
+GET/health公开 +
健康检查。不需要认证。
+
curl -s https://memory.lsz.name/health
+# {"ok": true, "data": {"status": "ok"}}
+
+ +
+GET/stats需认证 +
获取当前团队的统计信息(记忆条数、Agent 数量等)。返回的数据自动限定在 API Key 所属的团队范围内。
+
curl -X GET "https://memory.lsz.name/stats" \
+  -H "X-API-Key: your_api_key_here" \
+  -H "X-Timestamp: $(date +%s)" \
+  -H "X-Signature: <signature>"
+# {"ok": true, "data": {
+#   "personal_memories": 11,
+#   "team_memories": 4,
+#   "agents": 4,
+#   "redis_ok": true,
+#   "embedding_ok": true
+# }}
+
+# 响应字段:
+#   personal_memories  int   个人记忆条数
+#   team_memories      int   团队共享记忆条数
+#   agents             int   Agent 数量
+#   redis_ok           bool  Redis 连接状态
+#   embedding_ok       bool  Embedding 服务状态
+
+ +

4.2 团队管理

+ +
+POST/teams需认证 +
创建团队。
+
curl -X POST https://memory.lsz.name/teams \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"team_id": "my_team", "name": "My Team", "description": "团队描述"}'
+# {"ok": true, "data": {"id": "my_team", "name": "My Team", ...}}
+
+# 参数:
+#   team_id     string  必填  团队唯一标识
+#   name        string  必填  团队名称
+#   description string  可选  描述
+#   config      object  可选  配置(JSON)
+
+ +
+GET/teams/{team_id}需认证 +
查询团队信息。
+
curl -X GET "https://memory.lsz.name/teams/my_team" \
+  -H "X-API-Key: your_api_key_here" \
+  -H "X-Timestamp: <unix-ts>" \
+  -H "X-Signature: <signature>"
+# {"ok": true, "data": {"id": "my_team", "name": "My Team", ...}}
+
+ +
+DELETE/teams/{team_id}需认证 +
删除团队及其所有记忆。
+
curl -X DELETE "https://memory.lsz.name/teams/my_team" \
+  -H "X-API-Key: your_api_key_here" \
+  -H "X-Timestamp: <unix-ts>" \
+  -H "X-Signature: <signature>"
+# {"ok": true, "data": null}
+
+ +

4.3 Agent 管理

+ +
+POST/agents需认证 +
在当前团队下创建 Agent。
+
curl -X POST https://memory.lsz.name/agents \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"agent_id": "researcher", "name": "研究员", "role": "research"}'
+# {"ok": true, "data": {"id": "researcher", "team_id": "my_team", "name": "研究员", ...}}
+
+# 参数:
+#   agent_id string  必填  Agent 唯一标识(团队内唯一)
+#   name     string  必填  显示名称
+#   role     string  可选  角色描述
+#   config   object  可选  配置(JSON)
+
+ +
+GET/agents/{team_id}/{agent_id}需认证 +
查询 Agent 信息。仅能查询本团队的 Agent。
+
curl -X GET "https://memory.lsz.name/agents/my_team/researcher" \
+  -H "X-API-Key: your_api_key_here" \
+  -H "X-Timestamp: <unix-ts>" \
+  -H "X-Signature: <signature>"
+# {"ok": true, "data": {"id": "researcher", "team_id": "my_team", ...}}
+
+ +
+GET/agents/{team_id}需认证 +
列出团队下所有 Agent。
+
curl -X GET "https://memory.lsz.name/agents/my_team" \
+  -H "X-API-Key: your_api_key_here" \
+  -H "X-Timestamp: <unix-ts>" \
+  -H "X-Signature: <signature>"
+# {"ok": true, "data": [{"id": "researcher", ...}, {"id": "coder", ...}]}
+
+ +
+DELETE/agents/{team_id}/{agent_id}需认证 +
删除 Agent 及其个人记忆和工作记忆。
+
curl -X DELETE "https://memory.lsz.name/agents/my_team/researcher" \
+  -H "X-API-Key: your_api_key_here" \
+  -H "X-Timestamp: <unix-ts>" \
+  -H "X-Signature: <signature>"
+# {"ok": true, "data": null}
+
+ +

4.4 个人长期记忆

+ +
+POST/memories/personal需认证 +
写入个人长期记忆。自动进行 BGE 向量化并持久化到 MySQL。
+
curl -X POST https://memory.lsz.name/memories/personal \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"agent_id": "researcher", "content": "用户偏好简洁回复,不喜欢冗长解释", "importance": 0.8}'
+# {"ok": true, "data": {"id": 42, "content": "用户偏好简洁回复...", "importance": 0.8, ...}}
+
+# 参数:
+#   agent_id        string  必填  Agent 标识
+#   content         string  必填  记忆内容
+#   importance      float   可选  重要性评分 0.0~1.0(默认 0.5)
+#   metadata        object  可选  元数据(JSON)
+#   enable_dedup    bool    可选  是否启用去重(默认 true)
+#   dedup_threshold float   可选  去重相似度阈值 0.0~1.0(默认 0.85)
+#
+# 去重行为: 当 enable_dedup=true 时,系统会将新记忆与已有记忆做向量相似度
+# 比较。若存在相似度 >= dedup_threshold 的已有记忆,则跳过写入并返回该已有
+# 记忆(响应中包含 dedup_skipped=true 和 dedup_score 字段)。
+
+ +
+POST/memories/personal/search需认证 +
语义检索个人记忆。查询文本经 BGE 向量化后,与存储向量计算余弦相似度,返回最相关的 Top-K 结果。命中的记忆会自动更新 last_accessed
提示:query 为空时,自动 fallback 为返回最近的记忆列表(等同于 GET /memories/personal/recent/{agent_id})。
+
curl -X POST https://memory.lsz.name/memories/personal/search \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"agent_id": "researcher", "query": "用户的沟通偏好", "limit": 5, "min_score": 0.3}'
+# {"ok": true, "data": [
+#   {"id": 42, "content": "用户偏好简洁回复...", "importance": 0.8, "score": 0.75,
+#    "metadata": {"source": "compression", "source_items": [...]}, ...},
+#   ...
+# ]}
+
+# 参数:
+#   agent_id           string  必填  Agent 标识
+#   query              string  可选  查询文本(自然语言),为空时返回最近记忆
+#   limit              int     可选  返回条数(默认 10,最大 50)
+#   min_score          float   可选  最低相似度阈值 0.0~1.0(默认 0.3)
+#   max_chars_per_memory int   可选  单条记忆最大字符数(0=不限制)
+#   max_total_chars    int     可选  所有返回记忆的总字符预算(0=不限制)
+#
+# 字符限制: max_chars_per_memory 会截断超长单条记忆(末尾追加 "...")。
+# max_total_chars 在累计达到预算后停止返回更多结果。两者可组合使用。
+
+ +
+GET/memories/personal/recent/{agent_id}需认证 +
获取最近的个人记忆列表(按时间倒序)。读取时自动更新 last_accessed
+
curl -X GET "https://memory.lsz.name/memories/personal/recent/researcher?limit=10" \
+  -H "X-API-Key: your_api_key_here" \
+  -H "X-Timestamp: <unix-ts>" \
+  -H "X-Signature: <signature>"
+# {"ok": true, "data": [{"id": 42, "content": "...", "importance": 0.8, ...}, ...]}
+
+# 查询参数:
+#   limit  int  可选  返回条数(默认 20,最大 100)
+
+ +
+PUT/memories/personal/{id}需认证 +
更新个人记忆(内容、重要性等)。更新时自动重新向量化并更新 last_accessed
+
curl -X PUT https://memory.lsz.name/memories/personal/42 \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"content": "用户偏好简洁回复,重要结论需要给出论据", "importance": 0.9}'
+# {"ok": true, "data": {"id": 42, "content": "...", "importance": 0.9, ...}}
+
+ +
+DELETE/memories/personal/{id}需认证 +
删除个人记忆。
+
curl -X DELETE "https://memory.lsz.name/memories/personal/42" \
+  -H "X-API-Key: your_api_key_here" \
+  -H "X-Timestamp: <unix-ts>" \
+  -H "X-Signature: <signature>"
+# {"ok": true, "data": null}
+
+ +

4.5 团队共享记忆

+ +
+POST/memories/team需认证 +
写入团队共享记忆。团队内所有 Agent 可检索。
+
curl -X POST https://memory.lsz.name/memories/team \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"content": "代码规范:所有接口返回 ResultBean 统一格式", "importance": 0.9}'
+# {"ok": true, "data": {"id": 5, "content": "代码规范:...", "importance": 0.9, ...}}
+
+# 参数:
+#   content         string  必填  记忆内容
+#   importance      float   可选  重要性评分 0.0~1.0(默认 0.5)
+#   category        string  可选  分类标签(默认 "general")
+#   metadata        object  可选  元数据(JSON)
+#   enable_dedup    bool    可选  是否启用去重(默认 true)
+#   dedup_threshold float   可选  去重相似度阈值 0.0~1.0(默认 0.85)
+
+ +
+POST/memories/team/search需认证 +
语义检索团队共享记忆。提示:query 为空时,自动 fallback 为返回最近的记忆列表(等同于 GET /memories/team/recent)。
+
curl -X POST https://memory.lsz.name/memories/team/search \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"query": "接口规范", "limit": 5, "min_score": 0.3}'
+# {"ok": true, "data": [{"id": 5, "content": "代码规范:...", "score": 0.82, ...}, ...]}
+
+# 参数:
+#   query              string  可选  查询文本,为空时返回最近记忆
+#   limit              int     可选  返回条数(默认 10,最大 50)
+#   min_score          float   可选  最低相似度阈值(默认 0.3)
+#   max_chars_per_memory int   可选  单条记忆最大字符数(0=不限制)
+#   max_total_chars    int     可选  所有返回记忆的总字符预算(0=不限制)
+
+ +
+GET/memories/team/recent需认证 +
获取最近的团队共享记忆列表。
+
curl -X GET "https://memory.lsz.name/memories/team/recent?limit=10" \
+  -H "X-API-Key: your_api_key_here" \
+  -H "X-Timestamp: <unix-ts>" \
+  -H "X-Signature: <signature>"
+# {"ok": true, "data": [{"id": 5, "content": "...", ...}, ...]}
+
+ +
+PUT/memories/team/{id}需认证 +
更新团队共享记忆。
+
curl -X PUT https://memory.lsz.name/memories/team/5 \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"content": "代码规范:所有接口返回 ResultBean,含 ok/data/error/code 字段"}'
+# {"ok": true, "data": {"id": 5, "content": "...", ...}}
+
+ +
+DELETE/memories/team/{id}需认证 +
删除团队共享记忆。
+
curl -X DELETE "https://memory.lsz.name/memories/team/5" \
+  -H "X-API-Key: your_api_key_here" \
+  -H "X-Timestamp: <unix-ts>" \
+  -H "X-Signature: <signature>"
+# {"ok": true, "data": null}
+
+ +

4.6 工作记忆

+ +
+POST/memories/working需认证 +
写入工作记忆。存储在 Redis 中,默认 TTL 72 小时。
+
curl -X POST https://memory.lsz.name/memories/working \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"agent_id": "researcher", "content": "当前正在处理用户关于量化策略的查询"}'
+# {"ok": true, "data": {"agent_id": "researcher", "content": "..."}}
+
+# 参数:
+#   agent_id  string  必填  Agent 标识
+#   content   string  必填  工作记忆内容
+#   ttl       int     可选  TTL 秒数(默认 259200,即 72 小时)
+
+ +
+GET/memories/working/{agent_id}需认证 +
获取指定 Agent 的工作记忆列表(按时间倒序)。
+
curl -X GET "https://memory.lsz.name/memories/working/researcher?limit=20" \
+  -H "X-API-Key: your_api_key_here" \
+  -H "X-Timestamp: <unix-ts>" \
+  -H "X-Signature: <signature>"
+# {"ok": true, "data": [{"content": "...", "created_at": "..."}, ...]}
+
+# 查询参数:
+#   limit  int  可选  返回条数(默认 50)
+
+ +
+DELETE/memories/working/{agent_id}需认证 +
清空指定 Agent 的全部工作记忆。
+
curl -X DELETE "https://memory.lsz.name/memories/working/researcher" \
+  -H "X-API-Key: your_api_key_here" \
+  -H "X-Timestamp: <unix-ts>" \
+  -H "X-Signature: <signature>"
+# {"ok": true, "data": {"cleared": 5}}
+
+ +

4.7 生命周期管理

+ +
+POST/lifecycle/compress需认证 +
将工作记忆压缩为长期记忆。从 Redis 读取工作记忆,调用 LLM 生成摘要后写入 MySQL 长期记忆,原始工作记忆被删除。
+
curl -X POST https://memory.lsz.name/lifecycle/compress \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"agent_id": "researcher", "target_count": 5}'
+# {"ok": true, "data": {
+#   "compressed": 12,
+#   "remaining": 3,
+#   "total_before": 15,
+#   "target_count": 5,
+#   "summary": "用户主要关注量化策略优化..."
+# }}
+
+# 参数:
+#   agent_id     string  必填  Agent 标识
+#   target_count int     可选  压缩后保留的目标条数(默认 5)
+
+# 响应字段:
+#   compressed    int     已压缩的条目数
+#   remaining     int     压缩后剩余的工作记忆条目数
+#   total_before  int     压缩前的工作记忆总条目数
+#   target_count  int     请求的目标压缩条数
+#   summary       string  生成的摘要文本(可为 null)
+
+ +
+POST/lifecycle/cleanup需认证 +
清理过期和低重要性的长期记忆。基于 last_accessed(或回退到 created_at)和 importance 双维度筛选。
+
curl -X POST https://memory.lsz.name/lifecycle/cleanup \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"max_age_days": 90, "min_importance": 0.2}'
+# {"ok": true, "data": {"cleaned": 3, "remaining": 8}}
+
+# 参数:
+#   max_age_days   int     可选  最大未访问天数(默认 90)
+#   min_importance float   可选  重要性阈值 0.0~1.0(默认 0.2)
+#                                仅清理 importance < min_importance
+#                                且 last_accessed > max_age_days 的记忆
+
+ +
+POST/lifecycle/extract-facts需认证 +
从工作记忆中提取结构化原子事实。调用 LLM 分析工作记忆内容,提取独立的、有价值的结构化事实,每条事实存为独立的长期记忆。
+
⏱️ LLM 驱动接口:此接口调用大语言模型处理,预计耗时 5-15 秒,请适当设置客户端超时。
+ +
curl -X POST https://memory.lsz.name/lifecycle/extract-facts \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"agent_id": "researcher", "max_memories": 20, "delete_after": false}'
+# {"ok": true, "data": {
+#   "facts": [
+#     {"id": "abc123", "content": "...", "importance": 0.8, "category": "..."},
+#     ...
+#   ],
+#   "count": 4
+# }}
+
+# 参数:
+#   agent_id      string  必填  Agent 标识
+#   max_memories  int     可选  分析的最大工作记忆条数(默认 20)
+#   delete_after  bool    可选  提取后是否清空工作记忆(默认 false)
+
+# 响应字段:
+#   facts   array   提取的事实列表,每条含 id/content/importance/category
+#   count   int     提取的事实数量
+
+ +
+POST/lifecycle/aggregate-scenes需认证 +
将个人记忆按主题聚合成场景块。由系统内置 LLM 自动分析记忆内容,将相关记忆归入同一场景并生成摘要,无需额外配置。
+
⏱️ LLM 驱动接口:此接口调用大语言模型处理,预计耗时 5-15 秒,请适当设置客户端超时。
+ +
curl -X POST https://memory.lsz.name/lifecycle/aggregate-scenes \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"agent_id": "researcher", "max_memories": 50}'
+# {"ok": true, "data": {
+#   "scenarios": [
+#     {"id": "sc001", "name": "...", "summary": "...", "memory_ids": ["m1","m2"]},
+#     ...
+#   ],
+#   "count": 3
+# }}
+
+# 参数:
+#   agent_id      string  必填  Agent 标识
+#   max_memories  int     可选  分析的最大记忆条数(默认 50)
+
+# 响应字段:
+#   scenarios  array  场景列表,每条含 id/name/summary/memory_ids
+#   count      int    场景数量
+
+ +
+GET/lifecycle/scenarios/{agent_id}需认证 +
获取指定 Agent 已存储的场景块。
+
curl https://memory.lsz.name/lifecycle/scenarios/researcher \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>'
+# {"ok": true, "data": [
+#   {"id": "sc001", "name": "...", "summary": "...", "memory_ids": [...], "created_at": "..."},
+#   ...
+# ]}
+
+ +
+DELETE/lifecycle/scenarios/{id}需认证 +
删除指定场景块(需属于同一团队)。
+
curl -X DELETE https://memory.lsz.name/lifecycle/scenarios/sc001 \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>'
+# {"ok": true, "data": {"deleted": "sc001"}}
+
+ +
+POST/lifecycle/generate-persona需认证 +
从记忆和场景中生成用户画像。由系统内置 LLM 自动分析历史记忆,输出结构化画像(偏好、习惯、擅长领域、沟通风格、摘要),无需额外配置。
+
⏱️ LLM 驱动接口:此接口调用大语言模型处理,预计耗时 5-15 秒,请适当设置客户端超时。
+ +
curl -X POST https://memory.lsz.name/lifecycle/generate-persona \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{"agent_id": "researcher", "max_items": 30}'
+# {"ok": true, "data": {
+#   "persona": {
+#     "id": "p001",
+#     "preferences": ["...", "..."],
+#     "habits": ["...", "..."],
+#     "expertise": ["...", "..."],
+#     "communication_style": "...",
+#     "summary": "..."
+#   }
+# }}
+
+# 参数:
+#   agent_id   string  必填  Agent 标识
+#   max_items  int     可选  分析的最大记忆+场景条数(默认 30)
+
+# 响应字段:
+#   persona.preferences        array   用户偏好列表
+#   persona.habits             array   用户习惯列表
+#   persona.expertise          array   擅长领域列表
+#   persona.communication_style string  沟通风格描述
+#   persona.summary            string  画像摘要
+
+ +
+GET/lifecycle/persona/{agent_id}需认证 +
获取指定 Agent 的最新用户画像。
+
curl https://memory.lsz.name/lifecycle/persona/researcher \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>'
+# {"ok": true, "data": {
+#   "id": "p001", "agent_id": "researcher", "preferences": [...], ...
+# }}
+
+ +
+DELETE/lifecycle/persona/{id}需认证 +
删除指定用户画像(需属于同一团队)。
+
curl -X DELETE https://memory.lsz.name/lifecycle/persona/p001 \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>'
+# {"ok": true, "data": {"deleted": "p001"}}
+
+ +
+GET/lifecycle/auto-config需认证 +
查看当前 Pipeline 自动化配置(压缩规则、清理规则、暖机规则)。
+
curl https://memory.lsz.name/lifecycle/auto-config \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>'
+# {"ok": true, "data": {
+#   "team_id": "hermes1",
+#   "compress_every_n": 10,
+#   "compress_target_count": 5,
+#   "cleanup_idle_days": 30,
+#   "cleanup_min_importance": 0.2,
+#   "enabled": true,
+#   "warmup_max_memories": 5,
+#   "warmup_compress_every_n": 2
+# }}
+
+ +
+POST/lifecycle/auto-config需认证 +
设置 Pipeline 自动化规则。可配置自动压缩阈值、清理策略和暖机规则。
+
curl -X POST https://memory.lsz.name/lifecycle/auto-config \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{
+    "compress_every_n": 10,
+    "compress_target_count": 5,
+    "cleanup_idle_days": 30,
+    "cleanup_min_importance": 0.2,
+    "enabled": true,
+    "warmup_max_memories": 5,
+    "warmup_compress_every_n": 2
+  }'
+# {"ok": true, "data": {...}}
+
+# 参数:
+#   compress_every_n         int     可选  工作记忆达到 N 条时自动压缩(0=禁用)
+#   compress_target_count    int     可选  压缩后保留的目标条数(默认 5)
+#   cleanup_idle_days        int     可选  自动清理未访问天数(0=禁用)
+#   cleanup_min_importance   float   可选  清理的重要性阈值(默认 0.2)
+#   enabled                  bool    可选  是否启用自动规则(默认 true)
+#   warmup_max_memories      int     可选  暖机阈值:个人记忆少于 N 条时进入暖机模式(0=禁用)
+#   warmup_compress_every_n  int     可选  暖机模式下的压缩阈值(默认 1)
+
+ +
+POST/lifecycle/auto-run需认证 +
手动触发一次自动清理。根据当前配置清理过期和低重要性记忆。
+
curl -X POST https://memory.lsz.name/lifecycle/auto-run \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: your_api_key_here' \
+  -H 'X-Timestamp: <unix-ts>' \
+  -H 'X-Signature: <signature>' \
+  -d '{}'
+# {"ok": true, "data": {"results": [...]}}
+
+ +
+

last_accessed 字段维护说明

+

系统在以下操作时自动更新记忆的 last_accessed 时间戳:

+
    +
  • 语义检索命中POST /memories/personal/searchPOST /memories/team/search
  • +
  • 读取最近记忆GET /memories/personal/recent/{id}GET /memories/team/recent
  • +
  • 更新记忆内容PUT /memories/personal/{id}PUT /memories/team/{id}
  • +
+

新创建的记忆 last_accessed 初始为 NULL,cleanup 时回退到 created_at。因此从未被访问过的记忆也会被纳入清理范围。

+
+ + +

5. 对接示例

+ +

以下示例均使用前文的 MemoryClient 类。请将 API_KEYSECRET 替换为实际值。

+ +

5.1 OpenClaw 对接

+

场景:AI 助手 OpenClaw 记住用户偏好和团队知识。

+
from memory_client import MemoryClient  # 假设已封装为模块
+
+client = MemoryClient("your_api_key_here", "your_secret_here")
+
+# --- 1. 创建团队和 Agent ---
+client.post("/teams", {"team_id": "openclaw_team", "name": "OpenClaw 团队"})
+client.post("/agents", {"agent_id": "assistant", "name": "AI 助手", "role": "assistant"})
+
+# --- 2. 写入个人记忆(用户偏好)---
+client.post("/memories/personal", {
+    "agent_id": "assistant",
+    "content": "用户偏好简洁直接的回复,不喜欢冗长的解释",
+    "importance": 0.9
+})
+
+client.post("/memories/personal", {
+    "agent_id": "assistant",
+    "content": "用户的技术栈:Java 后端 + WSL2 + 腾讯云新加坡",
+    "importance": 0.7
+})
+
+# --- 3. 写入团队记忆(编码规范)---
+client.post("/memories/team", {
+    "content": "Java 接口规范:ResultBean 统一返回格式,@ControllerAdvice 全局异常处理",
+    "importance": 0.8
+})
+
+client.post("/memories/team", {
+    "content": "部署规范:代码变更后必须三同步——代码逻辑、内部文档、公网文档",
+    "importance": 0.95
+})
+
+# --- 4. 语义检索(个人记忆)---
+r = client.post("/memories/personal/search", {
+    "agent_id": "assistant",
+    "query": "用户的沟通风格偏好",
+    "limit": 3,
+    "min_score": 0.3
+})
+results = r.json()["data"]
+for m in results:
+    print(f"[{m['score']:.2f}] {m['content']}")
+# 输出:
+# [0.75] 用户偏好简洁直接的回复,不喜欢冗长的解释
+# [0.52] 用户的技术栈:Java 后端 + WSL2 + 腾讯云新加坡
+
+# --- 5. 语义检索(团队记忆)---
+r = client.post("/memories/team/search", {
+    "query": "接口返回格式规范",
+    "limit": 3,
+    "min_score": 0.3
+})
+for m in r.json()["data"]:
+    print(f"[{m['score']:.2f}] {m['content']}")
+# [0.82] Java 接口规范:ResultBean 统一返回格式...
+
+ +

5.2 Hermes 对接

+

场景:量化策略系统 Hermes 的多个 Agent 积累策略研究知识。

+
from memory_client import MemoryClient
+
+client = MemoryClient("your_api_key_here", "your_secret_here")
+
+# --- 1. 创建团队和 Agent ---
+client.post("/teams", {"team_id": "hermes", "name": "Hermes 策略研究部"})
+client.post("/agents", {"agent_id": "cloud_strategist", "name": "云策", "role": "决策调度"})
+client.post("/agents", {"agent_id": "backtest_engineer", "name": "弈回", "role": "回测工程师"})
+client.post("/agents", {"agent_id": "review_analyst", "name": "弈析", "role": "复盘分析"})
+
+# --- 2. 多 Agent 写入记忆 ---
+client.post("/memories/personal", {
+    "agent_id": "cloud_strategist",
+    "content": "趋势策略在震荡行情中表现不佳,需要增加行情识别层",
+    "importance": 0.9
+})
+
+client.post("/memories/personal", {
+    "agent_id": "backtest_engineer",
+    "content": "回测数据源:Binance USDT 永续合约,1h K线,2024-01 至今",
+    "importance": 0.7
+})
+
+client.post("/memories/personal", {
+    "agent_id": "review_analyst",
+    "content": "2025-06 策略复盘:最大回撤 12%,主要损失来自 6/3 闪崩",
+    "importance": 0.85
+})
+
+client.post("/memories/team", {
+    "content": "风控规则:单策略最大仓位 30%,总杠杆不超过 3x",
+    "importance": 0.95
+})
+
+# --- 3. 查看统计 ---
+r = client.get("/stats")
+stats = r.json()["data"]
+print(f"个人记忆: {stats['personal_memories']} 条")
+print(f"团队记忆: {stats['team_memories']} 条")
+print(f"Agent 数: {stats['agents']}")
+print(f"Redis: {'OK' if stats['redis_ok'] else 'DOWN'}")
+print(f"Embedding: {'OK' if stats['embedding_ok'] else 'DOWN'}")
+# 个人记忆: 3 条
+# 团队记忆: 1 条
+# Agent 数: 3
+# Redis: OK
+# Embedding: OK
+
+# --- 4. 获取最近记忆 ---
+r = client.get("/memories/personal/recent/cloud_strategist", params={"limit": 5})
+for m in r.json()["data"]:
+    print(f"[{m['importance']:.1f}] {m['content'][:50]}...")
+# [0.9] 趋势策略在震荡行情中表现不佳,需要增加行情识别层...
+
+ +

5.3 Claude Code 对接

+

场景:Claude Code 使用工作记忆记录当前编码上下文,任务完成后压缩为长期记忆。

+
from memory_client import MemoryClient
+
+client = MemoryClient("your_api_key_here", "your_secret_here")
+
+# --- 1. 创建团队和 Agent ---
+client.post("/teams", {"team_id": "claude_code", "name": "Claude Code 团队"})
+client.post("/agents", {"agent_id": "coder", "name": "编码助手", "role": "developer"})
+
+# --- 2. 写入工作记忆(当前任务上下文)---
+working_items = [
+    "正在重构 memory_system.py 中的 compress 方法",
+    "目标:将返回值从 {summary: string} 改为结构化 dict",
+    "需要同步更新:routes.py, docs/index.html, README_INTERNAL.html",
+    "测试方法:E2E curl 验证响应格式",
+]
+for item in working_items:
+    client.post("/memories/working", {
+        "agent_id": "coder",
+        "content": item
+    })
+
+# --- 3. 查看工作记忆 ---
+r = client.get("/memories/working/coder")
+print(f"工作记忆: {len(r.json()['data'])} 条")
+# 工作记忆: 4 条
+
+# --- 4. 任务完成后,压缩为长期记忆 ---
+r = client.post("/lifecycle/compress", {
+    "agent_id": "coder",
+    "target_count": 1
+})
+result = r.json()["data"]
+print(f"压缩: {result['compressed']} 条 → {result['remaining']} 条")
+print(f"摘要: {result['summary']}")
+# 压缩: 4 条 → 1 条
+# 摘要: 重构 memory_system.py 的 compress 方法,将返回值改为结构化 dict...
+
+# --- 5. 验证:语义检索 ---
+r = client.post("/memories/personal/search", {
+    "agent_id": "coder",
+    "query": "compress 方法重构",
+    "limit": 3,
+    "min_score": 0.3
+})
+for m in r.json()["data"]:
+    print(f"[{m['score']:.2f}] {m['content'][:60]}...")
+# [0.71] 重构 memory_system.py 的 compress 方法...
+
+ +

5.4 Codex 对接

+

场景:Codex Agent 记忆管理 + 验证跨团队隔离。

+
from memory_client import MemoryClient
+
+client = MemoryClient("your_api_key_here", "your_secret_here")
+
+# --- 1. 创建团队和 Agent ---
+client.post("/teams", {"team_id": "codex_team", "name": "Codex 团队"})
+client.post("/agents", {"agent_id": "codex_agent", "name": "Codex 助手", "role": "coder"})
+
+# --- 2. 写入记忆 ---
+client.post("/memories/personal", {
+    "agent_id": "codex_agent",
+    "content": "Codex 默认使用 gpt-4 模型,CLI 支持 --model 参数切换",
+    "importance": 0.7
+})
+
+client.post("/memories/personal", {
+    "agent_id": "codex_agent",
+    "content": "Codex 配置文件位于 ~/.codex/config.toml",
+    "importance": 0.6
+})
+
+# --- 3. 验证隔离:用当前 Key 查询其他团队 ---
+# 尝试访问不存在的团队(预期:403)
+r = client.get("/teams/other_team")
+print(f"跨团队查询: {r.status_code} - {r.json().get('code')}")
+# 跨团队查询: 403 - FORBIDDEN_CROSS_TEAM
+
+# 尝试查询其他团队的 Agent(预期:403)
+r = client.get("/agents/other_team/some_agent")
+print(f"跨团队Agent: {r.status_code} - {r.json().get('code')}")
+# 跨团队Agent: 403 - FORBIDDEN_CROSS_TEAM
+
+ +

5.5 跨团队隔离验证

+

验证不同团队之间的数据完全隔离:

+
from memory_client import MemoryClient
+
+# 两个不同团队的客户端
+team_a = MemoryClient("team_a_api_key", "team_a_secret")
+team_b = MemoryClient("team_b_api_key", "team_b_secret")
+
+# --- Team A 写入记忆 ---
+team_a.post("/teams", {"team_id": "team_a", "name": "Team Alpha"})
+team_a.post("/agents", {"agent_id": "agent_1", "name": "Agent 1"})
+team_a.post("/memories/personal", {
+    "agent_id": "agent_1",
+    "content": "这是 Team A 的私密记忆",
+    "importance": 0.9
+})
+
+# --- Team B 写入记忆 ---
+team_b.post("/teams", {"team_id": "team_b", "name": "Team Beta"})
+team_b.post("/agents", {"agent_id": "agent_1", "name": "Agent B1"})
+team_b.post("/memories/personal", {
+    "agent_id": "agent_1",
+    "content": "这是 Team B 的私密记忆",
+    "importance": 0.8
+})
+
+# --- 验证隔离 ---
+# Team A 搜索自己的记忆
+r = team_a.post("/memories/personal/search", {
+    "agent_id": "agent_1", "query": "私密记忆", "limit": 10
+})
+a_results = [m["content"] for m in r.json()["data"]]
+print(f"Team A sees: {a_results}")
+# Team A sees: ["这是 Team A 的私密记忆"]  ← 看不到 Team B 的
+
+# Team A 搜索团队记忆(也看不到 Team B 的)
+r = team_a.post("/memories/team/search", {
+    "query": "私密", "limit": 10
+})
+print(f"Team A team search: {len(r.json()['data'])} 条")
+# Team A team search: 0 条  ← Team B 的团队记忆不可见
+
+# Team A 的 Stats 只包含自己的数据
+r = team_a.get("/stats")
+stats = r.json()["data"]
+print(f"Team A stats: {stats['personal_memories']} personal, {stats['team_memories']} team")
+# Team A stats: 1 personal, 0 team  ← 只看到自己的
+
+print("\n✅ 跨团队隔离验证通过")
+
+ +

5.6 记忆生命周期完整示例

+

场景:完整的记忆生命周期 —— 从工作记忆写入,到原子事实提取、场景聚合、画像生成,最后配置自动化 Pipeline。

+
from memory_client import MemoryClient
+
+client = MemoryClient("your_api_key_here", "your_secret_here")
+
+# --- 1. 创建团队和 Agent ---
+client.post("/teams", {"team_id": "research_lab", "name": "研究实验室"})
+client.post("/agents", {"agent_id": "researcher", "name": "研究员", "role": "研究分析"})
+
+# --- 2. 写入工作记忆(模拟日常交互)---
+wm_items = [
+    "今天决定把量化策略的回测周期从 3 个月改为 6 个月,因为短期数据噪声太大",
+    "发现 DeepSeek V4 在代码生成任务上比 MiMo 快约 40%,但中文理解稍弱",
+    "用户偏好简洁直接的回复风格,不喜欢冗长的解释",
+    "Redis 缓存命中率从 75% 提升到 92% 后,API 响应时间从 120ms 降到 45ms",
+    "下周一需要和团队讨论 Q3 的风控模型升级方案",
+]
+for item in wm_items:
+    client.post("/memories/working", {"agent_id": "researcher", "content": item})
+
+print(f"工作记忆: {len(wm_items)} 条")
+
+# --- 3. 原子事实提取 ---
+# 从工作记忆中提取结构化事实,每条事实存为独立的长期记忆
+r = client.extract_facts("researcher", max_memories=20, delete_after=False)
+facts = r.json()["data"]["facts"]
+print(f"\n提取了 {len(facts)} 条原子事实:")
+for f in facts:
+    print(f"  [{f['importance']:.1f}] [{f['category']}] {f['content'][:60]}...")
+
+# --- 4. 场景聚合 ---
+# 将长期记忆按主题聚合为场景块
+r = client.post("/memories/personal", {
+    "agent_id": "researcher",
+    "content": "2025-06 策略复盘:最大回撤 12%,主要损失来自 6/3 闪崩",
+    "importance": 0.85
+})
+r = client.post("/memories/personal", {
+    "agent_id": "researcher",
+    "content": "回测数据源:Binance USDT 永续合约,1h K线,2024-01 至今",
+    "importance": 0.7
+})
+r = client.post("/memories/team", {
+    "content": "风控规则:单策略最大仓位 30%,总杠杆不超过 3x",
+    "importance": 0.95
+})
+
+r = client.aggregate_scenarios("researcher", max_memories=50)
+scenarios = r.json()["data"]["scenarios"]
+print(f"\n聚合了 {len(scenarios)} 个场景:")
+for s in scenarios:
+    print(f"  [{s['name']}] {s['summary'][:60]}... (关联 {len(s['memory_ids'])} 条记忆)")
+
+# --- 5. 查看已存储场景 ---
+r = client.get_scenarios("researcher")
+print(f"\n已存储场景: {len(r.json()['data'])} 个")
+
+# --- 6. 用户画像生成 ---
+# 从记忆和场景中提取用户画像
+r = client.generate_persona("researcher", max_items=30)
+persona = r.json()["data"]["persona"]
+print(f"\n用户画像:")
+print(f"  偏好: {persona.get('preferences', [])}")
+print(f"  习惯: {persona.get('habits', [])}")
+print(f"  擅长: {persona.get('expertise', [])}")
+print(f"  沟通风格: {persona.get('communication_style', '')}")
+print(f"  摘要: {persona.get('summary', '')[:80]}...")
+
+# --- 7. 查看用户画像 ---
+r = client.get_persona("researcher")
+print(f"\n画像已存储, ID: {r.json()['data'].get('id')}")
+
+# --- 8. 配置 Pipeline 自动化 ---
+# 设置自动压缩:每 10 条工作记忆自动压缩
+# 设置自动清理:60 天未访问且重要性 < 0.2 的记忆自动清理
+# 设置暖机:新 Agent 前 5 条记忆使用更频繁的压缩
+r = client.set_pipeline_config(
+    compress_every_n=10,
+    compress_target_count=5,
+    cleanup_idle_days=60,
+    cleanup_min_importance=0.2,
+    enabled=True,
+    warmup_max_memories=5,
+    warmup_compress_every_n=2
+)
+print(f"\nPipeline 配置: {r.json()['data']}")
+
+# --- 9. 查看 Pipeline 配置 ---
+r = client.get_pipeline_config()
+config = r.json()["data"]
+print(f"自动压缩: 每 {config.get('compress_every_n')} 条触发")
+print(f"自动清理: {config.get('cleanup_idle_days')} 天未访问, 重要性 < {config.get('cleanup_min_importance')}")
+print(f"暖机模式: 前 {config.get('warmup_max_memories')} 条记忆启用")
+
+# --- 10. 语义检索(验证所有数据可检索)---
+r = client.post("/memories/personal/search", {
+    "agent_id": "researcher",
+    "query": "策略回测",
+    "limit": 5,
+    "min_score": 0.3
+})
+print(f"\n搜索 '策略回测': {len(r.json()['data'])} 条结果")
+for m in r.json()["data"]:
+    src = m.get("metadata", {}).get("source", "normal")
+    print(f"  [{m['score']:.2f}] [{src}] {m['content'][:60]}...")
+
+print("\n✅ 记忆生命周期完整示例结束")
+
+ + +

6. 数据管理

+ +

6.1 数据持久化说明

+ + + + + + + + + + +
数据类型存储介质持久化说明
个人长期记忆MySQL (InnoDB)磁盘持久化含内容、向量(BLOB)、重要性、元数据
团队共享记忆MySQL (InnoDB)磁盘持久化同上,团队内所有 Agent 可检索
工作记忆Redis纯内存TTL 自动过期,服务重启可能丢失
API Key 元数据MySQL磁盘持久化Key、Secret Hash、状态等
Agent / Team 元数据MySQL磁盘持久化名称、角色、配置等
场景块 (Scenarios)MySQL磁盘持久化场景名、摘要、关联记忆 ID 列表(JSON)
用户画像 (Persona)MySQL磁盘持久化偏好、习惯、擅长、沟通风格(JSON)
Pipeline 配置MySQL磁盘持久化自动压缩/清理/暖机规则(per-team)
+ +
工作记忆存储在 Redis 中,TTL 到期后自动删除,且不保证服务重启后的持久性。重要内容请通过 /lifecycle/compress 及时转为长期记忆。
+ +

6.2 记忆清理与压缩策略

+ +

工作记忆压缩(/lifecycle/compress)

+

将 Redis 中的工作记忆压缩为 MySQL 长期记忆:

+
    +
  • 从 Redis 读取指定 Agent 的全部工作记忆
  • +
  • 调用 LLM 生成摘要
  • +
  • 摘要写入 MySQL 长期记忆
  • +
  • 原始工作记忆从 Redis 删除
  • +
+ +

长期记忆清理(/lifecycle/cleanup)

+

基于双维度清理过期的长期记忆:

+
    +
  • 时间维度max_age_days — 清理超过指定天数未访问的记忆
  • +
  • 重要性维度min_importance — 仅清理重要性评分低于阈值的记忆
  • +
+

两个条件同时满足才会被清理:即 低重要性 + 长期未访问 的记忆才会被移除。高重要性的记忆即使长期未访问也不会被清理。

+ +
+

推荐清理策略

+ + + + + +
场景max_age_daysmin_importance说明
保守清理1800.1仅清理半年未访问的极低重要性记忆
常规清理900.2清理 3 个月未访问的低重要性记忆(推荐)
积极清理300.3清理 1 个月未访问的中低重要性记忆
+
+ +

6.3 智能生命周期策略

+ +

原子事实提取(/lifecycle/extract-facts)

+

替代简单压缩,从工作记忆中提取独立的结构化事实:

+
    +
  • LLM 分析工作记忆内容,识别独立的、长期有价值的观察、决策和知识
  • +
  • 每条事实作为独立的长期记忆存储,便于精确检索
  • +
  • 适合需要精确回忆的场景(如技术决策、用户偏好、关键数据)
  • +
  • 相比压缩,事实提取保留了信息的独立性和可检索性
  • +
  • 系统对 LLM 返回结果和工作记忆条目均有类型安全校验,非标准格式数据会被自动跳过并记录警告日志
  • +
+ +

场景聚合(/lifecycle/aggregate-scenes)

+

将相关的长期记忆按主题聚合为场景块:

+
    +
  • LLM 分析多条记忆,识别共同主题并生成场景摘要
  • +
  • 场景块保留原始记忆 ID 列表,支持溯源
  • +
  • 适合将碎片化的记忆整合为完整的上下文(如项目进展、研究发现)
  • +
+ +

用户画像生成(/lifecycle/generate-persona)

+

从记忆和场景中提取结构化用户画像:

+
    +
  • 输出:偏好列表、习惯列表、擅长领域、沟通风格、综合摘要
  • +
  • 适合为 Agent 提供长期的用户/上下文理解
  • +
  • 建议定期更新(如每周或每月),画像会随记忆积累而丰富
  • +
+ +

Pipeline 自动化(/lifecycle/auto-config)

+

配置自动化的记忆管理规则,无需手动触发:

+ + + + + + +
规则配置项说明
自动压缩compress_every_n工作记忆达到 N 条时自动触发压缩(0=禁用)
自动清理cleanup_idle_days超过 N 天未访问且低重要性的记忆自动清理(0=禁用)
暖机模式warmup_max_memories新 Agent 个人记忆少于 N 条时,使用更低的压缩阈值(更频繁压缩)
暖机压缩warmup_compress_every_n暖机模式下的压缩触发条数(默认 1)
+

Pipeline 在每次写入工作记忆时自动检查是否达到压缩阈值,无需手动调用。

+ +

6.4 last_accessed 字段维护

+

系统自动维护每条长期记忆的 last_accessed 时间戳,确保清理策略基于"最近使用时间"而非"创建时间":

+ + + + + + + +
操作更新 last_accessed说明
语义检索命中POST /memories/personal/searchPOST /memories/team/search 返回的结果自动 touch
读取最近列表GET /memories/personal/recent/{id}GET /memories/team/recent 返回的结果自动 touch
更新记忆内容PUT /memories/personal/{id}PUT /memories/team/{id} 自动 touch
写入新记忆新记忆 last_accessed 初始为 NULL,cleanup 时回退到 created_at
+ +
这意味着:只要 Agent 定期通过 search 或 recent 接口读取记忆,这些记忆就不会被清理策略移除。频繁使用的记忆会一直保留。
+ + +

7. 常见问题

+ +
+

Q: 一个 Key 可以跨多个团队吗?

+

A: 不能。每个 Key 绑定一个 team_id,只能操作该团队的数据。跨团队访问返回 403 FORBIDDEN_CROSS_TEAM

+
+ +
+

Q: 不存在的 agent_id 会报错吗?

+

A: 查询类接口(GET 列表、搜索)返回空列表 [];写入类接口返回 AGENT_NOT_FOUND 错误。

+
+ +
+

Q: BGE 为什么输出 512 维?

+

A: BGE-small-zh-v1.5 的 PyTorch 模型输出 384 维。本系统使用 Xenova 社区预转换的 ONNX 量化版 (model_quantized.onnx),输出 512 维。这是 ONNX 转换差异,不影响语义检索质量。

+
+ +
+

Q: TTL 过期后工作记忆能恢复吗?

+

A: 不能。Redis 到期自动删除整个 List。请在过期前使用 /lifecycle/compress 将重要记忆转为长期记忆。

+
+ +
+

Q: 语义搜索的最低相似度阈值是多少?

+

A: 默认 min_score=0.3。值域为 [0.0, 1.0],越高越严格。建议从 0.3 开始,根据实际效果调整。

+
+ +
+

Q: 如何监控服务健康状态?

+

A: 定期调用 GET /health(无需认证)检查服务存活。调用 GET /stats(需认证)查看记忆条数、Agent 数量、Redis 和 Embedding 服务状态。

+
+ +
+

Q: 原子事实提取和压缩有什么区别?

+

A: 压缩将多条工作记忆合并为一条摘要,丢失了原始信息的独立性。事实提取将工作记忆分解为独立的结构化事实,每条事实作为独立的长期记忆存储,保留了精确的可检索性。建议:对于需要精确回忆的场景(如技术决策、用户偏好),使用事实提取;对于需要概括性总结的场景,使用压缩。

+
+ +
+

Q: Pipeline 自动化如何工作?

+

A: 通过 POST /lifecycle/auto-config 配置自动规则后,系统会在每次写入工作记忆时自动检查是否达到压缩阈值。暖机模式下,新 Agent 使用更低的阈值(更频繁压缩),帮助新 Agent 快速积累长期记忆。

+
+ +
+

Q: 使用场景聚合和画像生成需要什么前置条件?

+

A: 场景聚合和画像生成功能由系统内置 LLM 驱动,您只需调用对应的 API 端点即可,无需额外配置任何 LLM 参数。系统会自动完成事实提取、场景聚合和画像生成。

+
+ +
+

Q: 场景聚合和用户画像有什么区别?

+

A: 场景聚合将多条相关记忆按主题分组并生成摘要(如"策略回测系列讨论"),保留原始记忆 ID 列表支持溯源。用户画像从全局记忆中提取用户的结构化特征(偏好、习惯、擅长领域),是对用户整体的描述。场景是"事情"的聚合,画像是"人"的描述。

+
+ + + + +
+ + + + + + + + + + + diff --git a/embedding_service.py b/embedding_service.py new file mode 100644 index 0000000..e858146 --- /dev/null +++ b/embedding_service.py @@ -0,0 +1,67 @@ +"""BGE Embedding Service Client""" +import requests +import logging +from typing import List + +logger = logging.getLogger(__name__) + +# BGE-small-zh-v1.5: 512 tokens, roughly ~1500 Chinese chars +# Conservative limit: 2000 chars (most text under this is safe) +MAX_TEXT_CHARS = 2000 + + +class EmbeddingService: + """Client for BGE-small-zh-v1.5 embedding service.""" + + def __init__(self, base_url: str = "http://127.0.0.1:8080", timeout: int = 30): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + def embed(self, text: str) -> List[float]: + """Get embedding for a single text. + + Text is truncated to MAX_TEXT_CHARS if too long. + """ + original_len = len(text) + if len(text) > MAX_TEXT_CHARS: + logger.warning(f"Text too long ({original_len} chars), truncating to {MAX_TEXT_CHARS}") + text = text[:MAX_TEXT_CHARS] + + try: + resp = requests.post( + f"{self.base_url}/embed", + json={"text": text}, + timeout=self.timeout, + ) + resp.raise_for_status() + data = resp.json() + return data["embedding"] + except requests.exceptions.ConnectionError: + msg = "Embedding service unavailable (connection refused)" + logger.error(msg) + raise RuntimeError(msg) + except requests.exceptions.Timeout: + msg = f"Embedding request timeout ({self.timeout}s). Text may be too long ({original_len} chars, max {MAX_TEXT_CHARS})" + logger.error(msg) + raise RuntimeError(msg) + except requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response is not None else "unknown" + msg = f"Embedding service error (HTTP {status}): {e}" + logger.error(msg) + raise RuntimeError(msg) + except Exception as e: + msg = f"Embedding failed: {e}" + logger.error(msg) + raise RuntimeError(msg) + + def embed_batch(self, texts: List[str]) -> List[List[float]]: + """Get embeddings for multiple texts.""" + return [self.embed(t) for t in texts] + + def health_check(self) -> bool: + """Check if embedding service is alive.""" + try: + resp = requests.get(f"{self.base_url}/health", timeout=5) + return resp.status_code == 200 + except Exception: + return False diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..b86fa5b --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,15 @@ +"""Gunicorn configuration for Memory System""" +import multiprocessing +import os + +bind = os.getenv("API_BIND", "127.0.0.1:8081") +workers = min(multiprocessing.cpu_count(), 4) +worker_class = "sync" +timeout = 120 +keepalive = 5 +max_requests = 2000 +max_requests_jitter = 200 +accesslog = "-" +errorlog = "-" +loglevel = "info" +preload_app = True diff --git a/init_db.sql b/init_db.sql new file mode 100644 index 0000000..707d858 --- /dev/null +++ b/init_db.sql @@ -0,0 +1,140 @@ +-- Memory System Database Initialization +CREATE DATABASE IF NOT EXISTS memory_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE memory_system; + +-- Teams table +CREATE TABLE IF NOT EXISTS teams ( + id VARCHAR(64) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + config JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Agents table +CREATE TABLE IF NOT EXISTS agents ( + id VARCHAR(64) PRIMARY KEY, + team_id VARCHAR(64) NOT NULL, + name VARCHAR(255) NOT NULL, + role VARCHAR(255), + config JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_team (team_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Personal long-term memories +CREATE TABLE IF NOT EXISTS personal_memories ( + id VARCHAR(64) PRIMARY KEY, + agent_id VARCHAR(64) NOT NULL, + team_id VARCHAR(64) NOT NULL, + content TEXT NOT NULL, + summary TEXT, + embedding BLOB, + importance FLOAT DEFAULT 0.5, + memory_type VARCHAR(32) DEFAULT 'long_term', + metadata JSON, + access_count INT DEFAULT 0, + last_accessed TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_agent (agent_id), + INDEX idx_team (team_id), + INDEX idx_importance (importance), + INDEX idx_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Team shared memories +CREATE TABLE IF NOT EXISTS team_memories ( + id VARCHAR(64) PRIMARY KEY, + team_id VARCHAR(64) NOT NULL, + content TEXT NOT NULL, + summary TEXT, + embedding BLOB, + importance FLOAT DEFAULT 0.5, + category VARCHAR(64) DEFAULT 'general', + metadata JSON, + access_count INT DEFAULT 0, + last_accessed TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_team (team_id), + INDEX idx_category (category), + INDEX idx_importance (importance), + INDEX idx_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +FLUSH PRIVILEGES; + +-- ============================================================ +-- v3.0 新增表 - API Keys 认证 +-- ============================================================ +CREATE TABLE IF NOT EXISTS api_keys ( + id VARCHAR(64) PRIMARY KEY, + team_id VARCHAR(64) NOT NULL, + api_key VARCHAR(128) NOT NULL UNIQUE, + secret VARCHAR(128) NOT NULL, + name VARCHAR(255) DEFAULT '', + is_active TINYINT DEFAULT 1, + last_used TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_team (team_id), + INDEX idx_api_key (api_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- v3.0 新增表 - Pipeline 自动化配置 +-- ============================================================ +CREATE TABLE IF NOT EXISTS pipeline_config ( + id INT AUTO_INCREMENT PRIMARY KEY, + team_id VARCHAR(64) NOT NULL UNIQUE, + compress_every_n INT DEFAULT 0, + compress_target_count INT DEFAULT 5, + cleanup_idle_days INT DEFAULT 0, + cleanup_min_importance FLOAT DEFAULT 0.2, + enabled TINYINT DEFAULT 1, + warmup_max_memories INT DEFAULT 0, + warmup_compress_every_n INT DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_team (team_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- v3.0 新增表 - 场景块(LLM 聚合) +-- ============================================================ +CREATE TABLE IF NOT EXISTS memory_scenarios ( + id INT AUTO_INCREMENT PRIMARY KEY, + team_id VARCHAR(64) NOT NULL, + agent_id VARCHAR(64) NOT NULL, + name VARCHAR(255), + summary TEXT, + memory_ids JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, + INDEX idx_agent (team_id, agent_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- v3.0 新增表 - 用户画像(LLM 生成) +-- ============================================================ +CREATE TABLE IF NOT EXISTS user_personas ( + id INT AUTO_INCREMENT PRIMARY KEY, + team_id VARCHAR(64) NOT NULL, + agent_id VARCHAR(64) NOT NULL, + preferences JSON, + habits JSON, + expertise JSON, + communication_style TEXT, + summary TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, + INDEX idx_agent (team_id, agent_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/lifecycle/__init__.py b/lifecycle/__init__.py new file mode 100644 index 0000000..86d7175 --- /dev/null +++ b/lifecycle/__init__.py @@ -0,0 +1 @@ +# Lifecycle management diff --git a/lifecycle/aggregator.py b/lifecycle/aggregator.py new file mode 100644 index 0000000..6177405 --- /dev/null +++ b/lifecycle/aggregator.py @@ -0,0 +1,92 @@ +"""Scenario Aggregator - Group related memories into scenario blocks.""" +import json +import logging +from typing import Optional, List + +from lifecycle.llm_parse import parse_llm_json_array + +logger = logging.getLogger(__name__) + +SCENE_SYSTEM_PROMPT = """你是JSON生成器。直接输出JSON数组,禁止分析、思考、解释、讨论、markdown。 + +任务:将记忆按主题聚合成场景块。 + +规则: +1. 将相关记忆归入同一场景 +2. 每个场景:name(名称)、summary(100字以内摘要)、memory_ids(ID列表) +3. 最多10个场景 +4. 第一个字符必须是[,最后一个字符必须是] + +输出格式: +[{"name":"场景名","summary":"摘要","memory_ids":["id1","id2"]}] + +只输出JSON,不要有任何其他文字。""" + + +class ScenarioAggregator: + """Aggregate personal memories into scenario blocks.""" + + def __init__(self, mysql_store, llm_client): + self.mysql = mysql_store + self.llm = llm_client + + def aggregate(self, agent_id: str, team_id: str, + max_memories: int = 50) -> dict: + """Aggregate memories into scenarios using LLM.""" + if not self.llm.available: + return {"error": "LLM not configured. Set LLM_API_URL, LLM_API_KEY, LLM_MODEL environment variables."} + + memories = self.mysql.get_personal_memories_by_agent(agent_id, limit=max_memories) + if not memories: + return {"scenarios": [], "count": 0} + + memory_lines = [] + for m in memories: + mid = m["id"] + content = m.get("content", "")[:100] + memory_lines.append(f"[{mid}] {content}") + user_prompt = "请将以下记忆聚合为JSON场景数组:\n\n" + "\n".join(memory_lines) + + try: + scenarios = parse_llm_json_array( + self.llm, SCENE_SYSTEM_PROMPT, user_prompt, + temperature=0.3, context="aggregate-scenes", + max_tokens=2000, + ) + except ValueError as e: + return {"error": str(e)} + + stored = [] + for scene in scenarios: + if not isinstance(scene, dict): + logger.warning("Skipping non-dict item in scenarios: %r", scene) + continue + name = scene.get("name", "unnamed") + summary = scene.get("summary", "") + memory_ids = scene.get("memory_ids", []) + valid_ids = [mid for mid in memory_ids if isinstance(mid, str) and len(mid) > 0] + + scene_id = self.mysql.add_scenario( + team_id=team_id, + agent_id=agent_id, + name=name, + summary=summary, + memory_ids=valid_ids, + ) + stored.append({ + "id": scene_id, + "name": name, + "summary": summary, + "memory_ids": valid_ids, + }) + + return {"scenarios": stored, "count": len(stored)} + + def get_scenarios(self, agent_id: str) -> List[dict]: + return self.mysql.get_scenarios_by_agent(agent_id) + + def delete_scenario(self, scenario_id: str, team_id: str) -> bool: + scenario = self.mysql.get_scenario(scenario_id) + if not scenario or scenario["team_id"] != team_id: + return False + return self.mysql.delete_scenario(scenario_id) diff --git a/lifecycle/cleaner.py b/lifecycle/cleaner.py new file mode 100644 index 0000000..9ea9a47 --- /dev/null +++ b/lifecycle/cleaner.py @@ -0,0 +1,43 @@ +"""Memory Cleaner - Periodic cleanup of old/low-importance memories""" +import logging + +logger = logging.getLogger(__name__) + + +class MemoryCleaner: + """Clean up old, low-importance memories.""" + + def __init__(self, mysql_store): + self.mysql = mysql_store + + def cleanup(self, team_id: str = None, max_age_days: int = 90, + min_importance: float = 0.2) -> dict: + """ + Remove old memories with low importance scores. + + Args: + team_id: If set, only clean this team's memories + max_age_days: Remove memories older than this + min_importance: Remove memories with importance below this + + Returns: + Dict with counts of deleted memories + """ + personal_deleted = self.mysql.cleanup_personal_memories( + team_id=team_id, + max_age_days=max_age_days, + min_importance=min_importance, + ) + team_deleted = self.mysql.cleanup_team_memories( + team_id=team_id, + max_age_days=max_age_days, + min_importance=min_importance, + ) + + result = { + "personal_deleted": personal_deleted, + "team_deleted": team_deleted, + "total_deleted": personal_deleted + team_deleted, + } + logger.info(f"Cleanup result: {result}") + return result diff --git a/lifecycle/compressor.py b/lifecycle/compressor.py new file mode 100644 index 0000000..7f2c293 --- /dev/null +++ b/lifecycle/compressor.py @@ -0,0 +1,89 @@ +"""Working Memory Compressor""" +import logging +from typing import Optional, Callable + +logger = logging.getLogger(__name__) + + +class MemoryCompressor: + """Compress working memories when they exceed threshold.""" + + def __init__(self, mysql_store, redis_cache, embedding_service): + self.mysql = mysql_store + self.redis = redis_cache + self.embedder = embedding_service + + def compress(self, agent_id: str, max_items: int = 20, + summary_callback: Optional[Callable] = None) -> str: + """ + Compress working memories into a single long-term memory. + + If working memory exceeds max_items, take the oldest items, + summarize them (via callback or simple concatenation), and + store as a personal long-term memory. + + Returns: Summary text of compressed memories. + """ + # Get agent info to find team_id + agent = self.mysql.get_agent(agent_id) + if not agent: + raise ValueError(f"Agent {agent_id} not found") + + items = self.redis.get_working_memories(agent_id, limit=100) + if len(items) <= max_items: + return { + "compressed": 0, + "remaining": len(items), + "total_before": len(items), + "target_count": max_items, + "summary": None, + } + + # Take items beyond max_items (oldest) + to_compress = items[max_items:] + remaining = items[:max_items] + + # Build summary + contents = [item.get("content", "") for item in to_compress] + if summary_callback: + summary = summary_callback(contents) + else: + summary = " | ".join(contents[:50]) # Simple concat, limit 50 items + + # Store as personal long-term memory + embedding = self.embedder.embed(summary) + from storage.vector_search import embedding_to_bytes + emb_bytes = embedding_to_bytes(embedding) + + self.mysql.add_personal_memory( + agent_id=agent_id, + team_id=agent["team_id"], + content=summary, + embedding=emb_bytes, + importance=0.6, # Slightly elevated importance for compressed memories + metadata={ + "source": "compression", + "item_count": len(to_compress), + "source_items": [ + {"content": item.get("content", "")[:200], "timestamp": item.get("timestamp")} + for item in to_compress + ], + }, + ) + + # Replace working memory with remaining items + self.redis.clear_working_memory(agent_id) + for item in reversed(remaining): # lpush reverses order + self.redis.add_working_memory( + agent_id, item["content"], + metadata=item.get("metadata", {}), + ) + + logger.info(f"Compressed {len(to_compress)} working memories for agent {agent_id}") + return { + "compressed": len(to_compress), + "remaining": len(remaining), + "total_before": len(items), + "target_count": max_items, + "summary": summary, + } diff --git a/lifecycle/fact_extractor.py b/lifecycle/fact_extractor.py new file mode 100644 index 0000000..41c3cc4 --- /dev/null +++ b/lifecycle/fact_extractor.py @@ -0,0 +1,101 @@ +"""Fact Extractor - Extract structured atomic facts from working memory using LLM.""" +import json +import logging +from typing import Optional, List + +from lifecycle.llm_parse import parse_llm_json_array + +logger = logging.getLogger(__name__) + +FACT_SYSTEM_PROMPT = """你是JSON生成器。输入工作记忆,直接提取结构化事实。 + +严格规则: +1. 禁止输出分析、推理、思考、解释 +2. 第一个字符必须是 [,最后一个字符必须是 ] +3. 每条事实:content(50-200字)、importance(0.0-1.0)、category(分类) +4. 最多20条,无值得提取的事实返回 [] + +输出格式(只输出JSON,不输出其他任何文字): +[{"content":"事实","importance":0.8,"category":"分类"}]""" + + +class FactExtractor: + """Extract atomic facts from working memory using LLM.""" + + def __init__(self, mysql_store, redis_cache, llm_client, embedding_service): + self.mysql = mysql_store + self.redis = redis_cache + self.llm = llm_client + self.embedder = embedding_service + + def extract(self, agent_id: str, team_id: str, + max_memories: int = 20, + delete_after: bool = False) -> dict: + """Extract structured facts from working memory.""" + if not self.llm.available: + return {"error": "LLM not configured. Set LLM_API_URL, LLM_API_KEY, LLM_MODEL environment variables."} + + items = self.redis.get_working_memories(agent_id, limit=max_memories) + if not items: + return {"facts": [], "count": 0} + + memory_lines = [] + for i, item in enumerate(items): + if not isinstance(item, dict): + logger.warning('Skipping non-dict working memory item: %r', item) + continue + content = item.get("content", "")[:300] + memory_lines.append(f"[{i+1}] {content}") + user_prompt = "以下是需要提取事实的工作记忆:\n\n" + "\n".join(memory_lines) + + try: + facts = parse_llm_json_array( + self.llm, FACT_SYSTEM_PROMPT, user_prompt, + temperature=0.2, context="extract-facts", + ) + except ValueError as e: + return {"error": str(e)} + + stored = [] + for fact in facts: + if not isinstance(fact, dict): + logger.warning('Skipping non-dict fact from LLM: %r', fact) + continue + content = fact.get("content", "").strip() + if not content or len(content) < 10: + continue + + importance = fact.get("importance", 0.6) + if not isinstance(importance, (int, float)) or importance < 0 or importance > 1: + importance = 0.6 + + category = fact.get("category", "") + + embedding = self.embedder.embed(content) + from storage.vector_search import embedding_to_bytes + emb_bytes = embedding_to_bytes(embedding) + + memory_id = self.mysql.add_personal_memory( + agent_id=agent_id, + team_id=team_id, + content=content, + embedding=emb_bytes, + importance=float(importance), + metadata={ + "source": "fact_extraction", + "category": category, + "extracted_from": "working_memory", + }, + ) + stored.append({ + "id": memory_id, + "content": content, + "importance": importance, + "category": category, + }) + + if delete_after and stored: + self.redis.clear_working_memory(agent_id) + logger.info(f"Deleted working memories for {agent_id} after extracting {len(stored)} facts") + + return {"facts": stored, "count": len(stored)} diff --git a/lifecycle/llm_parse.py b/lifecycle/llm_parse.py new file mode 100644 index 0000000..437a765 --- /dev/null +++ b/lifecycle/llm_parse.py @@ -0,0 +1,344 @@ +"""Shared LLM response parsing utilities for lifecycle modules.""" +import json +import logging +import re +import time +from typing import Optional + +logger = logging.getLogger(__name__) + +MAX_PARSE_RETRIES = 2 # 3 total attempts (1 initial + 2 retry) + +RETRY_SYSTEM_PROMPT = ( + '只输出原始JSON。不要有任何其他文字、分析、思考、解释、markdown。\n' + '输出格式示例:\n' + '[{"name": "场景名", "summary": "摘要", "memory_ids": ["id1", "id2"]}]\n' + '重要:你回复的第一个字符必须是 [,最后一个字符必须是 ]。' +) + +RETRY_OBJECT_PROMPT = ( + '只输出原始JSON对象。不要有任何其他文字、分析、思考、解释、markdown。\n' + '输出格式:\n' + '{"data": [{"name": "场景名", "summary": "摘要", "memory_ids": ["id1", "id2"]}]}\n' + '重要:你回复的第一个字符必须是 {,最后一个字符必须是 }。' +) + + +def _strip_markdown_code_block(text: str) -> str: + text = text.strip() + if not text.startswith("```"): + return text + text = text.split("\n", 1)[1] if "\n" in text else text[3:] + if text.endswith("```"): + text = text[:-3].strip() + if text.lower().startswith("json"): + text = text[4:].strip() + elif text.lower().startswith("json\n"): + text = text[5:].strip() + return text.strip() + + +def _strip_reasoning_prefix(text: str) -> str: + """Strip reasoning/analysis text that some models output before JSON. + + Models like deepseek-v4-flash often output chain-of-thought analysis + before the actual JSON. This function tries to find where the JSON + actually starts by looking for lines that start with [ or { after + stripping analysis text. + """ + text = text.strip() + + # If text starts with [ or { it might already be JSON + if text and text[0] in ('[', '{'): + return text + + # Try to find the last occurrence of a JSON-like pattern + lines = text.split('\n') + for i in range(len(lines) - 1, -1, -1): + stripped = lines[i].strip() + if stripped.startswith('[') or stripped.startswith('{'): + candidate = '\n'.join(lines[i:]) + try: + json.loads(candidate) + return candidate + except (json.JSONDecodeError, ValueError): + continue + + # Try finding first [ or { that starts a valid JSON + for start_char in ['[', '{']: + idx = text.find(start_char) + while idx >= 0: + candidate = text[idx:] + end_char = ']' if start_char == '[' else '}' + if candidate.rstrip().endswith(end_char): + try: + json.loads(candidate) + return candidate + except (json.JSONDecodeError, ValueError): + pass + idx = text.find(start_char, idx + 1) + + return text + + +def _find_json_string(text: str) -> Optional[str]: + """Find the first valid JSON array or object in text. + + Iterates through ALL bracket-delimited segments, not just from the + first bracket. This handles cases where the LLM outputs analysis + text containing bracketed IDs before the actual JSON. + """ + text = text.strip() + if not text: + return None + + # Fast path: entire text is valid JSON + if (text.startswith("[") or text.startswith("{")) and text[-1] in ("}", "]"): + try: + json.loads(text) + return text + except (json.JSONDecodeError, ValueError): + pass + + # Strip markdown code blocks + cleaned = _strip_markdown_code_block(text) + if cleaned != text: + return _find_json_string(cleaned) + + # Strip reasoning prefix + cleaned = _strip_reasoning_prefix(text) + if cleaned != text: + result = _find_json_string(cleaned) + if result: + return result + + # Scan all bracket-delimited segments (for "[" / "]" and "{" / "}") + for start_char, end_char in [("[", "]"), ("{", "}")]: + search_start = 0 + while True: + start_idx = text.find(start_char, search_start) + if start_idx == -1: + break + depth = 0 + for i in range(start_idx, len(text)): + if text[i] == start_char: + depth += 1 + elif text[i] == end_char: + depth -= 1 + if depth == 0: + candidate = text[start_idx:i + 1] + try: + json.loads(candidate) + return candidate + except (json.JSONDecodeError, ValueError): + # Move past this bracket pair and continue scanning + search_start = i + 1 + break + else: + # No matching end bracket found + search_start = start_idx + 1 + + # Regex fallback: find ALL bracket-delimited segments + for pattern in [r"\[.*\]", r"\{.*\}"]: + for match in re.finditer(pattern, text, re.DOTALL): + candidate = match.group(0) + try: + json.loads(candidate) + return candidate + except (json.JSONDecodeError, ValueError): + continue + + return None + + +def _try_fix_json(text: str) -> Optional[str]: + """Try to fix common JSON issues from LLM output.""" + # Remove trailing commas before } or ] + text = re.sub(r",\s*}", "}", text) + text = re.sub(r",\s*\]", "]", text) + # Replace single quotes with double quotes only if no double quotes present + if '"' not in text: + text = text.replace("'", '"') + # Remove BOM and zero-width characters + text = text.replace("\ufeff", "").replace("\u200b", "") + # Try parsing as-is first + try: + json.loads(text) + return text + except json.JSONDecodeError: + pass + # Try fixing unescaped newlines inside JSON string values + fixed = re.sub(r'(?<=: ")((?:[^"]|\\")*?)(\n)((?:[^"]|\\")*?(?="))', r'\1\\n\3', text) + try: + json.loads(fixed) + return fixed + except json.JSONDecodeError: + pass + # Try wrapping in array if it looks like a bare object + stripped = text.strip() + if stripped.startswith("{") and not stripped.startswith("["): + wrapped = "[" + stripped + "]" + try: + json.loads(wrapped) + return wrapped + except json.JSONDecodeError: + pass + # Try to extract JSON from truncated output: find last complete object in array + if stripped.startswith("["): + depth = 0 + objects = [] + obj_start = -1 + for i, ch in enumerate(stripped): + if ch == "{": + if depth == 0: + obj_start = i + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0 and obj_start >= 0: + objects.append(stripped[obj_start:i + 1]) + obj_start = -1 + if objects: + candidate = "[" + ",".join(objects) + "]" + try: + json.loads(candidate) + return candidate + except json.JSONDecodeError: + pass + return None + + +def parse_llm_json_array(llm_client, system_prompt, user_prompt, + temperature=0.3, context="", max_tokens=4000): + last_raw = "" + total = 1 + MAX_PARSE_RETRIES + t_start = time.time() + for attempt in range(total): + is_retry = attempt > 0 + + if is_retry: + if attempt >= total - 1: + # Final attempt: json_mode as last resort with wrapper object + prompt = RETRY_OBJECT_PROMPT + use_json_mode = True + prefill_val = None + else: + # Intermediate retry: use prefill "[" + prompt = RETRY_SYSTEM_PROMPT + use_json_mode = False + prefill_val = "[" + else: + # First attempt: prefill "[" to force JSON output immediately + prompt = system_prompt + use_json_mode = False + prefill_val = "[" + + response = llm_client.chat( + prompt, user_prompt, + temperature=temperature, json_mode=use_json_mode, + max_tokens=max_tokens, + thinking={"type": "disabled"}, + prefill=prefill_val, + ) + if not response: + logger.warning("[%s] Attempt %d: LLM returned empty", context, attempt + 1) + continue + last_raw = response + json_str = _find_json_string(response) + if json_str: + try: + result = json.loads(json_str) + except json.JSONDecodeError as e: + logger.warning("[%s] Attempt %d: JSON parse error: %s", context, attempt + 1, e) + continue + if isinstance(result, list): + elapsed = time.time() - t_start + logger.info("[%s] Parsed JSON array in %.1fs (%d attempt(s), %d items)", context, elapsed, attempt + 1, len(result)) + return result + if isinstance(result, dict): + if "data" in result and isinstance(result["data"], list): + elapsed = time.time() - t_start + logger.info("[%s] Parsed JSON array in %.1fs (%d attempt(s), %d items)", context, elapsed, attempt + 1, len(result["data"])) + return result["data"] + logger.warning("[%s] Attempt %d: LLM returned dict, wrapping in list", context, attempt + 1) + return [result] + fixed = _try_fix_json(response) + if fixed: + try: + result = json.loads(fixed) + except json.JSONDecodeError: + continue + if isinstance(result, list): + return result + if isinstance(result, dict): + if "data" in result and isinstance(result["data"], list): + return result["data"] + return [result] + logger.warning( + "[%s] Attempt %d/%d: No valid JSON array found (len=%d): %r", + context, attempt + 1, total, len(response), response[:500] + ) + logger.error("[%s] All %d attempts failed. Last raw: %r", context, total, last_raw[:500]) + raise ValueError("LLM failed to return valid JSON after retries") + + +def parse_llm_json_object(llm_client, system_prompt, user_prompt, + temperature=0.3, context="", max_tokens=4000): + last_raw = "" + total = 1 + MAX_PARSE_RETRIES + t_start = time.time() + for attempt in range(total): + is_retry = attempt > 0 + + if is_retry: + if attempt >= total - 1: + # Final attempt: json_mode as last resort + prompt = '只输出 JSON 对象。不要输出任何其他文字。\n输出格式:{"preferences":[],"habits":[],"expertise":[],"communication_style":"","summary":""}' + use_json_mode = True + prefill_val = None + else: + prompt = '只输出 JSON 对象。不要输出任何其他文字。\n禁止输出:分析、思考、解释、讨论、描述、markdown。' + use_json_mode = False + prefill_val = "{" + else: + # First attempt: prefill "{" to force JSON output + prompt = system_prompt + use_json_mode = False + prefill_val = "{" + + response = llm_client.chat( + prompt, user_prompt, + temperature=temperature, json_mode=use_json_mode, + max_tokens=max_tokens, + thinking={"type": "disabled"}, + prefill=prefill_val, + ) + if not response: + logger.warning("[%s] Attempt %d: LLM returned empty", context, attempt + 1) + continue + last_raw = response + json_str = _find_json_string(response) + if json_str: + try: + result = json.loads(json_str) + except json.JSONDecodeError as e: + logger.warning("[%s] Attempt %d: JSON parse error: %s", context, attempt + 1, e) + continue + if isinstance(result, dict): + elapsed = time.time() - t_start + logger.info("[%s] Parsed JSON object in %.1fs (%d attempt(s))", context, elapsed, attempt + 1) + return result + fixed = _try_fix_json(response) + if fixed: + try: + result = json.loads(fixed) + except json.JSONDecodeError: + continue + if isinstance(result, dict): + return result + logger.warning( + "[%s] Attempt %d/%d: No valid JSON object found (len=%d): %r", + context, attempt + 1, total, len(response), response[:500] + ) + logger.error("[%s] All %d attempts failed. Last raw: %r", context, total, last_raw[:500]) + raise ValueError("LLM failed to return valid JSON after retries") diff --git a/lifecycle/persona_generator.py b/lifecycle/persona_generator.py new file mode 100644 index 0000000..a95e783 --- /dev/null +++ b/lifecycle/persona_generator.py @@ -0,0 +1,80 @@ +"""Persona Generator - Generate user persona from scenarios and memories.""" +import json +import logging +from typing import Optional + +from lifecycle.llm_parse import parse_llm_json_object + +logger = logging.getLogger(__name__) + +PERSONA_SYSTEM_PROMPT = """你是JSON生成器。直接输出JSON对象,禁止分析、思考、解释、markdown。 + +根据记忆和场景生成用户画像。 + +输出格式(第一个字符必须是{,最后一个字符必须是}): +{"preferences":["偏好1"],"habits":["习惯1"],"expertise":["擅长1"],"communication_style":"风格","summary":"100字以内摘要"} + +只输出JSON,不要有任何其他文字。""" + + +class PersonaGenerator: + """Generate user persona from memories and scenarios.""" + + def __init__(self, mysql_store, llm_client): + self.mysql = mysql_store + self.llm = llm_client + + def generate(self, agent_id: str, team_id: str, + max_items: int = 30) -> dict: + if not self.llm.available: + return {"error": "LLM not configured. Set LLM_API_URL, LLM_API_KEY, LLM_MODEL environment variables."} + + memories = self.mysql.get_personal_memories_by_agent(agent_id, limit=max_items) + scenarios = self.mysql.get_scenarios_by_agent(agent_id) + + if not memories and not scenarios: + return {"error": "No memories or scenarios found for this agent"} + + context_parts = [] + if memories: + mem_lines = [f"- {m.get('content', '')[:100]}" for m in memories] + context_parts.append("历史记忆:\n" + "\n".join(mem_lines)) + if scenarios: + scene_lines = [f"- [{s.get('name', '')}] {s.get('summary', '')}" for s in scenarios] + context_parts.append("已识别场景:\n" + "\n".join(scene_lines)) + + user_prompt = "请根据以下内容生成用户画像JSON:\n\n" + "\n\n".join(context_parts) + + try: + persona = parse_llm_json_object( + self.llm, PERSONA_SYSTEM_PROMPT, user_prompt, + temperature=0.3, context="generate-persona", + max_tokens=2000, + ) + except ValueError as e: + return {"error": str(e)} + + if not isinstance(persona, dict): + logger.warning("LLM returned non-dict persona: %r", persona) + return {"error": "LLM returned invalid persona format"} + + pid = self.mysql.add_persona( + team_id=team_id, + agent_id=agent_id, + preferences=persona.get("preferences", []), + habits=persona.get("habits", []), + expertise=persona.get("expertise", []), + communication_style=persona.get("communication_style", ""), + summary=persona.get("summary", ""), + ) + persona["id"] = pid + return {"persona": persona} + + def get_persona(self, agent_id: str): + return self.mysql.get_persona_by_agent(agent_id) + + def delete_persona(self, persona_id: str, team_id: str) -> bool: + persona = self.mysql.get_persona(persona_id) + if not persona or persona["team_id"] != team_id: + return False + return self.mysql.delete_persona(persona_id) diff --git a/lifecycle/pipeline.py b/lifecycle/pipeline.py new file mode 100644 index 0000000..abef954 --- /dev/null +++ b/lifecycle/pipeline.py @@ -0,0 +1,136 @@ +"""Pipeline Automation - Auto-trigger compression and cleanup based on configurable rules.""" +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +class PipelineConfig: + """Pipeline configuration for a team.""" + + def __init__(self, team_id: str, compress_every_n: int = 0, + compress_target_count: int = 5, + cleanup_idle_days: int = 0, + cleanup_min_importance: float = 0.2, + enabled: bool = True, **kwargs): + self.team_id = team_id + self.compress_every_n = compress_every_n # 0 = disabled + self.compress_target_count = compress_target_count + self.cleanup_idle_days = cleanup_idle_days # 0 = disabled + self.cleanup_min_importance = cleanup_min_importance + self.enabled = enabled + self.warmup_max_memories = kwargs.get("warmup_max_memories", 0) # 0 = disabled + self.warmup_compress_every_n = kwargs.get("warmup_compress_every_n", 1) + + def to_dict(self): + return { + "team_id": self.team_id, + "compress_every_n": self.compress_every_n, + "compress_target_count": self.compress_target_count, + "cleanup_idle_days": self.cleanup_idle_days, + "cleanup_min_importance": self.cleanup_min_importance, + "enabled": self.enabled, + "warmup_max_memories": self.warmup_max_memories, + "warmup_compress_every_n": self.warmup_compress_every_n, + } + + +class Pipeline: + """Auto-pipeline: trigger compression and cleanup based on rules.""" + + def __init__(self, mysql_store, redis_cache, compressor, cleaner): + self.mysql = mysql_store + self.redis = redis_cache + self.compressor = compressor + self.cleaner = cleaner + + def get_config(self, team_id: str) -> Optional[PipelineConfig]: + row = self.mysql.get_pipeline_config(team_id) + if not row: + return None + return PipelineConfig( + team_id=row["team_id"], + compress_every_n=row.get("compress_every_n", 0), + compress_target_count=row.get("compress_target_count", 5), + cleanup_idle_days=row.get("cleanup_idle_days", 0), + cleanup_min_importance=row.get("cleanup_min_importance", 0.2), + enabled=bool(row.get("enabled", 1)), + warmup_max_memories=row.get("warmup_max_memories", 0), + warmup_compress_every_n=row.get("warmup_compress_every_n", 1), + ) + + def set_config(self, team_id: str, **kwargs) -> PipelineConfig: + existing = self.mysql.get_pipeline_config(team_id) + if existing: + self.mysql.update_pipeline_config(team_id, **kwargs) + else: + self.mysql.create_pipeline_config(team_id, **kwargs) + return self.get_config(team_id) + + def check_after_wm_write(self, agent_id: str) -> Optional[dict]: + agent = self.mysql.get_agent(agent_id) + if not agent: + return None + team_id = agent["team_id"] + config = self.get_config(team_id) + if not config or not config.enabled: + return None + if config.compress_every_n <= 0: + return None + wm_count = self.redis.get_working_memory_count(agent_id) + # Warm-up: use lower threshold for new agents + effective_threshold = config.compress_every_n + if config.warmup_max_memories > 0: + personal_count = len(self.mysql.get_personal_memories_by_agent(agent_id, limit=config.warmup_max_memories + 1)) + if personal_count < config.warmup_max_memories: + effective_threshold = config.warmup_compress_every_n + if wm_count < effective_threshold: + return None + logger.info(f"Auto-compress triggered for {agent_id}: WM {wm_count} >= {config.compress_every_n}") + try: + result = self.compressor.compress(agent_id, max_items=config.compress_target_count) + result["auto_triggered"] = True + return result + except Exception as e: + logger.error(f"Auto-compress failed for {agent_id}: {e}") + return None + + def run_cleanup(self, team_id: str = None) -> Optional[dict]: + configs = [] + if team_id: + config = self.get_config(team_id) + if config and config.enabled and config.cleanup_idle_days > 0: + configs.append(config) + else: + all_configs = self.mysql.get_all_pipeline_configs() + for row in all_configs: + config = PipelineConfig( + team_id=row["team_id"], + compress_every_n=row.get("compress_every_n", 0), + compress_target_count=row.get("compress_target_count", 5), + cleanup_idle_days=row.get("cleanup_idle_days", 0), + cleanup_min_importance=row.get("cleanup_min_importance", 0.2), + enabled=bool(row.get("enabled", 1)), + warmup_max_memories=row.get("warmup_max_memories", 0), + warmup_compress_every_n=row.get("warmup_compress_every_n", 1), + ) + if config.enabled and config.cleanup_idle_days > 0: + configs.append(config) + if not configs: + return None + results = [] + for config in configs: + try: + result = self.cleaner.cleanup( + team_id=config.team_id, + max_age_days=config.cleanup_idle_days, + min_importance=config.cleanup_min_importance, + ) + result["team_id"] = config.team_id + result["auto_triggered"] = True + results.append(result) + logger.info(f"Auto-cleanup for {config.team_id}: {result}") + except Exception as e: + logger.error(f"Auto-cleanup failed for {config.team_id}: {e}") + results.append({"team_id": config.team_id, "error": str(e)}) + return {"results": results} diff --git a/llm_client.py b/llm_client.py new file mode 100644 index 0000000..fe69dcd --- /dev/null +++ b/llm_client.py @@ -0,0 +1,156 @@ +"""LLM Client with multi-provider failover and JSON mode support.""" +import json +import logging +import time +import requests +from typing import Optional, List, Dict, Any + +logger = logging.getLogger(__name__) + +COOLDOWN_SECONDS = 300 # 5 min before retrying primary after failure + + +class LLMClient: + """LLM client supporting provider list with automatic failover.""" + + def __init__(self, providers_config: List[Dict[str, Any]] = None): + self.providers = [] + if providers_config: + self.providers = sorted(providers_config, key=lambda p: p.get("priority", 999)) + + # Normalize API URLs + for p in self.providers: + url = p.get("api_url", "").rstrip("/") + if url and not url.endswith("/chat/completions"): + url = url + "/chat/completions" + p["api_url"] = url + + self._fallback_mode = False + self._last_primary_failure = 0.0 + + @property + def available(self) -> bool: + return len(self.providers) > 0 + + def chat(self, system_prompt: str, user_prompt: str, + temperature: float = 0.3, max_tokens: int = 800, + json_mode: bool = False, thinking: Optional[dict] = None, + prefill: str = None) -> Optional[str]: + if not self.available: + logger.warning("No LLM providers configured") + return None + + for i, provider in enumerate(self.providers): + is_primary = (i == 0) + + if is_primary and self._fallback_mode: + if time.time() - self._last_primary_failure < COOLDOWN_SECONDS: + continue + logger.info("Primary cooldown expired, retrying...") + + try: + result = self._call(provider, system_prompt, user_prompt, + temperature, max_tokens, json_mode, thinking, prefill) + if is_primary and self._fallback_mode: + logger.info("Primary provider recovered, switched back") + self._fallback_mode = False + return result + except requests.exceptions.RequestException as e: + logger.warning("Provider %s (%s) failed: %s", + provider.get('model', '?'), + provider.get('api_url', '?'), e) + if is_primary: + self._fallback_mode = True + self._last_primary_failure = time.time() + logger.info("Switched to fallback provider (cooldown: %ds)", COOLDOWN_SECONDS) + continue + except ValueError as e: + # json_mode returned non-JSON content (reasoning text from proxy) + logger.warning("Provider %s returned non-JSON for json_mode, trying next: %s", + provider.get('model', '?'), str(e)[:100]) + if is_primary: + self._fallback_mode = True + self._last_primary_failure = time.time() + continue + + logger.error("All LLM providers failed") + return None + + def _call(self, provider: dict, system_prompt: str, user_prompt: str, + temperature: float, max_tokens: int, json_mode: bool, + thinking: Optional[dict] = None, prefill: str = None) -> str: + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {provider['api_key']}", + } + payload = { + "model": provider["model"], + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": temperature, + "max_tokens": max_tokens, + } + if json_mode: + payload["response_format"] = {"type": "json_object"} + if thinking is not None: + payload["thinking"] = thinking + if prefill: + payload["messages"].append({"role": "assistant", "content": prefill}) + + resp = requests.post( + provider["api_url"], headers=headers, json=payload, timeout=60 + ) + resp.raise_for_status() + data = resp.json() + msg = data["choices"][0]["message"] + + reasoning = msg.get("reasoning_content", "") or "" + content = msg.get("content") or "" + + # If content is empty but reasoning has content, use reasoning as fallback + if not content and reasoning: + content = reasoning + + if reasoning and content: + logger.debug("LLM: reasoning=%d chars, content=%d chars", len(reasoning), len(content)) + + # Detect when json_mode was requested but response is not JSON. + # This catches two cases: + # 1. Proxy merged reasoning into content field (reasoning is empty, content starts with analysis text) + # 2. Model spent all tokens on reasoning (content was empty, reasoning was used as fallback) + if json_mode and content: + stripped = content.lstrip() + if stripped and stripped[0] not in ('[', '{', '"'): + raise ValueError( + "json_mode response is not JSON (starts with: %.50r)" % stripped + ) + + if json_mode and not content: + logger.warning("JSON mode returned empty content for %s", provider.get("model", "?")) + raise ValueError("JSON mode returned empty content") + + return content + + def get_status(self) -> dict: + status = [] + for p in self.providers: + status.append({ + "model": p["model"], + "priority": p.get("priority"), + "api_url": p["api_url"], + }) + return { + "fallback_mode": self._fallback_mode, + "last_primary_failure_ago": + int(time.time() - self._last_primary_failure) if self._fallback_mode else 0, + "providers": status, + } + + @property + def current_provider(self) -> str: + if not self.providers: + return "none" + idx = 1 if (self._fallback_mode and len(self.providers) > 1) else 0 + return self.providers[idx].get("model", "unknown") diff --git a/memory_system.py b/memory_system.py new file mode 100644 index 0000000..a5aca50 --- /dev/null +++ b/memory_system.py @@ -0,0 +1,423 @@ +"""Core MemorySystem - Orchestrates all storage, search, and lifecycle operations""" +import logging +from typing import Optional, List, Dict, Any + +from config import Config +from embedding_service import EmbeddingService +from storage.mysql_store import MySQLStore +from storage.redis_cache import RedisCache +from storage.vector_search import embedding_to_bytes, search_similar, find_duplicates +from lifecycle.compressor import MemoryCompressor +from llm_client import LLMClient +from lifecycle.pipeline import Pipeline +from lifecycle.aggregator import ScenarioAggregator +from lifecycle.persona_generator import PersonaGenerator +from lifecycle.fact_extractor import FactExtractor +from lifecycle.cleaner import MemoryCleaner + +logger = logging.getLogger(__name__) + + +class MemorySystem: + """Multi-agent memory system with team isolation.""" + + def __init__(self, config: Config = None): + cfg = config or Config() + self.mysql = MySQLStore( + host=cfg.MYSQL_HOST, + port=cfg.MYSQL_PORT, + user=cfg.MYSQL_USER, + password=cfg.MYSQL_PASSWORD, + database=cfg.MYSQL_DATABASE, + unix_socket=cfg.MYSQL_UNIX_SOCKET, + ) + self.redis = RedisCache( + host=cfg.REDIS_HOST, + port=cfg.REDIS_PORT, + password=cfg.REDIS_PASSWORD, + db=cfg.REDIS_DB, + ) + self.embedder = EmbeddingService(base_url=cfg.EMBEDDING_SERVICE_URL) + self.compressor = MemoryCompressor(self.mysql, self.redis, self.embedder) + self.cleaner = MemoryCleaner(self.mysql) + self.default_ttl = cfg.WORKING_MEMORY_TTL + self.llm = LLMClient(providers_config=cfg.llm_providers) + self.aggregator = ScenarioAggregator(self.mysql, self.llm) + self.persona_gen = PersonaGenerator(self.mysql, self.llm) + self.fact_extractor = FactExtractor(self.mysql, self.redis, self.llm, self.embedder) + self.pipeline = Pipeline(self.mysql, self.redis, self.compressor, self.cleaner) + + # ── Team management ────────────────────────────────────────── + + def create_team(self, team_id: str, name: str, description: str = "", + config: dict = None) -> dict: + return self.mysql.create_team(team_id, name, description, config) + + def delete_team(self, team_id: str) -> bool: + return self.mysql.delete_team(team_id) + + def get_team(self, team_id: str) -> Optional[dict]: + return self.mysql.get_team(team_id) + + # ── Agent management ───────────────────────────────────────── + + def create_agent(self, agent_id: str, team_id: str, name: str, + role: str = "") -> dict: + # Verify team exists + if not self.mysql.get_team(team_id): + raise ValueError(f"Team {team_id} not found") + return self.mysql.create_agent(agent_id, team_id, name, role) + + def delete_agent(self, agent_id: str) -> bool: + return self.mysql.delete_agent(agent_id) + + def get_agent(self, agent_id: str) -> Optional[dict]: + return self.mysql.get_agent(agent_id) + + def get_personal_memory(self, memory_id: str) -> Optional[dict]: + return self.mysql.get_personal_memory(memory_id) + + def get_team_memory(self, memory_id: str) -> Optional[dict]: + return self.mysql.get_team_memory(memory_id) + + def get_agents_by_team(self, team_id: str) -> list: + return self.mysql.get_agents_by_team(team_id) + + # ── Personal memories ──────────────────────────────────────── + + def add_personal_memory(self, agent_id: str, content: str, + importance: float = 0.5, + metadata: dict = None, + enable_dedup: bool = True, + dedup_threshold: float = 0.85) -> dict: + agent = self.mysql.get_agent(agent_id) + if not agent: + raise ValueError(f"Agent {agent_id} not found") + + # Generate embedding + embedding = self.embedder.embed(content) + emb_bytes = embedding_to_bytes(embedding) + + # Dedup check: compare against existing memories + if enable_dedup: + stored = self.mysql.get_personal_memories_with_embeddings(agent_id) + if stored: + dupes = find_duplicates(embedding, stored, threshold=dedup_threshold) + if dupes: + best_id, best_score, _ = dupes[0] + logger.info(f"Dedup: memory {best_id} already covers this (score={best_score:.4f}), skipping") + existing = self.mysql.get_personal_memory(best_id) + if existing: + existing["dedup_skipped"] = True + existing["dedup_score"] = round(best_score, 4) + return existing + + return self.mysql.add_personal_memory( + agent_id=agent_id, + team_id=agent["team_id"], + content=content, + embedding=emb_bytes, + importance=importance, + metadata=metadata, + ) + + def search_personal_memories(self, agent_id: str, query: str, + limit: int = 10, + min_score: float = 0.0, + max_chars_per_memory: int = 0, + max_total_chars: int = 0) -> list: + # Generate query embedding + query_embedding = self.embedder.embed(query) + + # Get all memories with embeddings for this agent + stored = self.mysql.get_personal_memories_with_embeddings(agent_id) + if not stored: + return [] + + # Vector search + results = search_similar(query_embedding, stored, top_k=limit, min_score=min_score) + + # Format output with char limits + output = [] + total_chars = 0 + for memory_id, score, item in results: + self.mysql.touch_personal_memory(memory_id) + content = item["content"] + + # Truncate single memory if needed + if max_chars_per_memory > 0 and len(content) > max_chars_per_memory: + content = content[:max_chars_per_memory] + "..." + + # Check total budget + if max_total_chars > 0: + if total_chars + len(content) > max_total_chars: + remaining = max_total_chars - total_chars + if remaining > 50: # only include if meaningful + content = content[:remaining] + "..." + else: + break + + total_chars += len(content) + output.append({ + "id": memory_id, + "content": content, + "score": round(score, 4), + "importance": item.get("importance", 0.5), + "metadata": item.get("metadata"), + }) + return output + + def get_recent_personal_memories(self, agent_id: str, + limit: int = 20) -> list: + rows = self.mysql.get_personal_memories_by_agent(agent_id, limit) + output = [ + { + "id": r["id"], + "content": r["content"], + "importance": r.get("importance", 0.5), + "metadata": r.get("metadata"), + "created_at": str(r.get("created_at", "")), + } + for r in rows + ] + for item in output: + self.mysql.touch_personal_memory(item["id"]) + return output + + def update_personal_memory(self, memory_id: str, content: str = None, + importance: float = None, + metadata: dict = None) -> bool: + embedding = None + if content is not None: + emb = self.embedder.embed(content) + embedding = embedding_to_bytes(emb) + result = self.mysql.update_personal_memory( + memory_id, content=content, importance=importance, + metadata=metadata, embedding=embedding, + ) + if result: + self.mysql.touch_personal_memory(memory_id) + return result + + def delete_personal_memory(self, memory_id: str) -> bool: + return self.mysql.delete_personal_memory(memory_id) + + # ── Working memory ─────────────────────────────────────────── + + def add_working_memory(self, agent_id: str, content: str, + ttl: int = None) -> dict: + ttl = ttl or self.default_ttl + return self.redis.add_working_memory(agent_id, content, ttl=ttl) + + def get_working_memories(self, agent_id: str, + limit: int = 20) -> list: + return self.redis.get_working_memories(agent_id, limit) + + def clear_working_memory(self, agent_id: str) -> bool: + return self.redis.clear_working_memory(agent_id) + + # ── Team memories ──────────────────────────────────────────── + + def add_team_memory(self, team_id: str, content: str, + importance: float = 0.5, category: str = "general", + metadata: dict = None, + enable_dedup: bool = True, + dedup_threshold: float = 0.85) -> dict: + if not self.mysql.get_team(team_id): + raise ValueError(f"Team {team_id} not found") + + embedding = self.embedder.embed(content) + emb_bytes = embedding_to_bytes(embedding) + + # Dedup check + if enable_dedup: + stored = self.mysql.get_team_memories_with_embeddings(team_id) + if stored: + dupes = find_duplicates(embedding, stored, threshold=dedup_threshold) + if dupes: + best_id, best_score, _ = dupes[0] + logger.info(f"Dedup: team memory {best_id} already covers this (score={best_score:.4f}), skipping") + existing = self.mysql.get_team_memory(best_id) + if existing: + existing["dedup_skipped"] = True + existing["dedup_score"] = round(best_score, 4) + return existing + + return self.mysql.add_team_memory( + team_id=team_id, + content=content, + embedding=emb_bytes, + importance=importance, + category=category, + metadata=metadata, + ) + + def search_team_memories(self, team_id: str, query: str, + limit: int = 10, + min_score: float = 0.0, + max_chars_per_memory: int = 0, + max_total_chars: int = 0) -> list: + query_embedding = self.embedder.embed(query) + stored = self.mysql.get_team_memories_with_embeddings(team_id) + if not stored: + return [] + + results = search_similar(query_embedding, stored, top_k=limit, min_score=min_score) + + output = [] + total_chars = 0 + for memory_id, score, item in results: + self.mysql.touch_team_memory(memory_id) + content = item["content"] + + if max_chars_per_memory > 0 and len(content) > max_chars_per_memory: + content = content[:max_chars_per_memory] + "..." + + if max_total_chars > 0: + if total_chars + len(content) > max_total_chars: + remaining = max_total_chars - total_chars + if remaining > 50: + content = content[:remaining] + "..." + else: + break + + total_chars += len(content) + output.append({ + "id": memory_id, + "content": content, + "score": round(score, 4), + "importance": item.get("importance", 0.5), + "metadata": item.get("metadata"), + }) + return output + + def get_recent_team_memories(self, team_id: str, + limit: int = 20) -> list: + rows = self.mysql.get_team_memories_by_team(team_id, limit) + output = [ + { + "id": r["id"], + "content": r["content"], + "importance": r.get("importance", 0.5), + "category": r.get("category", "general"), + "created_at": str(r.get("created_at", "")), + } + for r in rows + ] + for item in output: + self.mysql.touch_team_memory(item["id"]) + return output + + def update_team_memory(self, memory_id: str, content: str = None, + importance: float = None) -> bool: + embedding = None + if content is not None: + emb = self.embedder.embed(content) + embedding = embedding_to_bytes(emb) + result = self.mysql.update_team_memory( + memory_id, content=content, importance=importance, + embedding=embedding, + ) + if result: + self.mysql.touch_team_memory(memory_id) + return result + + def delete_team_memory(self, memory_id: str) -> bool: + return self.mysql.delete_team_memory(memory_id) + + # ── Lifecycle ──────────────────────────────────────────────── + + def compress_working_memories(self, agent_id: str, max_items: int = 20, + summary_callback=None) -> str: + return self.compressor.compress(agent_id, max_items, summary_callback) + + + # -- Pipeline automation -- + + + # -- Scenario aggregation -- + + + # -- Persona generation -- + + + # -- Fact extraction -- + + def extract_facts(self, agent_id: str, team_id: str, + max_memories: int = 20, delete_after: bool = False): + return self.fact_extractor.extract(agent_id, team_id, max_memories, delete_after) + def generate_persona(self, agent_id: str, team_id: str, max_items: int = 30): + return self.persona_gen.generate(agent_id, team_id, max_items) + + def get_persona(self, agent_id: str): + return self.persona_gen.get_persona(agent_id) + + def delete_persona(self, persona_id: str, team_id: str): + return self.persona_gen.delete_persona(persona_id, team_id) + def aggregate_scenarios(self, agent_id: str, team_id: str, max_memories: int = 30): + return self.aggregator.aggregate(agent_id, team_id, max_memories) + + def get_scenarios(self, agent_id: str): + return self.aggregator.get_scenarios(agent_id) + + def delete_scenario(self, scenario_id: str, team_id: str): + return self.aggregator.delete_scenario(scenario_id, team_id) + def get_pipeline_config(self, team_id: str): + return self.pipeline.get_config(team_id) + + def set_pipeline_config(self, team_id: str, **kwargs): + return self.pipeline.set_config(team_id, **kwargs) + + def check_auto_compress(self, agent_id: str): + return self.pipeline.check_after_wm_write(agent_id) + + def run_auto_cleanup(self, team_id: str = None): + return self.pipeline.run_cleanup(team_id) + def cleanup_memories(self, team_id: str = None, + max_age_days: int = 90, + min_importance: float = 0.2) -> dict: + return self.cleaner.cleanup(team_id, max_age_days, min_importance) + + def rebuild_vector_index(self, team_id: str = None) -> dict: + """Re-embed all memories (useful after model update).""" + # Get all personal memories + conn = self.mysql._get_conn() + with conn.cursor() as cur: + if team_id: + cur.execute("SELECT id, content FROM personal_memories WHERE team_id = %s", (team_id,)) + else: + cur.execute("SELECT id, content FROM personal_memories") + personals = cur.fetchall() + + if team_id: + cur.execute("SELECT id, content FROM team_memories WHERE team_id = %s", (team_id,)) + else: + cur.execute("SELECT id, content FROM team_memories") + teams = cur.fetchall() + + rebuilt = 0 + for row in personals: + try: + emb = self.embedder.embed(row["content"]) + self.mysql.update_personal_memory(row["id"], embedding=embedding_to_bytes(emb)) + rebuilt += 1 + except Exception as e: + logger.warning(f"Failed to rebuild personal memory {row['id']}: {e}") + + for row in teams: + try: + emb = self.embedder.embed(row["content"]) + self.mysql.update_team_memory(row["id"], embedding=embedding_to_bytes(emb)) + rebuilt += 1 + except Exception as e: + logger.warning(f"Failed to rebuild team memory {row['id']}: {e}") + + return {"rebuilt": rebuilt, "total": len(personals) + len(teams)} + + # ── Stats ──────────────────────────────────────────────────── + + def get_stats(self, team_id: str = None) -> dict: + stats = self.mysql.get_stats(team_id) + stats["redis_ok"] = self.redis.health_check() + stats["embedding_ok"] = self.embedder.health_check() + return stats diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a5ddbc4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +flask==3.0.0 +gunicorn==21.2.0 +pymysql==1.1.0 +redis==5.0.1 +numpy==1.24.4 +requests==2.31.0 +cryptography==41.0.7 diff --git a/storage/__init__.py b/storage/__init__.py new file mode 100644 index 0000000..83f6bfb --- /dev/null +++ b/storage/__init__.py @@ -0,0 +1 @@ +# Storage layer diff --git a/storage/mysql_store.py b/storage/mysql_store.py new file mode 100644 index 0000000..c8f5d0b --- /dev/null +++ b/storage/mysql_store.py @@ -0,0 +1,367 @@ +"""MySQL Storage Layer""" +import uuid +import json +import logging +from datetime import datetime +from typing import Optional, List, Dict, Any + +import pymysql +from pymysql.cursors import DictCursor + +logger = logging.getLogger(__name__) + + +class MySQLStore: + """MySQL-backed persistent storage for memories.""" + + def __init__(self, host, port, user, password, database, charset="utf8mb4", unix_socket=None): + self.conn_kwargs = dict( + host=host, + port=port, + user=user, + password=password, + database=database, + charset=charset, + cursorclass=DictCursor, + autocommit=True, + ) + if unix_socket: + self.conn_kwargs["unix_socket"] = unix_socket + self._conn = None + + def _get_conn(self): + if self._conn is None or not self._conn.open: + self._conn = pymysql.connect(**self.conn_kwargs) + try: + self._conn.ping(reconnect=True) + except Exception: + self._conn = pymysql.connect(**self.conn_kwargs) + return self._conn + + def _query(self, sql, args=None, fetch=False): + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute(sql, args) + if fetch: + return cur.fetchall() + return None + + # ── Team management ────────────────────────────────────────── + + def create_team(self, team_id: str, name: str, description: str = "", config: dict = None): + self._query( + "INSERT INTO teams (id, name, description, config) VALUES (%s, %s, %s, %s)", + (team_id, name, description, json.dumps(config) if config else None), + ) + return self.get_team(team_id) + + def get_team(self, team_id: str) -> Optional[dict]: + rows = self._query("SELECT * FROM teams WHERE id = %s", (team_id,), fetch=True) + return rows[0] if rows else None + + def delete_team(self, team_id: str) -> bool: + self._query("DELETE FROM teams WHERE id = %s", (team_id,)) + return True + + # ── Agent management ───────────────────────────────────────── + + def create_agent(self, agent_id: str, team_id: str, name: str, role: str = ""): + self._query( + "INSERT INTO agents (id, team_id, name, role) VALUES (%s, %s, %s, %s)", + (agent_id, team_id, name, role), + ) + return self.get_agent(agent_id) + + def get_agent(self, agent_id: str) -> Optional[dict]: + rows = self._query("SELECT * FROM agents WHERE id = %s", (agent_id,), fetch=True) + return rows[0] if rows else None + + def delete_agent(self, agent_id: str) -> bool: + self._query("DELETE FROM agents WHERE id = %s", (agent_id,)) + return True + + def get_agents_by_team(self, team_id: str) -> list: + return self._query( + "SELECT * FROM agents WHERE team_id = %s ORDER BY created_at DESC", + (team_id,), fetch=True, + ) + + # ── Personal memories ──────────────────────────────────────── + + def add_personal_memory(self, agent_id: str, team_id: str, content: str, + embedding: bytes, importance: float = 0.5, + metadata: dict = None) -> dict: + mid = str(uuid.uuid4())[:16] + self._query( + """INSERT INTO personal_memories + (id, agent_id, team_id, content, embedding, importance, metadata) + VALUES (%s, %s, %s, %s, %s, %s, %s)""", + (mid, agent_id, team_id, content, embedding, importance, + json.dumps(metadata) if metadata else None), + ) + return self.get_personal_memory(mid) + + def get_personal_memory(self, memory_id: str) -> Optional[dict]: + rows = self._query("SELECT * FROM personal_memories WHERE id = %s", (memory_id,), fetch=True) + return rows[0] if rows else None + + def get_personal_memories_by_agent(self, agent_id: str, limit: int = 20) -> List[dict]: + return self._query( + "SELECT * FROM personal_memories WHERE agent_id = %s ORDER BY created_at DESC LIMIT %s", + (agent_id, limit), fetch=True, + ) + + def get_personal_memories_with_embeddings(self, agent_id: str) -> List[dict]: + """Get all personal memories with embeddings for vector search.""" + return self._query( + "SELECT id, content, embedding, importance, metadata FROM personal_memories WHERE agent_id = %s", + (agent_id,), fetch=True, + ) + + def update_personal_memory(self, memory_id: str, content: str = None, + importance: float = None, metadata: dict = None, + embedding: bytes = None) -> bool: + sets, args = [], [] + if content is not None: + sets.append("content = %s") + args.append(content) + if importance is not None: + sets.append("importance = %s") + args.append(importance) + if metadata is not None: + sets.append("metadata = %s") + args.append(json.dumps(metadata)) + if embedding is not None: + sets.append("embedding = %s") + args.append(embedding) + if not sets: + return False + args.append(memory_id) + self._query(f"UPDATE personal_memories SET {', '.join(sets)} WHERE id = %s", args) + return True + + def touch_personal_memory(self, memory_id: str): + self._query( + "UPDATE personal_memories SET access_count = access_count + 1, last_accessed = NOW() WHERE id = %s", + (memory_id,), + ) + + def delete_personal_memory(self, memory_id: str) -> bool: + self._query("DELETE FROM personal_memories WHERE id = %s", (memory_id,)) + return True + + # ── Team memories ──────────────────────────────────────────── + + def add_team_memory(self, team_id: str, content: str, embedding: bytes, + importance: float = 0.5, category: str = "general", + metadata: dict = None) -> dict: + mid = str(uuid.uuid4())[:16] + self._query( + """INSERT INTO team_memories + (id, team_id, content, embedding, importance, category, metadata) + VALUES (%s, %s, %s, %s, %s, %s, %s)""", + (mid, team_id, content, embedding, importance, category, + json.dumps(metadata) if metadata else None), + ) + return self.get_team_memory(mid) + + def get_team_memory(self, memory_id: str) -> Optional[dict]: + rows = self._query("SELECT * FROM team_memories WHERE id = %s", (memory_id,), fetch=True) + return rows[0] if rows else None + + def get_team_memories_by_team(self, team_id: str, limit: int = 20) -> List[dict]: + return self._query( + "SELECT * FROM team_memories WHERE team_id = %s ORDER BY created_at DESC LIMIT %s", + (team_id, limit), fetch=True, + ) + + def get_team_memories_with_embeddings(self, team_id: str) -> List[dict]: + """Get all team memories with embeddings for vector search.""" + return self._query( + "SELECT id, content, embedding, importance, metadata FROM team_memories WHERE team_id = %s", + (team_id,), fetch=True, + ) + + def update_team_memory(self, memory_id: str, content: str = None, + importance: float = None, embedding: bytes = None) -> bool: + sets, args = [], [] + if content is not None: + sets.append("content = %s") + args.append(content) + if importance is not None: + sets.append("importance = %s") + args.append(importance) + if embedding is not None: + sets.append("embedding = %s") + args.append(embedding) + if not sets: + return False + args.append(memory_id) + self._query(f"UPDATE team_memories SET {', '.join(sets)} WHERE id = %s", args) + return True + + def touch_team_memory(self, memory_id: str): + self._query( + "UPDATE team_memories SET access_count = access_count + 1, last_accessed = NOW() WHERE id = %s", + (memory_id,), + ) + + def delete_team_memory(self, memory_id: str) -> bool: + self._query("DELETE FROM team_memories WHERE id = %s", (memory_id,)) + return True + + # ── Cleanup ────────────────────────────────────────────────── + + def cleanup_personal_memories(self, team_id: str = None, max_age_days: int = 90, + min_importance: float = 0.2) -> int: + sql = ("DELETE FROM personal_memories WHERE importance < %s " + "AND COALESCE(last_accessed, created_at) < DATE_SUB(NOW(), INTERVAL %s DAY)") + args = [min_importance, max_age_days] + if team_id: + sql += " AND team_id = %s" + args.append(team_id) + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute(sql, args) + return cur.rowcount + + def cleanup_team_memories(self, team_id: str = None, max_age_days: int = 90, + min_importance: float = 0.2) -> int: + sql = ("DELETE FROM team_memories WHERE importance < %s " + "AND COALESCE(last_accessed, created_at) < DATE_SUB(NOW(), INTERVAL %s DAY)") + args = [min_importance, max_age_days] + if team_id: + sql += " AND team_id = %s" + args.append(team_id) + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute(sql, args) + return cur.rowcount + + # ── Stats ──────────────────────────────────────────────────── + + def get_stats(self, team_id: str = None) -> dict: + stats = {} + if team_id: + row = self._query( + "SELECT COUNT(*) as cnt FROM personal_memories WHERE team_id = %s", + (team_id,), fetch=True, + ) + stats["personal_memories"] = row[0]["cnt"] if row else 0 + row = self._query( + "SELECT COUNT(*) as cnt FROM team_memories WHERE team_id = %s", + (team_id,), fetch=True, + ) + stats["team_memories"] = row[0]["cnt"] if row else 0 + row = self._query( + "SELECT COUNT(*) as cnt FROM agents WHERE team_id = %s", + (team_id,), fetch=True, + ) + stats["agents"] = row[0]["cnt"] if row else 0 + else: + row = self._query("SELECT COUNT(*) as cnt FROM personal_memories", fetch=True) + stats["personal_memories"] = row[0]["cnt"] if row else 0 + row = self._query("SELECT COUNT(*) as cnt FROM team_memories", fetch=True) + stats["team_memories"] = row[0]["cnt"] if row else 0 + row = self._query("SELECT COUNT(*) as cnt FROM agents", fetch=True) + stats["agents"] = row[0]["cnt"] if row else 0 + row = self._query("SELECT COUNT(*) as cnt FROM teams", fetch=True) + stats["teams"] = row[0]["cnt"] if row else 0 + return stats + + # -- Pipeline config -- + + def get_pipeline_config(self, team_id: str): + rows = self._query( + "SELECT * FROM pipeline_config WHERE team_id = %s", + (team_id,), fetch=True, + ) + return rows[0] if rows else None + + def create_pipeline_config(self, team_id: str, compress_every_n: int = 0, + compress_target_count: int = 5, + cleanup_idle_days: int = 0, + cleanup_min_importance: float = 0.2, + enabled: bool = True, + warmup_max_memories: int = 0, + warmup_compress_every_n: int = 1): + self._query( + "INSERT INTO pipeline_config (team_id, compress_every_n, compress_target_count, cleanup_idle_days, cleanup_min_importance, enabled, warmup_max_memories, warmup_compress_every_n) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + (team_id, compress_every_n, compress_target_count, cleanup_idle_days, cleanup_min_importance, enabled, warmup_max_memories, warmup_compress_every_n), + ) + + def update_pipeline_config(self, team_id: str, **kwargs): + sets, args = [], [] + for field in ["compress_every_n", "compress_target_count", "cleanup_idle_days", "cleanup_min_importance", "enabled", "warmup_max_memories", "warmup_compress_every_n"]: + if field in kwargs and kwargs[field] is not None: + sets.append(f"{field} = %s") + args.append(kwargs[field]) + if not sets: + return + args.append(team_id) + self._query(f"UPDATE pipeline_config SET {', '.join(sets)} WHERE team_id = %s", tuple(args)) + + def get_all_pipeline_configs(self): + return self._query("SELECT * FROM pipeline_config WHERE enabled = 1", fetch=True) or [] + + # -- Scenario management -- + + def add_scenario(self, team_id: str, agent_id: str, name: str, + summary: str, memory_ids: list) -> str: + import uuid + sid = str(uuid.uuid4())[:16] + self._query( + "INSERT INTO memory_scenarios (id, team_id, agent_id, name, summary, memory_ids) VALUES (%s, %s, %s, %s, %s, %s)", + (sid, team_id, agent_id, name, summary, json.dumps(memory_ids)), + ) + return sid + + def get_scenario(self, scenario_id: str): + rows = self._query("SELECT * FROM memory_scenarios WHERE id = %s", (scenario_id,), fetch=True) + return rows[0] if rows else None + + def get_scenarios_by_agent(self, agent_id: str): + rows = self._query("SELECT * FROM memory_scenarios WHERE agent_id = %s ORDER BY created_at DESC", (agent_id,), fetch=True) + for row in rows: + if isinstance(row.get("memory_ids"), str): + row["memory_ids"] = json.loads(row["memory_ids"]) + return rows or [] + + def delete_scenario(self, scenario_id: str) -> bool: + self._query("DELETE FROM memory_scenarios WHERE id = %s", (scenario_id,)) + return True + + # -- Persona management -- + + def add_persona(self, team_id, agent_id, preferences, habits, expertise, communication_style, summary): + import uuid + pid = str(uuid.uuid4())[:16] + self._query( + 'INSERT INTO user_personas (id, team_id, agent_id, preferences, habits, expertise, communication_style, summary) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)', + (pid, team_id, agent_id, json.dumps(preferences), json.dumps(habits), json.dumps(expertise), communication_style, summary), + ) + return pid + + def get_persona(self, persona_id): + rows = self._query('SELECT * FROM user_personas WHERE id = %s', (persona_id,), fetch=True) + if rows: + row = rows[0] + for field in ['preferences', 'habits', 'expertise']: + if isinstance(row.get(field), str): + row[field] = json.loads(row[field]) + return row + return None + + def get_persona_by_agent(self, agent_id): + rows = self._query('SELECT * FROM user_personas WHERE agent_id = %s ORDER BY created_at DESC LIMIT 1', (agent_id,), fetch=True) + if rows: + row = rows[0] + for field in ['preferences', 'habits', 'expertise']: + if isinstance(row.get(field), str): + row[field] = json.loads(row[field]) + return row + return None + + def delete_persona(self, persona_id): + self._query('DELETE FROM user_personas WHERE id = %s', (persona_id,)) + return True diff --git a/storage/redis_cache.py b/storage/redis_cache.py new file mode 100644 index 0000000..3c3c1d3 --- /dev/null +++ b/storage/redis_cache.py @@ -0,0 +1,68 @@ +"""Redis Cache Layer for Working Memory""" +import json +import time +import logging +from typing import List, Optional, Dict + +import redis + +logger = logging.getLogger(__name__) + + +class RedisCache: + """Redis-backed working memory cache with TTL.""" + + def __init__(self, host: str = "127.0.0.1", port: int = 6379, + password: str = "", db: int = 0): + self.client = redis.Redis( + host=host, port=port, password=password, db=db, + decode_responses=True, socket_timeout=5, + ) + + def _key(self, agent_id: str) -> str: + return f"wm:{agent_id}" + + def add_working_memory(self, agent_id: str, content: str, + metadata: dict = None, ttl: int = 259200) -> dict: + """Add an item to agent's working memory (Redis List).""" + item = { + "content": content, + "metadata": metadata or {}, + "timestamp": time.time(), + } + key = self._key(agent_id) + self.client.lpush(key, json.dumps(item)) + self.client.expire(key, ttl) + return item + + def get_working_memories(self, agent_id: str, limit: int = 20) -> List[dict]: + """Get recent working memory items for an agent.""" + key = self._key(agent_id) + items = self.client.lrange(key, 0, limit - 1) + result = [] + for item_str in items: + try: + parsed = json.loads(item_str) + if isinstance(parsed, dict): + result.append(parsed) + except json.JSONDecodeError: + continue + return result + + def clear_working_memory(self, agent_id: str) -> bool: + """Clear all working memory for an agent.""" + key = self._key(agent_id) + self.client.delete(key) + return True + + def get_working_memory_count(self, agent_id: str) -> int: + """Get count of working memory items.""" + key = self._key(agent_id) + return self.client.llen(key) + + def health_check(self) -> bool: + """Check Redis connectivity.""" + try: + return self.client.ping() + except Exception: + return False diff --git a/storage/vector_search.py b/storage/vector_search.py new file mode 100644 index 0000000..44c2a41 --- /dev/null +++ b/storage/vector_search.py @@ -0,0 +1,113 @@ +"""Vector Search Layer - Pure NumPy Implementation""" +import struct +import logging +from typing import List, Tuple, Optional + +import numpy as np + +logger = logging.getLogger(__name__) + + +def embedding_to_bytes(embedding: List[float]) -> bytes: + """Convert float list to compact bytes for MySQL BLOB storage.""" + return struct.pack(f"{len(embedding)}f", *embedding) + + +def bytes_to_embedding(data: bytes) -> np.ndarray: + """Convert bytes back to numpy array.""" + n = len(data) // 4 + return np.array(struct.unpack(f"{n}f", data), dtype=np.float32) + + +def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float: + """Compute cosine similarity between two vectors.""" + norm_a = np.linalg.norm(a) + norm_b = np.linalg.norm(b) + if norm_a == 0 or norm_b == 0: + return 0.0 + return float(np.dot(a, b) / (norm_a * norm_b)) + + +def search_similar( + query_embedding: List[float], + stored_items: List[dict], + top_k: int = 10, + min_score: float = 0.0, +) -> List[Tuple[str, float, dict]]: + """ + Search for similar items using cosine similarity. + + Args: + query_embedding: Query vector + stored_items: List of dicts with 'id', 'embedding' (bytes), 'content' + top_k: Max results + min_score: Minimum similarity score + + Returns: + List of (id, score, item_dict) tuples sorted by score descending + """ + if not stored_items: + return [] + + query_vec = np.array(query_embedding, dtype=np.float32) + query_norm = np.linalg.norm(query_vec) + if query_norm == 0: + return [] + + scores = [] + for item in stored_items: + emb_bytes = item.get("embedding") + if emb_bytes is None: + continue + try: + stored_vec = bytes_to_embedding(emb_bytes) + score = float(np.dot(query_vec, stored_vec) / (query_norm * np.linalg.norm(stored_vec))) + if score >= min_score: + scores.append((item["id"], score, item)) + except Exception as e: + logger.warning(f"Failed to compute similarity for item {item.get('id')}: {e}") + continue + + scores.sort(key=lambda x: x[1], reverse=True) + return scores[:top_k] + +def find_duplicates( + new_embedding: List[float], + stored_items: List[dict], + threshold: float = 0.85, +) -> List[Tuple[str, float, dict]]: + """ + Find items whose similarity to new_embedding exceeds threshold. + Used for deduplication before storing new memories. + + Args: + new_embedding: The embedding of the new memory to check + stored_items: Existing memories with 'id', 'embedding' (bytes), 'content' + threshold: Similarity threshold above which items are considered duplicates + + Returns: + List of (id, score, item_dict) for items exceeding threshold, sorted by score desc + """ + if not stored_items: + return [] + + query_vec = np.array(new_embedding, dtype=np.float32) + query_norm = np.linalg.norm(query_vec) + if query_norm == 0: + return [] + + duplicates = [] + for item in stored_items: + emb_bytes = item.get("embedding") + if emb_bytes is None: + continue + try: + stored_vec = bytes_to_embedding(emb_bytes) + score = float(np.dot(query_vec, stored_vec) / (query_norm * np.linalg.norm(stored_vec))) + if score >= threshold: + duplicates.append((item["id"], score, item)) + except Exception as e: + continue + + duplicates.sort(key=lambda x: x[1], reverse=True) + return duplicates diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..6013326 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +""" +Memory System Validation Tests + +Tests: +1. Create two teams (team_a, team_b) +2. Create agents in each team +3. Write personal memories with team-specific content +4. Semantic search with team isolation verification +5. Performance benchmarks (write latency, search latency, memory usage) +""" +import sys +import os +import time +import json +import statistics + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from config import Config +from memory_system import MemorySystem + +# ANSI colors +GREEN = "\033[92m" +RED = "\033[91m" +YELLOW = "\033[93m" +CYAN = "\033[96m" +RESET = "\033[0m" +BOLD = "\033[1m" + +def header(text): + print(f"\n{BOLD}{CYAN}{'='*60}{RESET}") + print(f"{BOLD}{CYAN}{text}{RESET}") + print(f"{BOLD}{CYAN}{'='*60}{RESET}") + +def ok(msg): + print(f" {GREEN}✓{RESET} {msg}") + +def fail(msg): + print(f" {RED}✗{RESET} {msg}") + +def warn(msg): + print(f" {YELLOW}⚠{RESET} {msg}") + +def measure(label, func, *args, **kwargs): + """Run func and return (result, elapsed_ms).""" + start = time.perf_counter() + result = func(*args, **kwargs) + elapsed = (time.perf_counter() - start) * 1000 + return result, elapsed + + +def main(): + cfg = Config() + ms = MemorySystem(cfg) + + # ── Health checks ──────────────────────────────────────────── + header("Health Checks") + + if ms.embedder.health_check(): + ok("BGE embedding service is alive") + else: + fail("BGE embedding service not responding") + sys.exit(1) + + if ms.redis.health_check(): + ok("Redis connection OK") + else: + fail("Redis connection failed") + sys.exit(1) + + ok("MySQL connection OK (implicit via queries)") + + # ── Step 1: Create teams ───────────────────────────────────── + header("Step 1: Create Teams") + + # Clean up first (idempotent) + try: + ms.delete_team("team_a") + ms.delete_team("team_b") + except: + pass + + team_a, elapsed = measure("create_team", ms.create_team, "team_a", "Team Alpha", "First test team") + ok(f"Created team_a: {team_a['name']} ({elapsed:.1f}ms)") + + team_b, elapsed = measure("create_team", ms.create_team, "team_b", "Team Beta", "Second test team") + ok(f"Created team_b: {team_b['name']} ({elapsed:.1f}ms)") + + # ── Step 2: Create agents ──────────────────────────────────── + header("Step 2: Create Agents") + + agent_a, elapsed = measure("create_agent", ms.create_agent, "agent_alpha_1", "team_a", "Alpha Agent", "developer") + ok(f"Created agent_alpha_1 in team_a ({elapsed:.1f}ms)") + + agent_b, elapsed = measure("create_agent", ms.create_agent, "agent_beta_1", "team_b", "Beta Agent", "developer") + ok(f"Created agent_beta_1 in team_b ({elapsed:.1f}ms)") + + # ── Step 3: Write personal memories ────────────────────────── + header("Step 3: Write Personal Memories") + + write_latencies = [] + + mem_a, elapsed = measure("add_personal_memory", ms.add_personal_memory, + "agent_alpha_1", "用户的编程偏好是 Python,喜欢用 Flask 和 FastAPI 框架", + importance=0.8, metadata={"topic": "preference"}) + write_latencies.append(elapsed) + ok(f"team_a memory: '{mem_a['content'][:40]}...' ({elapsed:.1f}ms)") + + mem_b, elapsed = measure("add_personal_memory", ms.add_personal_memory, + "agent_beta_1", "用户的编程偏好是 Java,喜欢用 Spring Boot 框架", + importance=0.8, metadata={"topic": "preference"}) + write_latencies.append(elapsed) + ok(f"team_b memory: '{mem_b['content'][:40]}...' ({elapsed:.1f}ms)") + + # Add more memories for richer testing + for i, content in enumerate([ + "团队每周一开例会", + "项目截止日期是下个月底", + "用户不喜欢加班", + ]): + _, elapsed = measure("add_personal_memory", ms.add_personal_memory, + "agent_alpha_1", content, importance=0.5 + i * 0.1) + write_latencies.append(elapsed) + + for i, content in enumerate([ + "团队使用 Jenkins 做 CI/CD", + "数据库用的是 PostgreSQL", + "代码审查需要至少两人通过", + ]): + _, elapsed = measure("add_personal_memory", ms.add_personal_memory, + "agent_beta_1", content, importance=0.5 + i * 0.1) + write_latencies.append(elapsed) + + ok(f"Wrote {len(write_latencies)} memories total") + + # ── Step 4: Write team memories ────────────────────────────── + header("Step 4: Write Team Shared Memories") + + tm_a, elapsed = measure("add_team_memory", ms.add_team_memory, + "team_a", "团队技术栈: Python, Flask, MySQL, Redis", + importance=0.9, category="tech_stack") + write_latencies.append(elapsed) + ok(f"team_a shared: '{tm_a['content'][:40]}...' ({elapsed:.1f}ms)") + + tm_b, elapsed = measure("add_team_memory", ms.add_team_memory, + "team_b", "团队技术栈: Java, Spring Boot, PostgreSQL, Kafka", + importance=0.9, category="tech_stack") + write_latencies.append(elapsed) + ok(f"team_b shared: '{tm_b['content'][:40]}...' ({elapsed:.1f}ms)") + + # ── Step 5: Semantic search with isolation ─────────────────── + header("Step 5: Semantic Search + Team Isolation") + + search_latencies = [] + + # Search team_a for "编程偏好" + results_a, elapsed = measure("search_personal_memories", ms.search_personal_memories, + "agent_alpha_1", "编程偏好", limit=5) + search_latencies.append(elapsed) + ok(f"team_a search '编程偏好' → {len(results_a)} results ({elapsed:.1f}ms)") + for r in results_a: + print(f" score={r['score']:.4f} | {r['content'][:60]}") + + # Search team_b for "编程偏好" + results_b, elapsed = measure("search_personal_memories", ms.search_personal_memories, + "agent_beta_1", "编程偏好", limit=5) + search_latencies.append(elapsed) + ok(f"team_b search '编程偏好' → {len(results_b)} results ({elapsed:.1f}ms)") + for r in results_b: + print(f" score={r['score']:.4f} | {r['content'][:60]}") + + # Verify isolation + header("Step 6: Cross-Team Isolation Verification") + + a_contents = [r["content"] for r in results_a] + b_contents = [r["content"] for r in results_b] + + python_in_a = any("Python" in c for c in a_contents) + java_in_a = any("Java" in c for c in a_contents) + python_in_b = any("Python" in c for c in b_contents) + java_in_b = any("Java" in c for c in b_contents) + + if python_in_a and not java_in_a: + ok("team_a returns Python (not Java) ✓") + elif not a_contents: + warn("team_a search returned no results") + else: + fail(f"team_a isolation issue: found Java={java_in_a}, Python={python_in_a}") + + if java_in_b and not python_in_b: + ok("team_b returns Java (not Python) ✓") + elif not b_contents: + warn("team_b search returned no results") + else: + fail(f"team_b isolation issue: found Python={python_in_b}, Java={java_in_b}") + + # Also verify team shared memories + team_search_a, elapsed = measure("search_team_memories", ms.search_team_memories, + "team_a", "技术栈", limit=3) + search_latencies.append(elapsed) + ok(f"team_a shared search '技术栈' → {len(team_search_a)} results ({elapsed:.1f}ms)") + for r in team_search_a: + print(f" score={r['score']:.4f} | {r['content'][:60]}") + + team_search_b, elapsed = measure("search_team_memories", ms.search_team_memories, + "team_b", "技术栈", limit=3) + search_latencies.append(elapsed) + ok(f"team_b shared search '技术栈' → {len(team_search_b)} results ({elapsed:.1f}ms)") + for r in team_search_b: + print(f" score={r['score']:.4f} | {r['content'][:60]}") + + # ── Step 7: Working memory ────────────────────────────────── + header("Step 7: Working Memory (Redis)") + + wm, elapsed = measure("add_working_memory", ms.add_working_memory, + "agent_alpha_1", "刚刚和用户讨论了部署方案") + ok(f"Added working memory ({elapsed:.1f}ms)") + + wm_items, elapsed = measure("get_working_memories", ms.get_working_memories, + "agent_alpha_1", limit=10) + ok(f"Retrieved {len(wm_items)} working memories ({elapsed:.1f}ms)") + + # ── Step 8: Recent memories ───────────────────────────────── + header("Step 8: Recent Memories") + + recent_a, elapsed = measure("get_recent_personal_memories", ms.get_recent_personal_memories, + "agent_alpha_1", limit=5) + ok(f"Recent personal memories for agent_alpha_1: {len(recent_a)} ({elapsed:.1f}ms)") + + recent_team, elapsed = measure("get_recent_team_memories", ms.get_recent_team_memories, + "team_a", limit=5) + ok(f"Recent team memories for team_a: {len(recent_team)} ({elapsed:.1f}ms)") + + # ── Performance Report ────────────────────────────────────── + header("Performance Report") + + write_p50 = statistics.median(write_latencies) + write_p99 = sorted(write_latencies)[int(len(write_latencies) * 0.99)] if len(write_latencies) > 1 else write_latencies[0] + write_max = max(write_latencies) + + search_p50 = statistics.median(search_latencies) + search_p99 = sorted(search_latencies)[int(len(search_latencies) * 0.99)] if len(search_latencies) > 1 else search_latencies[0] + search_max = max(search_latencies) + + print(f" Write latency: P50={write_p50:.1f}ms P99={write_p99:.1f}ms Max={write_max:.1f}ms") + print(f" Search latency: P50={search_p50:.1f}ms P99={search_p99:.1f}ms Max={search_max:.1f}ms") + + if write_p99 < 100: + ok("Write P99 < 100ms ✓") + else: + fail(f"Write P99 = {write_p99:.1f}ms (target < 100ms)") + + if search_p99 < 50: + ok("Search P99 < 50ms ✓") + else: + warn(f"Search P99 = {search_p99:.1f}ms (target < 50ms, but embedding call dominates)") + + # Memory usage + try: + import resource + rss_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 # Linux: KB → MB + print(f" Process RSS: {rss_mb:.0f} MB") + if rss_mb < 1536: + ok("RSS < 1.5 GiB ✓") + else: + fail(f"RSS = {rss_mb:.0f} MB (target < 1536 MB)") + except: + warn("Could not measure RSS") + + # Stats + header("System Stats") + stats, elapsed = measure("get_stats", ms.get_stats) + ok(f"Stats: {json.dumps(stats, indent=2)}") + + # ── Summary ───────────────────────────────────────────────── + header("TEST SUMMARY") + print(f" Teams created: 2") + print(f" Agents created: 2") + print(f" Memories written: {len(write_latencies)}") + print(f" Searches executed: {len(search_latencies)}") + print(f" Team isolation: {'PASS' if (python_in_a and java_in_b and not java_in_a and not python_in_b) else 'CHECK MANUALLY'}") + print(f" Write P99: {write_p99:.1f}ms {'PASS' if write_p99 < 100 else 'WARN'}") + print(f" Search P99: {search_p99:.1f}ms {'PASS' if search_p99 < 50 else 'WARN'}") + print() + print(f"{GREEN}{BOLD}All validation tests completed!{RESET}") + + +if __name__ == "__main__": + main()