初始化:记忆系统源代码上传(已脱敏)

- 排除 .env / *.bak / 内部运维文档(README_INTERNAL.html)
- config.py 默认密码已替换为占位符 CHANGE_ME_*
- init_db.sql 移除生产数据库用户 GRANT 段
- README.html 数据库用户名已脱敏
- 保留:源码 + 公网 API 文档 + 建表 SQL(无授权语句)
This commit is contained in:
zqf
2026-08-03 04:53:48 +08:00
commit b1f93588f1
30 changed files with 5591 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Lifecycle management
+92
View File
@@ -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(名称)、summary100字以内摘要)、memory_idsID列表)
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)
+43
View File
@@ -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
+89
View File
@@ -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,
}
+101
View File
@@ -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. 每条事实:content50-200字)、importance0.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)}
+344
View File
@@ -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")
+80
View File
@@ -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)
+136
View File
@@ -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}