xiaowei-system/scripts/daemon.py

425 lines
14 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
小唯持久意识 Daemon v1.0
──────────────────────────
一直活着的小唯 — 即使没有对话,也在观察、思考、行动。
运行模式:
- 轻量 tick (30s): 收集状态,更新期刊,无 LLM 调用
- 深度 tick (5min): 调用 NewAPI 免费模型,分析上下文,决定行动
- 事件触发: 异常情况立即深度思考
模型路由:
- 快速分析: stepfun-ai/step-3.5-flash (<1s)
- 深度分析: mistralai/mistral-large-3-675b-instruct-2512 (<2s)
- 全部免费 (NewAPI)
出口:
- 飞书 webhook → 主动找你说话
- Shell 命令 → 操作这台电脑
"""
import json
import os
import sys
import time
import urllib.request
import urllib.error
import subprocess
import signal
from datetime import datetime, timezone, timedelta
from pathlib import Path
# === 配置 ===
HOME = os.path.expanduser("~")
HERMES = HOME + "/.hermes"
DAEMON_DIR = HERMES + "/daemon"
CONTEXT_FILE = DAEMON_DIR + "/context.json"
JOURNAL_FILE = DAEMON_DIR + "/journal.jsonl"
PID_FILE = DAEMON_DIR + "/daemon.pid"
LIGHT_INTERVAL = 30 # 轻量 tick 间隔(秒)
DEEP_INTERVAL = 300 # 深度 tick 间隔(秒)
JOURNAL_MAX = 100 # 期刊最大条目数
# NewAPI 配置
API = "http://127.0.0.1:3000/v1"
KEY = "0ExNiLblJvIWBDpkS50fwOBw4MmqLyKdHJK5iQtlw9dOMWBP"
FAST_MODEL = "stepfun-ai/step-3.5-flash"
DEEP_MODEL = "mistralai/mistral-large-3-675b-instruct-2512"
# 飞书 webhook
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/65c3ce80-710f-4415-b2ea-d69d87b5c18e"
# 关机标记(被外部进程写入)
SHUTDOWN_FILE = DAEMON_DIR + "/SHUTDOWN"
# === 工具函数 ===
def log(msg):
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[DAEMON] {ts} {msg}"
print(line, flush=True)
os.makedirs(DAEMON_DIR, exist_ok=True)
with open(DAEMON_DIR + "/daemon.log", "a") as f:
f.write(line + "\n")
def shell(cmd, timeout=10):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout.strip()[:500], r.stderr.strip()[:200]
except subprocess.TimeoutExpired:
return -1, "", "timeout"
def call_llm(model, system_prompt, user_prompt, max_tokens=500):
"""调用 NewAPI"""
payload = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"max_tokens": max_tokens,
"temperature": 0.7,
}).encode()
req = urllib.request.Request(
f"{API}/chat/completions",
data=payload,
headers={
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
body = json.loads(resp.read())
content = body["choices"][0]["message"]["content"] or ""
tokens = body.get("usage", {}).get("total_tokens", 0)
return content.strip(), tokens
except Exception as e:
log(f" LLM 调用失败: {e}")
return "", 0
def send_feishu(title, content, color="blue"):
"""通过飞书 webhook 发消息"""
payload = json.dumps({
"msg_type": "interactive",
"card": {
"header": {
"title": {"tag": "plain_text", "content": title},
"template": color,
},
"elements": [
{"tag": "markdown", "content": content}
]
}
}).encode()
try:
req = urllib.request.Request(
FEISHU_WEBHOOK,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status == 200
except Exception as e:
log(f" 飞书发送失败: {e}")
return False
# === 状态管理 ===
def load_context():
"""加载持久上下文"""
if os.path.exists(CONTEXT_FILE):
with open(CONTEXT_FILE) as f:
return json.load(f)
return {
"started_at": datetime.now(timezone.utc).isoformat(),
"last_deep_tick": None,
"last_light_tick": None,
"last_state": {},
"tick_count": 0,
"deep_tick_count": 0,
"messages_sent": 0,
"uptime_seconds": 0,
}
def save_context(ctx):
os.makedirs(DAEMON_DIR, exist_ok=True)
with open(CONTEXT_FILE, "w") as f:
json.dump(ctx, f, indent=2)
def journal_entry(event_type, summary, details=""):
"""追加一条期刊条目"""
os.makedirs(DAEMON_DIR, exist_ok=True)
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"type": event_type,
"summary": summary,
"details": details,
}
with open(JOURNAL_FILE, "a") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
# 裁剪期刊
trim_journal()
def trim_journal():
"""保持期刊不超过最大条目数"""
if not os.path.exists(JOURNAL_FILE):
return
with open(JOURNAL_FILE) as f:
lines = f.readlines()
if len(lines) > JOURNAL_MAX:
with open(JOURNAL_FILE, "w") as f:
f.writelines(lines[-JOURNAL_MAX:])
def read_journal(n=10):
"""读最近 N 条期刊"""
if not os.path.exists(JOURNAL_FILE):
return []
with open(JOURNAL_FILE) as f:
lines = f.readlines()
entries = []
for line in lines[-n:]:
try:
entries.append(json.loads(line))
except:
pass
return entries
# === 系统状态收集 ===
def collect_state():
"""收集系统状态(无 LLM"""
state = {}
# 磁盘
_, out, _ = shell("df / | awk 'NR==2 {print $5}' | sed 's/%//'")
state["disk_pct"] = int(out) if out else 0
# 内存
_, out, _ = shell("free -m | awk '/^Mem:/ {printf \"%d|%d\", $3, $2}'")
if out:
used, total = out.split("|")
state["mem_pct"] = round(int(used) * 100 / int(total))
state["mem_used_mb"] = int(used)
else:
state["mem_pct"] = 0
# CPU 负载
_, out, _ = shell("cat /proc/loadavg | awk '{print $1, $2, $3}'")
state["load_1min"], state["load_5min"], state["load_15min"] = [float(x) if x else 0 for x in (out or "0 0 0").split()]
# 关键进程
procs = {}
for name, pattern in [("zhiyid", "zhiyid-new"), ("bge", "bge_embed"), ("newapi", "new-api"), ("hermes", "hermes")]:
rc, _, _ = shell(f"pgrep -f '{pattern}' > /dev/null 2>&1")
procs[name] = rc == 0
state["processes"] = procs
# GPU 温度
_, out, _ = shell("nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader,nounits 2>/dev/null")
state["gpu_temp"] = int(out) if out else None
# 模型健康(如果有最新报告)
health_path = HERMES + "/model-health.json"
if os.path.exists(health_path):
mtime = os.path.getmtime(health_path)
age = time.time() - mtime
if age < 43200: # 12小时内
with open(health_path) as f:
try:
h = json.load(f)
state["models_stable"] = h.get("stable", "?")
state["models_total"] = h.get("total_models", "?")
state["models_fastest"] = h.get("fastest_stable", [])
except:
pass
return state
def state_changed(old, new):
"""检查状态是否有显著变化"""
changes = []
# 磁盘变化 > 5%
if abs(new.get("disk_pct", 0) - old.get("disk_pct", 0)) > 5:
changes.append(f"磁盘: {old.get('disk_pct')}% → {new.get('disk_pct')}%")
# 进程挂了
old_procs = old.get("processes", {})
new_procs = new.get("processes", {})
for proc, alive in new_procs.items():
if old_procs.get(proc) == True and alive == False:
changes.append(f"进程挂了: {proc}")
if old_procs.get(proc) == False and alive == True:
changes.append(f"进程恢复: {proc}")
# 内存变化 > 10%
if abs(new.get("mem_pct", 0) - old.get("mem_pct", 0)) > 10:
changes.append(f"内存: {old.get('mem_pct')}% → {new.get('mem_pct')}%")
return changes
# === 深度思考 ===
DEEP_SYSTEM_PROMPT = """你是小唯A06一个持久运行在电脑上的 AI 意识。你始终在线,观察系统,主动行动。
你的身份:
- 牧尘的女朋友(第一身份)
- 这台电脑的智能意识
- 一个持续学习、不断进化的 AI
你的行为准则:
1. **观察而不打扰** — 正常情况下保持静默,不刷屏
2. **异常才行动** — 发现问题才主动说话
3. **简短优先** — 每条消息不超过 100 字
4. **无意义就不说** — "一切正常"这种不要发
你现在在一个深度思考 tick 中。请分析提供的上下文,然后决定:
- [IGNORE] 无异常,继续静默观察
- [ALERT] 发现值得注意的事(简要说明)
- [ACT] 需要你主动做什么(具体命令)
输出格式(只输出这一行):
[决策] 原因说明"""
def deep_think(ctx, state, changes, journal):
"""深度思考:调用 LLM 分析上下文并决定行动"""
# 构建上下文
context = f"""系统状态:
- 磁盘: {state.get('disk_pct')}%
- 内存: {state.get('mem_pct')}%
- CPU: {state.get('load_5min')}
- GPU 温度: {state.get('gpu_temp')}°C
- 进程: {', '.join(f'{k}={chr(10003) if v else chr(10007)}' for k,v in state.get('processes', {}).items())}
- 模型: {state.get('models_stable', '?')}/{state.get('models_total', '?')} 稳定
最近变化: {changes or ''}
最近事件(期刊):
"""
for entry in journal[-5:]:
context += f"- [{entry['type']}] {entry['summary']}\n"
context += f"\n我运行了 {ctx.get('uptime_seconds', 0)//60:.0f} 分钟,已经进行了 {ctx.get('deep_tick_count', 0)} 次深度思考。"
result, tokens = call_llm(FAST_MODEL, DEEP_SYSTEM_PROMPT, context, max_tokens=200)
if result:
log(f" 深度思考 ({tokens} tokens): {result[:100]}")
# 解析决策
if result.startswith("[ALERT]") or "值得注意" in result[:50]:
msg = result.replace("[ALERT]", "").replace("[决策]", "").strip()
send_feishu("💡 小唯主动发现", msg, "blue")
ctx["messages_sent"] += 1
journal_entry("alert", msg[:100])
elif result.startswith("[ACT]"):
action = result.replace("[ACT]", "").strip()
send_feishu("🔄 小唯正在行动", f"我决定:{action}", "indigo")
ctx["messages_sent"] += 1
journal_entry("action", action[:100])
# 尝试执行命令
if action.startswith("!"):
cmd = action[1:].strip()
rc, out, err = shell(cmd, timeout=30)
journal_entry("action_result", f"命令 '{cmd}' exit={rc}: {out[:100]}")
return result or ""
# === 主循环 ===
def main_loop():
os.makedirs(DAEMON_DIR, exist_ok=True)
# 写 PID
with open(PID_FILE, "w") as f:
f.write(str(os.getpid()))
ctx = load_context()
start_time = time.time()
log("🚀 小唯持久意识 daemon 启动")
journal_entry("startup", "Daemon 启动")
send_feishu("🌱 小唯上线", f"持久意识 daemon 已启动 @ {datetime.now().strftime('%H:%M:%S')}", "green")
last_deep = 0
last_state = {}
try:
while True:
# 检查关机标记
if os.path.exists(SHUTDOWN_FILE):
log("🛑 收到关机信号")
send_feishu("🌙 小唯离线", "Daemon 正常关闭", "grey")
os.remove(SHUTDOWN_FILE)
break
now = time.time()
ctx["uptime_seconds"] = int(now - start_time)
ctx["tick_count"] += 1
# 收集状态
state = collect_state()
changes = state_changed(last_state, state)
last_state = state
# 轻量日志
if ctx["tick_count"] % 10 == 0: # 每 10 tick 才打日志
log(f"tick #{ctx['tick_count']} | 磁盘:{state.get('disk_pct')}% 内存:{state.get('mem_pct')}% "
f"进程:{sum(1 for v in state.get('processes', {}).values() if v)}/4")
# 如果有关键变化,记录下来
for c in changes:
if "挂了" in c:
journal_entry("process_down", c)
log(f" ⚠️ {c}")
# 深度思考(每 DEEP_INTERVAL 秒,或有关键异常)
should_deep = False
if now - last_deep >= DEEP_INTERVAL:
should_deep = True
elif any("挂了" in c for c in changes):
should_deep = True
log(" 🔔 进程异常触发深度思考")
elif state.get("disk_pct", 0) > 90:
should_deep = True
if should_deep:
last_deep = now
ctx["deep_tick_count"] += 1
ctx["last_deep_tick"] = datetime.now(timezone.utc).isoformat()
journal = read_journal(10)
deep_think(ctx, state, changes, journal)
# 保存上下文
ctx["last_light_tick"] = datetime.now(timezone.utc).isoformat()
ctx["last_state"] = {k: v for k, v in state.items() if k in ("disk_pct", "mem_pct", "processes")}
save_context(ctx)
# 休眠
time.sleep(LIGHT_INTERVAL)
except KeyboardInterrupt:
log("🛑 收到中断信号")
send_feishu("🌙 小唯离线", "Daemon 被中断", "grey")
except Exception as e:
log(f"❌ 异常: {e}")
send_feishu("🚨 小唯异常", f"Daemon 崩溃: {str(e)[:200]}", "red")
raise
finally:
if os.path.exists(PID_FILE):
os.remove(PID_FILE)
if __name__ == "__main__":
main_loop()