scout/automation/starlight.py
Rama main · réplica de lectura del repositorio original.
"""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