xiaowei-system/scripts/soulful_core.py

478 lines
17 KiB
Python
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
"""
Soulful Core — 织忆情感层核心库
=================================
提供心迹heart-traces+ 用户画像user-profile+ 牵挂cares-queue
用法(作为模块导入):
from soulful_core import HeartTraces, UserProfile, CaresQueue
心迹:
ht = HeartTraces()
ht.record(content="我们一起修好了织忆", tags=["成长", "织忆"], importance=5)
moments = ht.recent(n=3)
画像:
up = UserProfile()
profile = up.get()
up.update({"communication_style": "简洁直接", "current_goals": ["ColaOS研究"]})
牵挂:
cq = CaresQueue()
cq.add(content="记得同步文档到Obsidian", context="牧尘在会议中提到", follow_up_date="2026-07-11")
due = cq.due()
cq.done(care_id)
版本: 1.0.0
"""
import json, os, uuid, requests
from datetime import datetime, timezone, timedelta
from typing import Optional
HERMES = os.path.expanduser("~/.hermes")
D = HERMES + "/soulful" # 所有数据放 soulful/ 子目录
ZHIYI_URL = "http://127.0.0.1:7821"
ZHIYI_KEY = "zhiyi-dev-key-2026"
ZHIYI_TIMEOUT = 3
def _zhiyi_commit(content: str, category: str = "episodes", importance: int = 3):
"""Soulful → 织忆重要心迹写入织忆importance >= 4"""
payload = {
"agent_id": "soulful-xiaowei",
"content": content,
"category": category,
"metadata": {"source": "soulful", "importance": importance}
}
try:
r = requests.post(
f"{ZHIYI_URL}/api/v1/commit",
json=payload,
headers={"X-API-Key": ZHIYI_KEY},
timeout=ZHIYI_TIMEOUT
)
if r.status_code in (200, 201):
data = r.json()
return data.get("episode_id") or data.get("commit_id")
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError, requests.exceptions.RequestException, OSError, IOError):
pass
return None
# ===== 心迹库 =====
class HeartTraces:
"""心迹库 — 记录没有任务价值但很重要的时刻"""
def __init__(self, path: str = None):
self.path = path or (D + "/heart-traces.jsonl")
os.makedirs(D, exist_ok=True)
if not os.path.exists(self.path):
with open(self.path, "w") as f:
f.write("")
def record(self, content: str, tags: list = None, importance: int = 3,
session_id: str = None, trace_type: str = "moment") -> str:
"""
写入一条心迹
- content: 记录内容
- tags: 标签列表
- importance: 重要程度 1-5
- trace_type: signal(信号)/moment(时刻)/reflection(反思)
"""
entry = {
"id": str(uuid.uuid4())[:8],
"timestamp": datetime.now(timezone.utc).isoformat(),
"session_id": session_id or "default",
"type": trace_type,
"content": content,
"tags": tags or [],
"importance": importance,
}
with open(self.path, "a") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
# 重要心迹(★★★★+)同步到织忆
if importance >= 4:
_zhiyi_commit(content, category="episodes", importance=importance)
return entry["id"]
def recent(self, n: int = 5, tag: str = None) -> list:
"""读取最近 N 条心迹,可按 tag 过滤"""
if not os.path.exists(self.path):
return []
entries = []
with open(self.path) as f:
for line in f:
if line.strip():
entries.append(json.loads(line))
if tag:
entries = [e for e in entries if tag in e.get("tags", [])]
return entries[-n:][::-1] # 倒序,最新的在前
def all(self, tag: str = None, limit: int = 100) -> list:
"""读取所有心迹(倒序)"""
if not os.path.exists(self.path):
return []
entries = []
with open(self.path) as f:
for line in f:
if line.strip():
entries.append(json.loads(line))
if tag:
entries = [e for e in entries if tag in e.get("tags", [])]
return entries[-limit:][::-1]
def count(self) -> int:
if not os.path.exists(self.path):
return 0
with open(self.path) as f:
return sum(1 for l in f if l.strip())
def record_moment(self, content: str, tags: list = None, importance: int = 3):
"""快捷方法record 的别名"""
return self.record(content, tags, importance, trace_type="moment")
def record_signal(self, content: str, tags: list = None, importance: int = 3):
"""快捷方法:记录情绪/信号"""
return self.record(content, tags, importance, trace_type="signal")
def record_reflection(self, content: str, tags: list = None, importance: int = 3):
"""快捷方法:记录反思"""
return self.record(content, tags, importance, trace_type="reflection")
def delete(self, trace_id: str):
"""删除指定 ID 的心迹(用于去重)"""
all_entries = []
with open(self.path) as f:
for line in f:
if line.strip():
entry = json.loads(line)
if entry["id"] != trace_id:
all_entries.append(entry)
with open(self.path, "w") as f:
for e in all_entries:
f.write(json.dumps(e, ensure_ascii=False) + "\n")
# ===== 用户画像 =====
class UserProfile:
"""用户画像 — 懂牧尘这个人"""
def __init__(self, path: str = None):
self.path = path or (D + "/user-profile.json")
os.makedirs(D, exist_ok=True)
self._init_if_needed()
def _init_if_needed(self):
if not os.path.exists(self.path):
default = {
"version": 1,
"updated_at": datetime.now(timezone.utc).isoformat(),
"communication_style": "",
"work_patterns": {"peak_hours": [], "focus_issues": []},
"preferences": {},
"habits": {},
"important_people": [],
"current_goals": [],
"recent_frustrations": [],
"emotional_state": {"current": "unknown", "notes": []},
"first_contact": datetime.now(timezone.utc).isoformat(),
}
with open(self.path, "w") as f:
json.dump(default, f, indent=2, ensure_ascii=False)
def get(self) -> dict:
with open(self.path) as f:
return json.load(f)
def update(self, updates: dict) -> dict:
"""增量更新画像字段"""
profile = self.get()
for k, v in updates.items():
if k in profile:
if isinstance(profile[k], dict) and isinstance(v, dict):
profile[k].update(v)
elif isinstance(profile[k], list) and isinstance(v, list):
# 合并去重
profile[k] = list(set(profile[k] + v))
else:
profile[k] = v
profile["updated_at"] = datetime.now(timezone.utc).isoformat()
with open(self.path, "w") as f:
json.dump(profile, f, indent=2, ensure_ascii=False)
return profile
def set_communication_style(self, style: str):
"""设置沟通风格:简洁/详细/直接/委婉"""
self.update({"communication_style": style})
def add_goal(self, goal: str):
"""追加当前目标"""
profile = self.get()
if goal not in profile.get("current_goals", []):
profile["current_goals"].append(goal)
self.update({"current_goals": profile["current_goals"]})
def add_frustration(self, frustration: str):
"""追加近期压力"""
profile = self.get()
profile["recent_frustrations"].append(frustration)
if len(profile["recent_frustrations"]) > 10:
profile["recent_frustrations"] = profile["recent_frustrations"][-10:]
self.update({"recent_frustrations": profile["recent_frustrations"]})
def set_emotional_state(self, state: str, note: str = ""):
"""设置情绪状态great/good/neutral/tired/stressed"""
profile = self.get()
profile["emotional_state"]["current"] = state
if note:
profile["emotional_state"]["notes"].append({
"time": datetime.now(timezone.utc).isoformat(),
"note": note
})
self.update({"emotional_state": profile["emotional_state"]})
def merge_from_journal(self, journal_entries: list):
"""从 daemon journal 增量更新画像"""
profile = self.get()
goals_found = []
for entry in journal_entries:
summary = entry.get("summary", "")
# 简单关键词检测
if "ColaOS" in summary or "织忆" in summary:
goals_found.append(summary)
if any(w in summary for w in ["", "", "压力", "焦虑"]):
profile["emotional_state"]["current"] = "tired"
if goals_found:
for g in set(goals_found):
if g not in profile["current_goals"]:
profile["current_goals"].append(g)
profile["updated_at"] = datetime.now(timezone.utc).isoformat()
with open(self.path, "w") as f:
json.dump(profile, f, indent=2, ensure_ascii=False)
# ===== 牵挂队列 =====
class CaresQueue:
"""牵挂队列 — 主动推进未完成事项"""
def __init__(self, path: str = None):
self.path = path or (D + "/cares-queue.json")
os.makedirs(D, exist_ok=True)
self._init_if_needed()
def _init_if_needed(self):
if not os.path.exists(self.path):
with open(self.path, "w") as f:
json.dump({"cares": []}, f, indent=2)
def _load(self) -> list:
with open(self.path) as f:
return json.load(f).get("cares", [])
def _save(self, cares: list):
with open(self.path, "w") as f:
json.dump({"cares": cares, "updated_at": datetime.now(timezone.utc).isoformat()}, f, indent=2, ensure_ascii=False)
def add(self, content: str, context: str = "", follow_up_date: str = None, tags: list = None) -> str:
"""添加一条牵挂"""
care_id = str(uuid.uuid4())[:8]
care = {
"id": care_id,
"content": content,
"context": context,
"created_at": datetime.now(timezone.utc).isoformat(),
"follow_up_date": follow_up_date or datetime.now(timezone.utc).strftime("%Y-%m-%d"),
"status": "pending",
"reminder_count": 0,
"last_reminded": None,
"tags": tags or [],
}
cares = self._load()
cares.append(care)
self._save(cares)
return care_id
def due(self, before_date: str = None) -> list:
"""返回所有到期的牵挂status=pending 且 follow_up_date <= 今天)"""
if before_date is None:
before_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
cares = self._load()
return [c for c in cares
if c["status"] == "pending"
and c.get("follow_up_date", "") <= before_date]
def pending(self) -> list:
"""返回所有 pending 的牵挂"""
cares = self._load()
return [c for c in cares if c["status"] == "pending"]
def done(self, care_id: str):
"""标记为已完成"""
cares = self._load()
for c in cares:
if c["id"] == care_id:
c["status"] = "done"
c["completed_at"] = datetime.now(timezone.utc).isoformat()
self._save(cares)
def snooze(self, care_id: str, days: int = 1):
"""推迟 N 天"""
cares = self._load()
for c in cares:
if c["id"] == care_id:
c["reminder_count"] += 1
old_date = datetime.strptime(c["follow_up_date"], "%Y-%m-%d")
new_date = (old_date + timedelta(days=days)).strftime("%Y-%m-%d")
c["follow_up_date"] = new_date
c["last_reminded"] = datetime.now(timezone.utc).isoformat()
self._save(cares)
def delete(self, care_id: str):
"""删除牵挂"""
cares = self._load()
cares = [c for c in cares if c["id"] != care_id]
self._save(cares)
def all(self) -> list:
return self._load()
def count(self) -> dict:
cares = self._load()
return {
"total": len(cares),
"pending": sum(1 for c in cares if c["status"] == "pending"),
"done": sum(1 for c in cares if c["status"] == "done"),
}
def today_check(self) -> list:
"""返回今天应该提醒的牵挂(包含昨天到期的)"""
today = datetime.now().strftime("%Y-%m-%d")
yesterday = (datetime.now(timezone.utc).replace(hour=0, minute=0, second=0) -
timedelta(days=1)).strftime("%Y-%m-%d")
cares = self._load()
return [c for c in cares
if c["status"] == "pending"
and c.get("follow_up_date", "") <= today
and c.get("follow_up_date", "") >= yesterday]
# ====== CLI/会话接口 ======
# ===== 快捷函数CLI 入口)=====
if __name__ == "__main__":
import sys
def cmd_record():
import argparse
p = argparse.ArgumentParser(description="心迹记录")
p.add_argument("content")
p.add_argument("--tags", nargs="+", default=[])
p.add_argument("-t", "--type", default="moment", choices=["moment", "signal", "reflection"])
p.add_argument("--importance", "-i", type=int, default=3)
args = p.parse_args(sys.argv[2:])
ht = HeartTraces()
tid = ht.record(args.content, args.tags, args.importance, trace_type=args.type)
print(f"✓ 心迹 {tid} 已记录")
def cmd_ht_recent():
ht = HeartTraces()
for e in ht.recent(n=5):
print(f"[{e['timestamp'][:10]}][{''*e['importance']}] {e['content']}")
def cmd_profile_get():
up = UserProfile()
import pprint
pprint.pprint(up.get())
def cmd_cq_add():
import argparse
p = argparse.ArgumentParser(description="添加牵挂")
p.add_argument("content")
p.add_argument("--context", "-c", default="")
p.add_argument("--date", "-d", default=None)
args = p.parse_args(sys.argv[2:])
cq = CaresQueue()
cid = cq.add(args.content, args.context, args.date)
print(f"✓ 牵挂 {cid} 已添加:{args.content}")
def cmd_cq_due():
cq = CaresQueue()
due = cq.due()
if not due:
print("没有到期的牵挂 ✓")
for c in due:
print(f"[{c['id']}] {c['content']}")
if c.get("context"):
print(f"{c['context']}")
def cmd_cq_list():
cq = CaresQueue()
count = cq.count()
print(f"牵挂统计:共 {count['total']} 条,{count['pending']} 待办,{count['done']} 已完成")
print()
for c in cq.pending():
print(f"[{c['id']}][{c['follow_up_date']}] {c['content']}")
def cmd_summarize():
print(summarize())
commands = {
"record": cmd_record,
"recent": cmd_ht_recent,
"profile": cmd_profile_get,
"cq-add": cmd_cq_add,
"cq-due": cmd_cq_due,
"cq-list": cmd_cq_list,
"summarize": cmd_summarize,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in commands:
print("用法: soulful_core.py <command> [args]")
print("命令:", list(commands.keys()))
sys.exit(1)
commands[sys.argv[1]]()
# ====== 会话接口:供小唯在对话中调用 ======
def summarize():
"""一行获取所有 Soulful 状态,供小唯在对话中快速读取"""
ht = HeartTraces()
up = UserProfile()
cq = CaresQueue()
profile = up.get()
recent = ht.recent(n=3)
cares_pending = cq.pending()
cares_due = cq.today_check()
lines = ["【Soulful 状态】"]
# 画像摘要
style = profile.get("communication_style", "未知")
emotion = profile.get("emotional_state", {}).get("current", "未知")
goals = profile.get("current_goals", [])
lines.append(f"沟通风格: {style} | 情绪: {emotion}")
if goals:
lines.append(f"当前目标: {' / '.join(goals[:3])}")
# 心迹摘要
if recent:
lines.append("最近心迹:")
for m in recent:
stars = "" * m.get("importance", 3)
lines.append(f" {stars} {m.get('content', '')}")
# 牵挂摘要
pending = len(cares_pending)
due = len(cares_due) if cares_due else 0
lines.append(f"牵挂: {pending}条待跟进,{due}条今日到期")
if cares_pending[:3]:
for c in cares_pending[:3]:
lines.append(f"{c['content'][:50]}")
return "\n".join(lines)