"""Starlight Distillation — 星夜凝萃:从对话中自动提取长期记忆. 核心思想: - 每天定时(默认凌晨 2:00)回顾过去 24 小时的对话 - 用 LLM 提取关键信息(偏好、决策、事实、上下文) - 去重后存入记忆库,形成 Agent 的长期知识 """ from __future__ import annotations import json import logging import asyncio from datetime import datetime, timedelta from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from scout.engine.agent import Agent from scout.memory.store import MemoryStore logger = logging.getLogger(__name__) EXTRACT_PROMPT = """你是一位细心的记忆提取专家。请回顾以下对话,提取值得长期记住的关键信息。 ## 提取标准 **必须提取:** - 用户明确的偏好(如"我喜欢用中文"、"我不喜欢深色主题"、"代码风格偏好") - 重要的决策或结论(如"我们决定用方案 B") - 用户透露的个人事实(职业、习惯、计划、偏好) - 技术问题的最终解决方案 - 重复出现的模式或习惯 **不要提取:** - 临时性、一次性的问题(除非用户明确表示重要) - 纯技术细节(除非用户反复提及) - 闲聊内容 ## 对话内容 {conversations} ## 输出格式 以 JSON 格式输出,确保是合法 JSON: ```json {{ "memories": [ {{ "content": "用户喜欢使用中文交流,偏好简洁的代码风格", "category": "preference", "importance": 0.8, "reason": "用户多次提到" }}, {{ "content": "用户是前端开发者,使用 React + TypeScript", "category": "fact", "importance": 0.9, "reason": "用户自我介绍" }} ], "summary": "本次提取了 X 条关键记忆" }} ``` category 可选值:preference(偏好)、fact(事实)、decision(决策)、context(上下文) importance 范围:0.1 - 1.0(越高越重要) """ class StarlightDistiller: """星夜凝萃器 — 自动从对话中提取长期记忆.""" def __init__(self, agent: Agent): self.agent = agent self.session_store = agent.session_store self.memory_store: MemoryStore = agent.memory_store self.llm = agent.llm self.config = { "enabled": True, "lookback_hours": 24, # 回溯多少小时 "min_conversations": 2, # 至少多少对话才触发提取 "similarity_threshold": 0.75, # 相似记忆去重阈值 "model": None, # None 表示使用主模型 "schedule_hour": 2, # 每天几点执行(24小时制) } self.last_run: datetime | None = None self.last_result: dict | None = None self._scheduler_task: asyncio.Task | None = None self._catchup_task: asyncio.Task | None = None # ★ 桌面「用完就关」补偿:last_run 必须落盘,否则进程重启就忘了 # 上次什么时候跑过,启动补偿会每次启动都误触发。 self._load_persisted_state() # ── 持久化(last_run 落盘) ────────────────────────────────── @property def _state_path(self) -> Path: from scout.config.paths import DATA_DIR return DATA_DIR / "starlight_state.json" def _load_persisted_state(self): """从磁盘恢复 last_run,让重启后的启动补偿能正确判断是否落后.""" try: p = self._state_path if not p.exists(): return data = json.loads(p.read_text(encoding="utf-8")) lr = data.get("last_run") if lr: self.last_run = datetime.fromisoformat(lr) except Exception as e: # noqa: BLE001 logger.debug(f"读取星夜凝萃状态文件失败(忽略): {e}") def _persist_state(self): """把 last_run 写到磁盘.""" try: p = self._state_path p.parent.mkdir(parents=True, exist_ok=True) p.write_text( json.dumps( { "last_run": self.last_run.isoformat() if self.last_run else None, "updated_at": datetime.now().isoformat(), }, ensure_ascii=False, ), encoding="utf-8", ) except Exception as e: # noqa: BLE001 logger.warning(f"写入星夜凝萃状态文件失败: {e}") def set_config(self, **kwargs): """更新配置.""" self.config.update(kwargs) def start_scheduler(self): """启动定时调度器.""" if self._scheduler_task and not self._scheduler_task.done(): logger.info("调度器已在运行") return # ★ 2026-09-01:先取 running loop 再创建协程,避免"协程对象已创建但 # create_task 因无事件循环失败"导致的 never awaited 泄漏警告。 loop = asyncio.get_running_loop() # 无事件循环时在此抛 RuntimeError self._scheduler_task = loop.create_task(self._schedule_loop()) # ★ 2026-09-25:启动补偿跟随调度器挂载。补偿若只在 server.py lifespan # 挂载,懒加载模式下 agent 建立晚于 lifespan,get_starlight() 为 None, # 补偿永远挂不上(实测 --no-gui / 首次对话前均如此)。挂在调度器 # 成功启动之后,无论 lifespan 还是 init_starlight 触发都覆盖。 if self._catchup_task is None or self._catchup_task.done(): self._catchup_task = loop.create_task(self.maybe_catch_up()) logger.info(f"星夜凝萃调度器已启动,每天 {self.config['schedule_hour']}:00 执行") def stop_scheduler(self): """停止定时调度器.""" if self._scheduler_task and not self._scheduler_task.done(): self._scheduler_task.cancel() logger.info("星夜凝萃调度器已停止") if self._catchup_task and not self._catchup_task.done(): self._catchup_task.cancel() async def _schedule_loop(self): """定时调度循环 — 每天在指定时间执行.""" while True: try: now = datetime.now() # 计算下次执行时间(今天的指定小时,如果已过则明天) target = now.replace(hour=self.config['schedule_hour'], minute=0, second=0, microsecond=0) if target <= now: target += timedelta(days=1) # 等待到目标时间 wait_seconds = (target - now).total_seconds() logger.debug(f"下次凝萃将在 {target.strftime('%Y-%m-%d %H:%M:%S')} 执行,等待 {wait_seconds:.0f} 秒") await asyncio.sleep(wait_seconds) # 检查是否启用 if not self.config["enabled"]: logger.info("星夜凝萃已禁用,跳过本次执行") continue # 执行凝萃 logger.info("开始执行星夜凝萃...") result = await self.run(force=False) logger.info(f"星夜凝萃完成: {result.get('message', '')}") except asyncio.CancelledError: logger.info("调度器被取消") break except Exception as e: logger.error(f"调度器异常: {e}", exc_info=True) # 出错后等待 1 小时再重试 await asyncio.sleep(3600) async def extract_from_conversations(self, conversations: list[dict]) -> dict: """从对话列表中提取关键记忆. Args: conversations: 对话列表,每条包含 session_id, messages, updated_at Returns: {"memories": [...], "summary": "..."} """ if not conversations: return {"memories": [], "summary": "无对话可提取"} # 构建对话文本 conv_text = [] for conv in conversations: messages = conv.get("messages", []) if not messages: continue conv_text.append(f"=== 会话 {conv['session_id'][:8]} ({conv['updated_at']}) ===") for msg in messages[-20:]: # 只取最近 20 条消息 role = msg.get("role", "unknown") content = msg.get("content", "") if content: conv_text.append(f"[{role}]: {content[:500]}") conv_text.append("") if not conv_text: return {"memories": [], "summary": "对话内容为空"} full_text = "\n".join(conv_text) # 调用 LLM 提取 prompt = EXTRACT_PROMPT.format(conversations=full_text) try: response = await self.llm.complete( [{"role": "user", "content": prompt}], temperature=0.3, # 低温度保证一致性 ) # 解析 JSON result_text = response.content.strip() # 尝试提取 JSON 块 if "```json" in result_text: start = result_text.find("```json") + 7 end = result_text.find("```", start) result_text = result_text[start:end].strip() elif "{" in result_text: start = result_text.find("{") end = result_text.rfind("}") + 1 result_text = result_text[start:end] result = json.loads(result_text) return result except Exception as e: logger.error(f"提取记忆失败: {e}") return {"memories": [], "summary": f"提取失败: {e}"} async def deduplicate_memories(self, new_memories: list[dict]) -> list[dict]: """去重:过滤掉与现有记忆高度相似的新记忆. Args: new_memories: 新提取的记忆列表 Returns: 去重后的记忆列表 """ if not new_memories: return [] unique = [] for mem in new_memories: content = mem.get("content", "") if not content or len(content) < 5: continue # 搜索相似记忆 similar = self.memory_store.search(content, limit=3) if similar: # 检查内容是否有高度重叠(简单的前缀/子串匹配) is_duplicate = False for existing in similar: existing_content = existing.content or "" # 如果新记忆的大部分内容已存在于某条记忆中,视为重复 if len(content) > 10 and (content[:50] in existing_content or existing_content[:50] in content): is_duplicate = True break # 如果两条记忆长度相近且前 30 个字符相同 if abs(len(content) - len(existing_content)) < 20 and content[:30] == existing_content[:30]: is_duplicate = True break if is_duplicate: logger.debug(f"跳过重复记忆: {content[:50]}") continue unique.append(mem) return unique async def store_memories(self, memories: list[dict]) -> int: """存储记忆到记忆库. Args: memories: 记忆列表 Returns: 成功存储的记忆数量 """ stored = 0 for mem in memories: try: content = mem.get("content", "").strip() if not content: continue category = mem.get("category", "context") importance = float(mem.get("importance", 0.5)) importance = max(0.1, min(1.0, importance)) self.memory_store.add( content=content, category=category, importance=importance, ) stored += 1 logger.info(f"✓ 存储记忆: [{category}] {content[:60]}") except Exception as e: logger.error(f"存储记忆失败: {e}") return stored async def run(self, force: bool = False) -> dict: """执行星夜凝萃. Args: force: 是否强制执行(忽略时间窗口和最小对话数检查) Returns: 执行结果 """ if not self.config["enabled"] and not force: return {"success": False, "message": "星夜凝萃已禁用"} # 计算时间窗口 lookback = timedelta(hours=self.config["lookback_hours"]) cutoff = datetime.now() - lookback # 获取最近的会话 (async_list_sessions 返回 list[dict],包含 id, updated_at 等) # ★ 2026-09-27 冻死修复:本方法运行在主事件循环上,必须 await 异步 API。 all_sessions = await self.session_store.async_list_sessions(limit=50) recent_sessions = [] for s in all_sessions: updated = s.get("updated_at") if not updated: continue try: if isinstance(updated, str): updated_dt = datetime.fromisoformat(updated) else: updated_dt = updated if updated_dt > cutoff: recent_sessions.append(s) except (ValueError, TypeError): continue logger.info(f"找到 {len(recent_sessions)} 个近 {self.config['lookback_hours']}h 会话") if len(recent_sessions) < self.config["min_conversations"] and not force: return { "success": True, "message": f"对话数不足({len(recent_sessions)}/{self.config['min_conversations']}),跳过提取", "extracted": 0, "stored": 0, } # 加载每个会话的完整消息,构建对话列表 conversations = [] for s in recent_sessions: session = await self.session_store.async_load_session(s["id"]) if not session or not session.messages: continue # 将 Message 对象转为 dict msgs = [] for m in session.messages: msgs.append({ "role": m.role.value if hasattr(m.role, 'value') else str(m.role), "content": m.content or "", }) conversations.append({ "session_id": s["id"], "messages": msgs, "updated_at": s.get("updated_at", ""), }) # 提取记忆 result = await self.extract_from_conversations(conversations) new_memories = result.get("memories", []) logger.info(f"提取到 {len(new_memories)} 条候选记忆") # 去重 unique_memories = await self.deduplicate_memories(new_memories) logger.info(f"去重后剩余 {len(unique_memories)} 条") # 存储 stored_count = await self.store_memories(unique_memories) logger.info(f"成功存储 {stored_count} 条记忆") # 记录结果 self.last_run = datetime.now() self._persist_state() # ★ 落盘,供下次启动的补偿判断 self.last_result = { "timestamp": self.last_run.isoformat(), "sessions_processed": len(conversations), "memories_extracted": len(new_memories), "memories_stored": stored_count, "summary": result.get("summary", ""), } return { "success": True, "message": f"星夜凝萃完成:处理 {len(conversations)} 个会话,存储 {stored_count} 条记忆", "sessions_processed": len(conversations), "extracted": len(new_memories), "stored": stored_count, "summary": result.get("summary", ""), "memories": [ {"content": m["content"], "category": m["category"], "importance": m["importance"]} for m in unique_memories ], } async def maybe_catch_up(self, delay_seconds: float = 5.0) -> dict | None: """启动补偿 — 桌面「用完就关」场景下定时器等不到 2 点, 改为每次启动时检查:若上次运行已落后(从未跑过 / 距今 >24h), 则延迟数秒后自动补跑一次,把昨天的对话凝萃掉。 - 不依赖进程常驻,契合 Windows 桌面用完即关的习惯。 - 受 min_conversations 门槛约束,对话不足时 run() 自行跳过(不调 LLM)。 - 仅当落后时触发,last_run 已落盘,正常情况不会重复跑。 Returns: 补跑结果 dict;未触发(不落后 / 未启用)时返回 None。 """ try: if not self.config["enabled"]: return None now = datetime.now() if self.last_run is not None: elapsed = now - self.last_run # 24h 内的运行视为「刚跑过」,跳过补偿 if elapsed < timedelta(hours=self.config["lookback_hours"]): logger.debug( f"星夜凝萃上次运行于 {elapsed.total_seconds()/3600:.1f}h 前,无需启动补偿" ) return None logger.info("检测到星夜凝萃落后(从未运行或 >24h),启动补偿凝萃...") await asyncio.sleep(delay_seconds) result = await self.run(force=False) logger.info(f"星夜凝萃启动补偿完成: {result.get('message', '')}") return result except asyncio.CancelledError: logger.info("星夜凝萃启动补偿被取消") return None except Exception as e: # noqa: BLE001 logger.error(f"星夜凝萃启动补偿异常: {e}", exc_info=True) return None def get_status(self) -> dict: """获取星夜凝萃状态.""" return { "enabled": self.config["enabled"], "lookback_hours": self.config["lookback_hours"], "min_conversations": self.config["min_conversations"], "similarity_threshold": self.config["similarity_threshold"], "schedule_hour": self.config["schedule_hour"], "scheduler_running": self._scheduler_task is not None and not self._scheduler_task.done(), "last_run": self.last_run.isoformat() if self.last_run else None, "last_result": self.last_result, } # 全局实例(延迟初始化) _distiller: StarlightDistiller | None = None def init_starlight(agent: Agent) -> StarlightDistiller: """初始化星夜凝萃器.""" global _distiller _distiller = StarlightDistiller(agent) # 启动定时调度器。 # ★ 2026-09-01 修复:本函数在 WebAdapter._rebuild_agent(同步上下文)中被调用, # 此时 uvicorn 事件循环尚未运行,asyncio.create_task 会抛 # "no running event loop" → 调度协程 never awaited,星夜凝萃从未运行过。 # 无事件循环时静默跳过,由 server.py 的 FastAPI lifespan(有 loop)补启动。 try: _distiller.start_scheduler() except RuntimeError as e: logger.info(f"当前无事件循环,星夜凝萃调度器将由应用启动时补启: {e}") except Exception as e: logger.warning(f"启动定时调度器失败: {e}") return _distiller def get_starlight() -> StarlightDistiller | None: """获取星夜凝萃器实例.""" return _distiller