478 lines
17 KiB
Python
478 lines
17 KiB
Python
#!/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) |