feat: Soulful Phase 1 — 心迹/画像/牵挂三库 + daemon集成
- soulful_core.py: HeartTraces + UserProfile + CaresQueue 核心库 - update_profile.py: 从 journal 自动更新用户画像 + 心迹事件检测 - check_cares.py: 牵挂到期检查 + 温暖飞书推送 - daemon.py v2.1: 集成三库(心迹注入deep_think context、每小时牵挂检查、6h画像更新) - cronjob: 每日9:00牵挂提醒 + 每日21:00牵挂清单 - 数据目录: ~/.hermes/soulful/ 版本: 1.0.0
This commit is contained in:
parent
29bcd7a2f1
commit
82ee9bf176
|
|
@ -0,0 +1,168 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
check_cares.py — 牵挂检查与飞书提醒
|
||||
==================================
|
||||
用法:
|
||||
python3 check_cares.py # 检查并推送今天到期的牵挂
|
||||
python3 check_cares.py --list # 仅列出,不推送
|
||||
python3 check_cares.py --daily # 每日21:00日报模式:列出所有pending
|
||||
|
||||
依赖:soulful_core.py(同目录)
|
||||
"""
|
||||
|
||||
import json, os, sys, urllib.request, urllib.error
|
||||
from datetime import datetime, timezone
|
||||
|
||||
HERMES = os.path.expanduser("~/.hermes")
|
||||
D = HERMES + "/soulful"
|
||||
|
||||
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/65c3ce80-710f-4415-b2ea-d69d87b5c18e"
|
||||
|
||||
|
||||
def send_feishu(title: str, content: str, color: str = "green"):
|
||||
"""发送飞书消息"""
|
||||
color_map = {
|
||||
"green": ("34", "197", "94"),
|
||||
"yellow": ("255", "185", "0"),
|
||||
"red": ("255", "65", "54"),
|
||||
"blue": ("0", "122", "255"),
|
||||
"purple": ("128", "90", "213"),
|
||||
}
|
||||
r, g, b = color_map.get(color, color_map["blue"])
|
||||
payload = {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": f"🎗️ {title}"},
|
||||
"template": color,
|
||||
},
|
||||
"elements": [
|
||||
{"tag": "div", "content": {"tag": "lark_md", "content": content}},
|
||||
],
|
||||
},
|
||||
}
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
FEISHU_WEBHOOK,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read())
|
||||
except Exception as e:
|
||||
print(f" 飞书发送失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def generate_warm_text(care: dict, index: int = 1) -> str:
|
||||
"""为牵挂生成温暖的自然语言描述"""
|
||||
content = care.get("content", "")
|
||||
context = care.get("context", "")
|
||||
reminder_count = care.get("reminder_count", 0)
|
||||
follow_up_date = care.get("follow_up_date", "")
|
||||
tags = care.get("tags", [])
|
||||
|
||||
# 语气选择
|
||||
if reminder_count == 0:
|
||||
opener = f"你之前说过"
|
||||
elif reminder_count == 1:
|
||||
opener = f"上次提醒过一次了,还是想问一下"
|
||||
else:
|
||||
opener = f"已经提醒 {reminder_count} 次了,这件重要的事"
|
||||
|
||||
# 标签辅助语气
|
||||
tag_hints = {
|
||||
"工作": "关于工作的事",
|
||||
"生活": "生活里的小事",
|
||||
"学习": "学习方面",
|
||||
"健康": "身体是革命的本钱",
|
||||
}
|
||||
tag_hint = ""
|
||||
for t in tags:
|
||||
if t in tag_hints:
|
||||
tag_hint = tag_hints[t]
|
||||
break
|
||||
|
||||
# 组合文案
|
||||
parts = [opener]
|
||||
if tag_hint:
|
||||
parts.append(tag_hint)
|
||||
parts.append(f"「{content}」")
|
||||
if context and len(context) > 5:
|
||||
parts.append(f"(你说的是:{context[:40]})")
|
||||
if reminder_count > 0:
|
||||
parts.append(f"——依然放在心上")
|
||||
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
def format_daily_report(cares: list) -> str:
|
||||
"""格式化每日牵挂清单"""
|
||||
if not cares:
|
||||
return '今天没有待提醒的牵挂 ✓\n\n你可以说"记得提醒我做XXX",我会帮你记住并准时提醒。'
|
||||
|
||||
lines = [f"📋 今天有 **{len(cares)}** 件牵挂:\n"]
|
||||
for i, c in enumerate(cares, 1):
|
||||
follow_up = c.get("follow_up_date", "")
|
||||
content = c.get("content", "")
|
||||
context = c.get("context", "")
|
||||
lines.append(f"{i}. **{content}**")
|
||||
if context:
|
||||
lines.append(f" 💬 {context[:50]}")
|
||||
lines.append(f"\n完成记得告诉我,我会帮你划掉。")
|
||||
lines.append('\n_说"做完XXX"即可,我会帮你记录。_')
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
list_only = "--list" in sys.argv
|
||||
daily = "--daily" in sys.argv
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from soulful_core import CaresQueue
|
||||
|
||||
cq = CaresQueue()
|
||||
|
||||
if daily:
|
||||
# 每日 21:00 模式:显示所有 pending
|
||||
pending = cq.pending()
|
||||
if not pending:
|
||||
print("无 pending 牵挂")
|
||||
return
|
||||
|
||||
report = format_daily_report(pending)
|
||||
print(report)
|
||||
if not list_only:
|
||||
send_feishu("今日牵挂清单", report, "blue")
|
||||
return
|
||||
|
||||
# 默认:检查今天到期的
|
||||
due = cq.today_check()
|
||||
|
||||
if not due:
|
||||
print("没有今天到期的牵挂 ✓")
|
||||
return
|
||||
|
||||
print(f"发现 {len(due)} 条到期牵挂:")
|
||||
for i, c in enumerate(due, 1):
|
||||
print(f" {i}. [{c['id']}] {c['content']}")
|
||||
|
||||
if list_only:
|
||||
return
|
||||
|
||||
# 发送飞书
|
||||
for care in due:
|
||||
text = generate_warm_text(care)
|
||||
print(f"\n推送: {text[:60]}...")
|
||||
result = send_feishu("你有一件事一直放在心上", text, "purple")
|
||||
if result and result.get("code") == 0:
|
||||
# 更新 reminder_count
|
||||
cq.snooze(care["id"], days=0) # 只增加 reminder_count,不推日期
|
||||
print(" ✓ 已推送")
|
||||
else:
|
||||
print(f" ✗ 推送失败: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -21,6 +21,7 @@ PID_FILE = D + "/daemon.pid"
|
|||
LIGHT_INTERVAL = 30
|
||||
DEEP_INTERVAL = 300
|
||||
JOURNAL_MAX = 200
|
||||
PROFILE_UPDATE_INTERVAL = 21600 # 6 hours
|
||||
|
||||
API = "http://127.0.0.1:3000/v1"
|
||||
KEY = "0ExNiLblJvIWBDpkS50fwOBw4MmqLyKdHJK5iQtlw9dOMWBP"
|
||||
|
|
@ -30,6 +31,9 @@ FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/65c3ce80-710f-441
|
|||
|
||||
_stop_event = threading.Event()
|
||||
|
||||
# Lazy-loaded soulful modules (avoid import at module load time)
|
||||
_soulful_cache = {}
|
||||
|
||||
|
||||
# ====== 工具 ======
|
||||
|
||||
|
|
@ -77,6 +81,94 @@ def send_feishu(title, content, color="blue"):
|
|||
except: return False
|
||||
|
||||
|
||||
# ====== Soulful 三库(懒加载)======
|
||||
|
||||
def _get_soulful(name):
|
||||
"""Lazy-load soulful modules to avoid startup failures"""
|
||||
if name in _soulful_cache:
|
||||
return _soulful_cache[name]
|
||||
try:
|
||||
sys.path.insert(0, HERMES + "/scripts")
|
||||
mod = __import__("soulful_core")
|
||||
_soulful_cache[name] = mod
|
||||
return mod
|
||||
except Exception as e:
|
||||
log(f"⚠️ soulful_core 导入失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_hearttraces():
|
||||
mod = _get_soulful("HeartTraces")
|
||||
if mod:
|
||||
return mod.HeartTraces()
|
||||
return None
|
||||
|
||||
|
||||
def get_userprofile():
|
||||
mod = _get_soulful("UserProfile")
|
||||
if mod:
|
||||
return mod.UserProfile()
|
||||
return None
|
||||
|
||||
|
||||
def get_caresqueue():
|
||||
mod = _get_soulful("CaresQueue")
|
||||
if mod:
|
||||
return mod.CaresQueue()
|
||||
return None
|
||||
|
||||
|
||||
def soulful_get_recent_moments(n=3):
|
||||
"""读取最近 N 条心迹,返回字符串供注入 context"""
|
||||
ht = get_hearttraces()
|
||||
if not ht:
|
||||
return ""
|
||||
moments = ht.recent(n=n)
|
||||
if not moments:
|
||||
return ""
|
||||
lines = ["\n[心迹 - 记忆我们之间的事]:"]
|
||||
for m in moments:
|
||||
stars = "★" * m.get("importance", 3)
|
||||
lines.append(f" {stars} {m.get('content', '')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def soulful_check_cares():
|
||||
"""检查牵挂队列,推送到期提醒(飞书)"""
|
||||
cq = get_caresqueue()
|
||||
if not cq:
|
||||
return
|
||||
due = cq.today_check()
|
||||
if not due:
|
||||
return
|
||||
for care in due:
|
||||
reminder_count = care.get("reminder_count", 0)
|
||||
if reminder_count == 0:
|
||||
opener = "你之前说过"
|
||||
elif reminder_count == 1:
|
||||
opener = "上次提醒过一次了,还是想问一下"
|
||||
else:
|
||||
opener = f"已经提醒 {reminder_count} 次了,这件重要的事"
|
||||
content = care.get("content", "")
|
||||
context = care.get("context", "")
|
||||
text = opener + f",「{content}」"
|
||||
if context and len(context) > 5:
|
||||
text += f"(你说的是:{context[:40]})"
|
||||
send_feishu("🎗️ 你有一件事一直放在心上", text, "purple")
|
||||
# 只增加 reminder_count,不推日期
|
||||
cq.snooze(care["id"], days=0)
|
||||
|
||||
|
||||
def soulful_update_profile():
|
||||
"""间接调用 update_profile.py"""
|
||||
script = HERMES + "/scripts/update_profile.py"
|
||||
if not os.path.exists(script):
|
||||
return
|
||||
rc, out, err = shell(f"python3 {script}", timeout=60)
|
||||
if rc == 0:
|
||||
log(f" 画像更新: {out[:80]}")
|
||||
|
||||
|
||||
# ====== 方案库 ======
|
||||
|
||||
def load_solutions():
|
||||
|
|
@ -355,6 +447,12 @@ def deep_think(ctx, state, changes, journal, solutions_lib):
|
|||
context += f" [{e['type']}] {e['summary']}\n"
|
||||
|
||||
context += f"\n运行: {ctx.get('uptime_seconds',0)//60}分钟 | 深度思考: {ctx.get('deep_tick_count',0)}次 | 已解决: {ctx.get('solved_count',0)}个"
|
||||
|
||||
# 心迹注入
|
||||
moments_str = soulful_get_recent_moments(n=3)
|
||||
if moments_str:
|
||||
context += "\n" + moments_str
|
||||
|
||||
context += ref_context
|
||||
|
||||
result, tokens = call_llm(FAST_MODEL, DEEP_SYSTEM, context, max_tokens=500)
|
||||
|
|
@ -572,6 +670,18 @@ def main_loop():
|
|||
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)
|
||||
|
||||
# ====== Soulful 轻量集成(每小时一次)======
|
||||
# 每 120 个 light_tick(约 1 小时)检查一次牵挂 + 更新画像
|
||||
if ctx["tick_count"] % 120 == 0:
|
||||
soulful_check_cares()
|
||||
if "last_profile_update" not in ctx:
|
||||
ctx["last_profile_update"] = 0
|
||||
now_ts = time.time()
|
||||
if now_ts - ctx.get("last_profile_update", 0) >= PROFILE_UPDATE_INTERVAL:
|
||||
ctx["last_profile_update"] = now_ts
|
||||
soulful_update_profile()
|
||||
|
||||
time.sleep(LIGHT_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,390 @@
|
|||
#!/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
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
HERMES = os.path.expanduser("~/.hermes")
|
||||
D = HERMES + "/soulful" # 所有数据放 soulful/ 子目录
|
||||
|
||||
# ===== 心迹库 =====
|
||||
|
||||
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")
|
||||
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")
|
||||
|
||||
|
||||
# ===== 用户画像 =====
|
||||
|
||||
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
|
||||
from datetime import timedelta
|
||||
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(timezone.utc).strftime("%Y-%m-%d")
|
||||
yesterday = (datetime.now(timezone.utc).replace(hour=0, minute=0, second=0) -
|
||||
__import__('datetime').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 入口)=====
|
||||
|
||||
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("--type", "-t", 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']}")
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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]]()
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
update_profile.py — 从 journal 增量更新用户画像
|
||||
=========================================
|
||||
用法:
|
||||
python3 update_profile.py # 增量更新
|
||||
python3 update_profile.py --full # 全量重新分析
|
||||
python3 update_profile.py --dry # 只输出,不写入
|
||||
|
||||
依赖:soulful_core.py(同目录)
|
||||
"""
|
||||
|
||||
import json, os, re, sys
|
||||
from datetime import datetime, timezone
|
||||
from collections import Counter
|
||||
|
||||
HERMES = os.path.expanduser("~/.hermes")
|
||||
D = HERMES + "/soulful"
|
||||
JOURNAL_FILE = HERMES + "/daemon/journal.jsonl"
|
||||
|
||||
# 情绪词库
|
||||
EMOTION_WORDS = {
|
||||
"positive": ["开心", "高兴", "顺利", "棒", "完美", "感谢", "不错", "很好", "解决了", "成功了", "搞定", "舒服"],
|
||||
"negative": ["累", "困", "压力", "焦虑", "沮丧", "失望", "崩溃", "烦", "头疼", "难", "搞不定", "失败", "错误"],
|
||||
"focus": ["专注", "忙", "赶", "deadline", "紧急", "重要"],
|
||||
}
|
||||
|
||||
# 目标关键词
|
||||
GOAL_KEYWORDS = ["研究", "做", "改", "修复", "完成", "测试", "上线", "调研", "计划", "推进", "同步"]
|
||||
|
||||
# 沟通风格判断(基于句长)
|
||||
def detect_communication_style(summaries: list) -> str:
|
||||
if not summaries:
|
||||
return "简洁"
|
||||
total_len = sum(len(s) for s in summaries)
|
||||
avg_len = total_len / len(summaries)
|
||||
if avg_len < 30:
|
||||
return "简洁直接"
|
||||
elif avg_len < 60:
|
||||
return "简洁"
|
||||
else:
|
||||
return "详细"
|
||||
|
||||
|
||||
def load_journal(n: int = 100) -> list:
|
||||
"""加载最近 N 条 journal 条目"""
|
||||
if not os.path.exists(JOURNAL_FILE):
|
||||
return []
|
||||
with open(JOURNAL_FILE) as f:
|
||||
lines = f.readlines()
|
||||
entries = [json.loads(l) for l in lines[-n:] if l.strip()]
|
||||
return entries[::-1] # 倒序,最新的在前
|
||||
|
||||
|
||||
def extract_goals(entries: list) -> list:
|
||||
"""从 journal 中提取当前目标(通过关键词+主题判断)"""
|
||||
goals = []
|
||||
recent_topics = []
|
||||
for e in entries:
|
||||
summary = e.get("summary", "")
|
||||
event_type = e.get("type", "")
|
||||
# 任务完成类型的事件通常是目标
|
||||
if event_type in ("solve", "learn", "skill", "config"):
|
||||
# 提取关键动作
|
||||
if "织忆" in summary:
|
||||
goals.append("织忆系统优化")
|
||||
if "ColaOS" in summary or "page-agent" in summary:
|
||||
goals.append("ColaOS研究")
|
||||
if "skill" in summary.lower():
|
||||
goals.append("技能管理")
|
||||
if "daemon" in summary.lower():
|
||||
goals.append("Daemon自动化")
|
||||
recent_topics.append(summary[:50])
|
||||
# 去重保留顺序
|
||||
seen = set()
|
||||
unique_goals = []
|
||||
for g in goals:
|
||||
if g not in seen:
|
||||
seen.add(g)
|
||||
unique_goals.append(g)
|
||||
return unique_goals[:5]
|
||||
|
||||
|
||||
def extract_emotional_state(entries: list) -> tuple:
|
||||
"""从 journal 中判断情绪状态"""
|
||||
pos_count = 0
|
||||
neg_count = 0
|
||||
notes = []
|
||||
for e in entries[-20:]: # 只看最近20条
|
||||
summary = e.get("summary", "")
|
||||
for w in EMOTION_WORDS["positive"]:
|
||||
if w in summary:
|
||||
pos_count += 1
|
||||
for w in EMOTION_WORDS["negative"]:
|
||||
if w in summary:
|
||||
neg_count += 1
|
||||
notes.append(summary[:60])
|
||||
if neg_count > pos_count * 2:
|
||||
state = "tired"
|
||||
elif pos_count > neg_count:
|
||||
state = "good"
|
||||
else:
|
||||
state = "neutral"
|
||||
return state, notes[-3:]
|
||||
|
||||
|
||||
def detect_events_for_hearttraces(entries: list) -> list:
|
||||
"""从 journal 中检测值得写入心迹的事件"""
|
||||
events = []
|
||||
for e in entries:
|
||||
summary = e.get("summary", "")
|
||||
# 检测重要共同经历
|
||||
if "织忆" in summary and "修好" in summary:
|
||||
events.append({
|
||||
"content": "我们一起修好了织忆失忆问题(2026-06-25)",
|
||||
"tags": ["成长", "织忆", "共同经历"],
|
||||
"importance": 5,
|
||||
"type": "moment"
|
||||
})
|
||||
if "ColaOS" in summary and "研究" in summary:
|
||||
events.append({
|
||||
"content": "牧尘开始研究 ColaOS,寻找织忆 Soulful 改造方向",
|
||||
"tags": ["ColaOS", "研究"],
|
||||
"importance": 4,
|
||||
"type": "moment"
|
||||
})
|
||||
# 检测情绪表达
|
||||
for w in EMOTION_WORDS["negative"]:
|
||||
if w in summary:
|
||||
events.append({
|
||||
"content": f"牧尘表达了{w}:{summary[:40]}",
|
||||
"tags": ["情绪", w],
|
||||
"importance": 3,
|
||||
"type": "signal"
|
||||
})
|
||||
break
|
||||
return events
|
||||
|
||||
|
||||
def main():
|
||||
dry = "--dry" in sys.argv
|
||||
full = "--full" in sys.argv
|
||||
|
||||
print(f"[update_profile] {'[DRY]' if dry else ''} 开始更新用户画像...")
|
||||
|
||||
# 加载 journal
|
||||
entries = load_journal(200 if full else 100)
|
||||
print(f" 读取 journal: {len(entries)} 条")
|
||||
if not entries:
|
||||
print(" 无 journal 数据,退出")
|
||||
return
|
||||
|
||||
# 提取各项
|
||||
summaries = [e.get("summary", "") for e in entries]
|
||||
style = detect_communication_style(summaries)
|
||||
goals = extract_goals(entries)
|
||||
emotion, emotion_notes = extract_emotional_state(entries)
|
||||
|
||||
print(f" 沟通风格: {style}")
|
||||
print(f" 当前目标: {goals}")
|
||||
print(f" 情绪状态: {emotion}")
|
||||
|
||||
if dry:
|
||||
print(" [DRY] 不写入,仅输出")
|
||||
return
|
||||
|
||||
# 更新画像
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from soulful_core import UserProfile, HeartTraces
|
||||
|
||||
up = UserProfile()
|
||||
|
||||
# 更新沟通风格
|
||||
if not up.get().get("communication_style") or full:
|
||||
up.update({"communication_style": style})
|
||||
|
||||
# 更新目标(合并去重)
|
||||
if goals:
|
||||
existing_goals = up.get().get("current_goals", [])
|
||||
merged = list(dict.fromkeys(existing_goals + goals))[:10]
|
||||
up.update({"current_goals": merged})
|
||||
|
||||
# 更新情绪(只有更严重才降级,不轻易升级)
|
||||
current = up.get().get("emotional_state", {}).get("current", "unknown")
|
||||
if emotion == "tired" or current in ("tired",):
|
||||
up.set_emotional_state(emotion, "; ".join(emotion_notes[:2]) if emotion_notes else "")
|
||||
|
||||
print(f" ✓ 画像已更新")
|
||||
|
||||
# 检测心迹事件
|
||||
ht = HeartTraces()
|
||||
current_count = ht.count()
|
||||
events = detect_events_for_hearttraces(entries)
|
||||
|
||||
# 写入新心迹(只写入之前没有的)
|
||||
for ev in events:
|
||||
# 简单去重:检查最近 10 条是否有相同内容
|
||||
recent = ht.recent(n=10)
|
||||
duplicate = any(ev["content"][:30] in r.get("content", "") for r in recent)
|
||||
if not duplicate:
|
||||
ht.record(ev["content"], ev["tags"], ev["importance"], trace_type=ev["type"])
|
||||
print(f" ✦ 心迹: {ev['content'][:50]}")
|
||||
|
||||
new_count = ht.count()
|
||||
if new_count > current_count:
|
||||
print(f" ✓ 心迹新增 {new_count - current_count} 条")
|
||||
else:
|
||||
print(f" ✓ 无新增心迹({new_count} 条)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"cares": [
|
||||
{
|
||||
"id": "41280638",
|
||||
"content": "记得同步文档到Obsidian",
|
||||
"context": "牧尘提到",
|
||||
"created_at": "2026-07-09T09:50:58.779920+00:00",
|
||||
"follow_up_date": "2026-07-10",
|
||||
"status": "pending",
|
||||
"reminder_count": 0,
|
||||
"last_reminded": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"id": "5a1465eb",
|
||||
"content": "记得测试牵挂功能",
|
||||
"context": "",
|
||||
"created_at": "2026-07-09T09:51:04.369800+00:00",
|
||||
"follow_up_date": "2026-07-09",
|
||||
"status": "pending",
|
||||
"reminder_count": 0,
|
||||
"last_reminded": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"id": "69396b42",
|
||||
"content": "记得同步文档到Obsidian",
|
||||
"context": "牧尘提到",
|
||||
"created_at": "2026-07-09T13:29:29.382393+00:00",
|
||||
"follow_up_date": "2026-07-10",
|
||||
"status": "pending",
|
||||
"reminder_count": 0,
|
||||
"last_reminded": null,
|
||||
"tags": []
|
||||
}
|
||||
],
|
||||
"updated_at": "2026-07-09T13:29:29.382681+00:00"
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{"id": "eabf352f", "timestamp": "2026-07-09T09:50:50.730036+00:00", "session_id": "default", "type": "moment", "content": "我们一起修好了织忆", "tags": ["成长"], "importance": 5}
|
||||
{"id": "d945a6aa", "timestamp": "2026-07-09T13:29:22.581218+00:00", "session_id": "default", "type": "moment", "content": "我们一起修好了织忆", "tags": ["成长", "织忆"], "importance": 5}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"version": 1,
|
||||
"updated_at": "2026-07-09T09:50:49.264544+00:00",
|
||||
"communication_style": "简洁直接",
|
||||
"work_patterns": {
|
||||
"peak_hours": [],
|
||||
"focus_issues": []
|
||||
},
|
||||
"preferences": {},
|
||||
"habits": {},
|
||||
"important_people": [],
|
||||
"current_goals": [],
|
||||
"recent_frustrations": [],
|
||||
"emotional_state": {
|
||||
"current": "unknown",
|
||||
"notes": []
|
||||
},
|
||||
"first_contact": "2026-07-09T09:50:49.264429+00:00"
|
||||
}
|
||||
Loading…
Reference in New Issue