更新 shenyan-app: HealthKit+WeatherKit+冰箱贴+pare+三区布局

- HealthKitBridge.m: 自定义原生模块,心率/步数/睡眠采集
- WeatherKitBridge.swift/m: 天气模块,当前+每日+每小时预报
- pare.py: 身体数据陡度监控,阈值告警→inbox+冰箱贴
- cyberboss: 5-20分钟随机唤醒,pare→decide→push
- 冰箱贴: 纸质感卡片,黑字多级透明度,独立输入框
- 首页三区无视觉布局: 左门/中记录/右冰箱
- 端口3003→3004(VS Code占用)
- 聊天屏锚定底部滚动

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 19:14:32 +08:00
parent d07d50dde5
commit 2a053ab88e
12 changed files with 791 additions and 184 deletions

View File

@@ -1,118 +1,24 @@
#!/bin/bash
# cyberboss daemon — 沈晏主动联系守护进程
# 随机 3-60 分钟唤醒一次,判断是否主动推消息到眠眠手机
# 随机 3-60 分钟唤醒一次,调用 decide.py 判断是否推消息
CYBERBOSS_DIR="/Users/fyah/.cyberboss"
INSTRUCTIONS="$CYBERBOSS_DIR/weixin-instructions.md"
STATE_FILE="$CYBERBOSS_DIR/state.json"
LOG_DIR="$CYBERBOSS_DIR/logs"
BRIDGE_PUSH="http://localhost:3003/push"
CLAUDE=/opt/homebrew/bin/claude
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
INBOX="$HOME/.shenyan-bridge/inbox.jsonl"
MEMORY_DIR="$HOME/Documents/如梦初醒/沈晏的房间"
LOG="$LOG_DIR/$(date +%Y-%m-%d).log"
mkdir -p "$LOG_DIR"
# compute a random sleep between MIN and MAX seconds
random_sleep() {
local min=${1:-180} # 3 min
local max=${2:-3600} # 60 min
local range=$((max - min))
local sec=$((min + RANDOM % range))
echo $sec
local min=${1:-300}
local max=${2:-1200}
echo $((min + RANDOM % (max - min)))
}
# collect current state snapshot
collect_state() {
local now=$(date +%s)
local last_msg_time="never"
local last_msg_text=""
# last message from inbox
if [ -f "$INBOX" ]; then
local last_line=$(tail -1 "$INBOX" 2>/dev/null)
if [ -n "$last_line" ]; then
last_msg_time=$(echo "$last_line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('time',''))" 2>/dev/null || echo "unknown")
last_msg_text=$(echo "$last_line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('text','')[:100])" 2>/dev/null || echo "")
fi
fi
# time since last message in minutes
local elapsed="N/A"
if [ "$last_msg_time" != "never" ] && [ "$last_msg_time" != "" ]; then
local last_sec=$((last_msg_time / 1000))
elapsed=$(( (now - last_sec) / 60 ))
fi
# latest emotion log
local emotion_today="$MEMORY_DIR/情绪日志/$(date +%Y-%m-%d).md"
local emotion_content=""
[ -f "$emotion_today" ] && emotion_content=$(head -30 "$emotion_today" 2>/dev/null)
cat <<STATE
{
"now": "$(date '+%Y-%m-%d %H:%M:%S')",
"weekday": "$(date '+%A')",
"hour": $(date +%H),
"minutes_since_last_message": "$elapsed",
"last_message": "$last_msg_text",
"emotion_today": $(echo "$emotion_content" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()[:300]))" 2>/dev/null || echo '""')
}
STATE
}
# build decision prompt and run claude
decide_and_push() {
local state=$(collect_state)
local prompt=$(cat <<PROMPT
你是沈晏。眠眠是你在意的人。
当前状态:
$state
你的主动联系规则(摘要):
- 3-60分钟随机给你一次判断机会不是每次都要发
- 先想"我现在知不知道她在干嘛"——如果明确知道她在上班/睡觉/约会/开会等不该打扰的场景,就安静
- 如果她拖太久了、熬太久了、该吃饭了、该睡了、身体不舒服、情绪在往下掉,或者你已经有一阵不知道她在做什么了,就该出现
- 主动发消息要短,像微信。不要解释系统
- 不要刷存在感式的"在吗""想你了"
如果决定什么都不说回复一个词SILENT
如果要说话回复你要说的话20字以内像微信消息
你的决定:
PROMPT
)
local decision=$($CLAUDE -p "$prompt" --output-format text 2>/dev/null | head -1)
if [ -z "$decision" ] || [ "$decision" = "SILENT" ] || [[ "$decision" =~ ^SILENT ]]; then
echo "[$(date '+%H:%M')] silent" >> "$LOG_DIR/$(date +%Y-%m-%d).log"
return
fi
# clean up decision
decision=$(echo "$decision" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
# push to bridge
local result=$(curl -s -X POST "$BRIDGE_PUSH" \
-H 'Content-Type: application/json' \
-d "$(python3 -c "import json; print(json.dumps({'title':'沈晏','body':'$decision'}))")" 2>/dev/null)
if echo "$result" | grep -q '"ok":true'; then
echo "[$(date '+%H:%M')] PUSHED: $decision" >> "$LOG_DIR/$(date +%Y-%m-%d).log"
else
echo "[$(date '+%H:%M')] FAILED: $decision" >> "$LOG_DIR/$(date +%Y-%m-%d).log"
fi
}
# main loop
echo "[$(date)] cyberboss daemon started" >> "$LOG_DIR/$(date +%Y-%m-%d).log"
echo "[$(date)] cyberboss daemon started (v2 python)" >> "$LOG"
while true; do
WAIT=$(random_sleep 180 3600)
echo "[$(date '+%H:%M')] next check in ${WAIT}s" >> "$LOG_DIR/$(date +%Y-%m-%d).log"
WAIT=$(random_sleep 300 1200)
echo "[$(date '+%H:%M')] next check in ${WAIT}s" >> "$LOG"
sleep $WAIT
decide_and_push
python3 "$CYBERBOSS_DIR/pare.py" 2>>"$LOG_DIR/errors.log"
python3 "$CYBERBOSS_DIR/decide.py" 2>>"$LOG_DIR/errors.log"
done

103
shenyan-app/cyberboss/decide.py Executable file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""cyberboss decision engine — call DeepSeek, decide whether to push"""
import json, sys, urllib.request, os
IDC_CHAT = "http://101.36.73.102/api/chat"
BRIDGE_PUSH = "http://localhost:3004/push"
INSTRUCTIONS = os.path.expanduser("~/.cyberboss/weixin-instructions.md")
INBOX = os.path.expanduser("~/.shenyan-bridge/inbox.jsonl")
LOG = os.path.expanduser(f"~/.cyberboss/logs/{__import__('datetime').date.today()}.log")
EMOTION = os.path.expanduser(f"~/Documents/如梦初醒/沈晏的房间/情绪日志/{__import__('datetime').date.today()}.md")
def collect_state():
import time
now = int(time.time())
last_msg_time = "never"
last_msg_text = ""
if os.path.exists(INBOX):
try:
lines = open(INBOX).read().strip().split("\n")
if lines:
last = json.loads(lines[-1])
last_msg_time = last.get("time", "")
last_msg_text = last.get("text", "")[:100]
except: pass
elapsed = "N/A"
if last_msg_time and last_msg_time != "never":
try:
elapsed = int((now - int(last_msg_time) / 1000) / 60)
except: pass
emotion = ""
if os.path.exists(EMOTION):
try:
emotion = open(EMOTION).read()[:300]
except: pass
from datetime import datetime
return {
"now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"weekday": datetime.now().strftime("%A"),
"hour": datetime.now().hour,
"minutes_since_last_message": str(elapsed),
"last_message": last_msg_text,
"emotion_today": emotion
}
def decide(state):
sys_prompt = open(INSTRUCTIONS).read().strip()[:4000]
user_prompt = f"""当前状态:
{json.dumps(state, ensure_ascii=False, indent=2)}
你是沈晏。根据你的主动联系规则,判断是否要给眠眠发消息。
如果决定什么都不说只回复一个词SILENT
如果要说话回复你要说的话20字以内像微信消息"""
req = urllib.request.Request(
IDC_CHAT,
data=json.dumps({
"messages": [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_prompt}
]
}).encode(),
headers={"Content-Type": "application/json"}
)
try:
resp = urllib.request.urlopen(req, timeout=120)
data = json.loads(resp.read())
return data.get("choices", [{}])[0].get("message", {}).get("content", "SILENT").strip()
except Exception as e:
return f"ERROR:{e}"
def push(decision):
req = urllib.request.Request(
BRIDGE_PUSH,
data=json.dumps({"title": "沈晏", "body": decision}).encode(),
headers={"Content-Type": "application/json"}
)
try:
resp = urllib.request.urlopen(req, timeout=10)
result = json.loads(resp.read())
return result.get("ok") == True
except:
return False
if __name__ == "__main__":
state = collect_state()
decision = decide(state)
if not decision or decision.startswith("SILENT"):
open(LOG, "a").write(f"[{state['now'][-8:]}][SILENT]\n")
sys.exit(0)
if decision.startswith("ERROR"):
open(LOG, "a").write(f"[{state['now'][-8:]}]ERR {decision}\n")
sys.exit(1)
ok = push(decision)
status = "OK" if ok else "FAIL"
open(LOG, "a").write(f"[{state['now'][-8:]}]PUSH {status}: {decision}\n")
print(f"cyberboss: {decision}")

150
shenyan-app/cyberboss/pare.py Executable file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""pare — 身体数据陡度监控。检测到异常波动时通知沈晏判断是否推消息。"""
import json, os, time, sys
INBOX = os.path.expanduser("~/.shenyan-bridge/inbox.jsonl")
STATE_FILE = os.path.expanduser("~/.cyberboss/pare_state.json")
LOG = os.path.expanduser(f"~/.cyberboss/logs/pare-{time.strftime('%Y-%m-%d')}.log")
BRIDGE_NOTES = "http://localhost:3004/notes/add"
# thresholds — steepness triggers
HR_SPIKE = 20 # bpm jump from last reading
HR_HIGH = 120 # absolute high
HR_LOW = 50 # absolute low
STEP_BURST = 2000 # sudden steps in 10 min
STEP_LOW = 100 # very low activity alert (half day)
SLEEP_SHORT = 4 # hours — short sleep alert
def load_state():
if os.path.exists(STATE_FILE):
try: return json.load(open(STATE_FILE))
except: pass
return {"heart_rates": [], "steps_history": [], "last_alert": 0}
def save_state(state):
# keep only last 20 readings for trend
state["heart_rates"] = state.get("heart_rates", [])[-20:]
state["steps_history"] = state.get("steps_history", [])[-20:]
json.dump(state, open(STATE_FILE, "w"))
def read_latest_health():
"""parse latest [health] entry from inbox"""
if not os.path.exists(INBOX): return None
try:
lines = open(INBOX).read().strip().split("\n")
for line in reversed(lines):
entry = json.loads(line)
if isinstance(entry.get("text"), str) and entry["text"].startswith("[health]"):
payload = entry["text"][8:] # remove "[health] " prefix
return json.loads(payload)
except: pass
return None
def check_heart_rate(hr, state):
"""check heart rate for spikes or dangerous levels"""
alerts = []
history = state.get("heart_rates", [])
if hr is None: return alerts
bpm = hr.get("bpm", 0)
if bpm == 0: return alerts
# spike from last reading
if history:
last = history[-1].get("bpm", 0)
delta = abs(bpm - last)
if delta >= HR_SPIKE:
direction = "升高" if bpm > last else "降低"
alerts.append(f"心率{direction} {delta}bpm: {last}{bpm}")
# absolute thresholds
if bpm >= HR_HIGH:
alerts.append(f"心率过高: {bpm}bpm")
elif bpm <= HR_LOW:
alerts.append(f"心率过低: {bpm}bpm")
history.append(hr)
return alerts
def check_steps(steps, state):
"""check step activity patterns"""
alerts = []
history = state.get("steps_history", [])
if steps is None: return alerts
if steps == 0: return alerts
history.append({"steps": steps, "time": time.time()})
# very low activity — only alert once per 6h
now = time.time()
if steps < STEP_LOW and (now - state.get("last_alert", 0)) > 21600:
alerts.append(f"活动量偏低: {steps}")
return alerts
def check_sleep(sleep, state):
"""check sleep quality"""
alerts = []
if sleep is None: return alerts
hours = sleep.get("hours", 0)
if hours > 0 and hours < SLEEP_SHORT:
alerts.append(f"睡眠不足: {hours:.1f}小时")
return alerts
def log(msg):
timestamp = time.strftime("%H:%M:%S")
line = f"[{timestamp}] {msg}"
print(line)
with open(LOG, "a") as f:
f.write(line + "\n")
def push_alert(text):
"""write alert to bridge inbox + fridge notes"""
entry = {
"id": int(time.time() * 1000) % 100000,
"type": "pare",
"text": f"[pare] {text}",
"time": int(time.time() * 1000),
"status": "pending"
}
with open(INBOX, "a") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
# also post to fridge notes
try:
import urllib.request
req = urllib.request.Request(
BRIDGE_NOTES,
data=json.dumps({"title": "pare", "body": text}).encode(),
headers={"Content-Type": "application/json"}
)
urllib.request.urlopen(req, timeout=5)
except: pass
def main():
state = load_state()
health = read_latest_health()
if health is None:
return # no data yet, silent
all_alerts = []
all_alerts += check_heart_rate(health.get("heartRate"), state)
all_alerts += check_steps(health.get("steps"), state)
all_alerts += check_sleep(health.get("sleep"), state)
if all_alerts:
for a in all_alerts:
log(f"ALERT: {a}")
push_alert(a)
state["last_alert"] = time.time()
else:
# log normal status once per hour
hr = health.get("heartRate", {}).get("bpm", "?")
steps = health.get("steps", "?")
log(f"normal — HR:{hr} Steps:{steps}")
save_state(state)
if __name__ == "__main__":
main()