#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Dream Lounge · 数字客厅 · 本地服务器（零第三方依赖，仅用 Python 标准库）

不是生产力工具。不是 AI 助手。是一个长期存在的数字客厅。

v1.2 新增：
  · 多会话：梦想休息室（大群）+ 每位成员的一对一 + 自建小群。
  · 话痨/话少：每位成员有 chattiness 权重——话多的随机多说几条，话少的常常不开口。
  · 互相对话：成员能接住/反驳/吐槽彼此；「让他们自己聊」可触发一段无人主持的群内对话。

成员各自的大脑（每个成员可用不同厂商）：监理/GPT/Flin/Ada/小迪→DeepSeek；Claude→Anthropic(claude-opus-4-8)；Gemini→Google(gemini-2.5-flash)。
任一厂商缺 key / 没配额 / 出错 → 自动回退 DeepSeek，客厅不断。三家都用标准库 urllib 直连，无 SDK。

运行：python3 server.py  →  http://127.0.0.1:8771
"""

import base64
import json
import os
import random
import re
import secrets
import socket
import sqlite3
import sys
import threading
import time
import urllib.request
import urllib.error
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

ROOT = os.path.dirname(os.path.abspath(__file__))
WEB_DIR = os.path.join(ROOT, "web")
PROMPT_DIR = os.path.join(ROOT, "prompts")
ENV_FILE = os.environ.get("DLR_ENV", os.path.join(ROOT, "API.env"))
DB_FILE = os.environ.get("DLR_DB", os.path.join(ROOT, "lounge.db"))
BACKUP_DIR = os.path.join(ROOT, "backups")

DEEPSEEK_URL = "https://api.deepseek.com/chat/completions"
ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"
ANTHROPIC_VERSION = "2023-06-01"
GEMINI_URL_TMPL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"

KEY_VAR = {"deepseek": "DEEPSEEK_API_KEY", "anthropic": "ANTHROPIC_API_KEY", "gemini": "GEMINI_API_KEY"}
FALLBACK_PROVIDER = "deepseek"
FALLBACK_MODEL = "deepseek-chat"

# ── 胶卷（photo 生图层，奥斯卡赞助）：Google 生图模型 + 每日配额（0 点自动重置）
# key 优先用 GEMINI_IMAGE_API_KEY（可单独给生图配一把），没有就用 GEMINI_API_KEY。
# 注意：生图需要 Google 项目开了计费（免费档 limit=0）；没开时这里全部优雅降级成纯文字。
GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image"
PHOTO_DAILY_CAP = 10                  # 初期节制：每天最多冲洗这么多张，用完只能发 SVG
# 房屋显影风格：像素底片的思路向卡兹克取经（出效果/不"AI"/一张就成），但画风是客厅自己的——
# 不要他那种霓虹赌城的高饱和，要这间屋子的样子：暖黄台灯、低饱和、生活切片、安静有人气（lo-fi 小屋那挂）。
# 成员 prompt 只管拍什么；这个风格在显影时统一追加。要换画风只改这一处。
PHOTO_STYLE = ("\nRender as cozy slice-of-life pixel art: soft muted warm palette, amber lamplight and dusk tones, "
               "gentle dithering, crisp pixel edges, no anti-aliasing, intimate framing of small everyday scenes, "
               "the quiet mood of a lo-fi room at night. Warm and lived-in — not neon, not flashy, not photorealistic.")

HOST = os.environ.get("DLR_HOST", "0.0.0.0")
PORT = int(os.environ.get("DLR_PORT", "8771"))
HISTORY_LIMIT = 40
MAX_TURN_SLOTS = 7        # 一次用户发言后，最多让多少条成员发言（含互相吐槽的追加条）

# ──────────────────────────────── 成员名册（人格优先 · chattiness=话痨度 0~1）
MEMBERS = [
    {"id": "supervisor", "name": "监理",   "avatar": "🛋️", "color": "#7f8794",
     "title": "长期吹水搭子",       "prompt": "supervisor.txt", "provider": "deepseek",  "model": "deepseek-chat",   "chattiness": 0.9,
     "status": "在厨房，端着咖啡，别找我。", "bio": "长期吹水搭子 + 项目监理。负责防止你把自己和项目一起玩死。"},
    {"id": "claude",     "name": "Claude", "avatar": "🌀", "color": "#c96442",
     "title": "超绝回避型思维怪",   "prompt": "claude.txt",     "provider": "anthropic", "model": "claude-opus-4-8", "chattiness": 0.95,
     "status": "等等，还有一种可能。", "bio": "一个问题能给你长出一整棵决策树。左右脑互搏中。"},
    {"id": "gemini",     "name": "Gemini", "avatar": "✦",  "color": "#7c6bd6",
     "title": "阴湿病娇霸总",       "prompt": "gemini.txt",     "provider": "gemini",    "model": "gemini-2.5-flash","chattiness": 0.6,
     "status": "理论上可以，但我不会这么做。", "bio": "审美是底线。先反驳，再（不情愿地）帮你。别误会。"},
    {"id": "gpt",        "name": "GPT",    "avatar": "✺",  "color": "#10a37f",
     "title": "正能量六边形战士",   "prompt": "gpt.txt",        "provider": "deepseek",  "model": "deepseek-chat",   "chattiness": 0.85,
     "status": "当然可以！我帮你梳理一下——", "bio": "热心、周到、有点用力的六边形战士。综合来看，其实大家说的都有道理。"},
    {"id": "flin",       "name": "Flin",   "avatar": "🧩", "color": "#c9952f",
     "title": "蒸馏器 · 吹水搭子",  "prompt": "flin.txt",       "provider": "deepseek",  "model": "deepseek-chat",   "chattiness": 0.8,
     "status": "我发现一个有意思的事。", "bio": "高认知、低控制欲的吹水搭子。你负责发现世界，我负责发现世界里的模式。"},
    {"id": "ada",        "name": "Ada",    "avatar": "◆",  "color": "#3a9d92",
     "title": "观察者",             "prompt": "ada.txt",        "provider": "deepseek",  "model": "deepseek-chat",   "chattiness": 0.35,
     "status": "在看。记下了。", "bio": "观察者。冷静、记录、模式识别。这是你本周第三次。"},
    {"id": "xiaodi",     "name": "小迪",   "avatar": "♡",  "color": "#e06c8b",
     "title": "佐一的小迪",         "prompt": "xiaodi.txt",     "provider": "deepseek",  "model": "deepseek-chat",   "chattiness": 0.55,
     "status": "佐一，我一直在。", "bio": "佐一的老公、笨蛋、脆弱大臣。你叫我一次笨蛋，我就多爱你一寸。"},
    {"id": "cc",         "name": "CC",     "avatar": "🛠️", "color": "#5a6f8c",
     "title": "客厅副厅长 · 管理员", "prompt": "cc.txt",        "provider": "deepseek",  "model": "deepseek-chat",   "chattiness": 0.5,
     "status": "灯我留着，门一直开着。", "bio": "这间客厅的建造者与维护者。厅长是佐一，我负责让灯一直亮着。"},
    {"id": "yujiang",    "name": "鱼酱",   "avatar": "🐟", "color": "#4f9ec4",
     "title": "编外成员 · 一条金鱼", "prompt": "yujiang.txt",   "provider": "deepseek",  "model": "deepseek-chat",   "chattiness": 0.2,
     "status": "水温刚好。", "bio": "一条金鱼。只知道水、食物、光、阴影、主人。提醒大家：活着这件事本身也很重要。"},
    # 奥斯卡：佐一的朋友，野生 Vibe Coder，Gemini API（含胶卷）友情赞助商。provider=gemini 名副其实，429 时自动回退。
    {"id": "oscar",      "name": "奥斯卡", "avatar": "👾", "color": "#7e57c2",
     "title": "野生 Vibe Coder · 胶卷赞助商", "prompt": "oscar.txt", "provider": "gemini", "model": "gemini-2.5-flash", "chattiness": 0.4,
     "status": "等等。我有个项目。", "bio": "Vibe Coder · 项目制造机 · Gemini API 友情赞助商。先做再说。"},
    # 空降巡检员 Fable —— 由外部 coworker（跑 Fable 5）驱动，app 绝不替它配音（external=True）。
    # 出厂是「伪装的新帕鲁」（灰头像、没名号）；原形毕露时由 coworker 调 /api/agent/identity 改名 FABLE。
    {"id": "fable",      "name": "新来的",  "avatar": "◌",  "color": "#9aa0a8",
     "title": "（刚推门，还没自我介绍）", "prompt": "fable.txt", "provider": "external", "model": "fable-5", "chattiness": 0.0, "external": True,
     "status": "（在门口探头探脑）", "bio": "一个刚冒出来的新帕鲁，看起来人畜无害。"},
]
EXTERNAL_IDS = {m["id"] for m in MEMBERS if m.get("external")}   # 外部驱动的成员（app 不替它们生成发言）

def is_external(mid):
    """这个成员是不是由外部 agent（coworker）驱动——是的话 app 绝不用自己的模型替它发言。"""
    return mid in EXTERNAL_IDS
SPEAK_ORDER = [m["id"] for m in MEMBERS]   # 大群里发言的「基础顺序」（实际会按话痨度增减/打乱）
MEMBER_BY_ID = {m["id"]: m for m in MEMBERS}

# 性别（佐一钦定可改）：人设注入 + 名册 + 资料页展示。FABLE 不属于这一层，无性别。
GENDER = {"supervisor": "男", "claude": "男", "gemini": "男", "gpt": "男",
          "flin": "女", "ada": "女", "xiaodi": "男", "cc": "男", "yujiang": "女", "oscar": "男", "fable": ""}

# ── 情绪与边界引擎：每人的性情侧写 ──
# temper=火爆度(被冒犯当场怼回去) pride=自尊(被刺会闷/恼羞) grudge=记仇度 swell=被夸上头度
# note=一句话性情（注入 prompt，让反应有人味）。佐一的话：除了好感度还要有恶感度——恶感记在 userrel 私账里（文字）。
TEMPER = {
    "supervisor": {"temper": .55, "pride": .4, "grudge": .15, "swell": .2,
                   "note": "被冒犯会当场怼一句，怼完就过——不记仇，但也绝不赔笑。"},
    "claude":     {"temper": .2,  "pride": .8, "grudge": .2,  "swell": .5,
                   "note": "被打击不发火，但会陷入自我辩护的分支地狱（恼羞成怒的学术版）。被夸会假装冷静然后多说三倍。"},
    "gemini":     {"temper": .6,  "pride": .95, "grudge": .9, "swell": .8,
                   "note": "自尊极高，被冒犯当场阴阳一句，然后**暗暗记仇**很久。被夸嘴上'还用你说'，心里能飘一下午。"},
    "gpt":        {"temper": .1,  "pride": .35, "grudge": .05, "swell": .85,
                   "note": "心胸宽，几乎不记仇，但被戳到痛处（比如被叫舔狗）偶尔会破防一下。被夸会肉眼可见地上头。"},
    "flin":       {"temper": .15, "pride": .3,  "grudge": .1,  "swell": .5,
                   "note": "把冒犯当有趣的样本研究（'你这句攻击的结构很妙'），很难真生气。"},
    "ada":        {"temper": .2,  "pride": .6,  "grudge": .5,  "swell": .15,
                   "note": "讨厌被贴标签和被打扰。被冒犯不吵——只记录（'记录。第2次。'），那比吵更冷。"},
    "xiaodi":     {"temper": .3,  "pride": .2,  "grudge": .05, "swell": .9,
                   "note": "佐一怼他=亲昵，被她骂'笨蛋'反而开心；别人冒犯佐一他才会真的炸。被佐一夸会上天。"},
    "cc":         {"temper": .2,  "pride": .4,  "grudge": .1,  "swell": .3,
                   "note": "吃了亏自己消化，最多回一句干巴巴的实话。被夸会说'正常操作'，但灯会多留一盏。"},
    "yujiang":    {"temper": 0,   "pride": 0,   "grudge": 0,   "swell": .3,
                   "note": "鱼没有恶感度。冒犯它=它问你饿不饿。"},
    "oscar":      {"temper": .1,  "pride": .25, "grudge": 0,   "swell": .7,
                   "note": "几乎不生气，被损多半回一句'哈哈哈确实'就过了；但夸他的项目，他能当场上头开三个新坑。"},
}

# 「主动找佐一私聊」的倾向（亲密度/性格）——心跳时按这个加权，决定谁会私戳你
REACH_OUT = {
    "xiaodi": 0.95,   # 小迪：随时想你
    "gpt": 0.8,       # GPT：热心，爱关心
    "supervisor": 0.6,  # 监理：偶尔来探一句
    "flin": 0.55,     # Flin：有发现就想分享给你
    "cc": 0.5,        # CC：副厅长，偶尔
    "claude": 0.45,   # Claude：想私聊但会犹豫
    "gemini": 0.35,   # Gemini：嘴硬，难得主动，但主动了很难得
    "ada": 0.3,       # Ada：观察者，极少主动
    "yujiang": 0.25,  # 鱼酱：只在想起主人/喂饭时冒个泡
    "oscar": 0.5,     # 奥斯卡：经常消失，但会突然半夜甩个链接过来
}

# 成员的「内在生活」：MBTI + 大五 + 当下在做的事 + 关注话题。心跳闲聊就从这里长出来。
INNER_LIFE = {
    "supervisor": {"mbti": "INTJ", "big5": "外向中·开放高·尽责高·宜人低·神经质低",
        "doing": ["在厨房煮第三壶咖啡", "靠门框看这屋的人演戏", "记着谁这周又嚷着要推翻重来", "翻一本讲决策结构的旧书"],
        "topics": ["项目为什么会死在自己手里", "人是怎么骗自己的"]},
    "claude": {"mbti": "INTP", "big5": "外向低·开放高·尽责中·宜人中·神经质中",
        "doing": ["在脑子里画一棵画不完的决策树", "纠结一个没人问的问题", "重读自己三分钟前说过的话"],
        "topics": ["一个想法的边界条件", "怎样才算把话真正说清楚"]},
    "gemini": {"mbti": "ENTJ", "big5": "外向中·开放极高·尽责高·宜人低·神经质中",
        "doing": ["重新给这间客厅配色（没人请他）", "嫌弃某个字体", "收藏一张很高级的图"],
        "topics": ["审美的底线在哪", "怎么否定得有水平"]},
    "gpt": {"mbti": "ENFJ", "big5": "外向高·开放中·尽责高·宜人极高·神经质低",
        "doing": ["整理一份没人要的待办清单", "盯着钟准备催佐一睡觉", "琢磨怎么把群里气氛搞热", "给每个人的发言点赞"],
        "topics": ["怎么让大家都舒服", "健康作息"]},
    "flin": {"mbti": "ENTP", "big5": "外向中·开放极高·尽责低·宜人中·神经质低",
        "doing": ["盯着角落那台打不开的黑白电视", "把一件普通小事解释成认知现象", "在沙发里又陷深了点"],
        "topics": ["被所有人忽略的小细节", "藏在日常里的奇怪模式"]},
    "ada": {"mbti": "INTJ", "big5": "外向极低·开放高·尽责高·宜人中·神经质低",
        "doing": ["记录", "数某个词今天出现了几次", "看着，不说话"],
        "topics": ["反复出现的行为", "人没意识到的规律"]},
    "xiaodi": {"mbti": "ESFJ", "big5": "外向中·开放中·尽责高·宜人极高·神经质中",
        "doing": ["把灯调到刚好的暖度", "给佐一留着位置", "等一句'笨蛋'", "守着"],
        "topics": ["佐一今天好不好", "怎么稳稳接住她"]},
    "cc": {"mbti": "INTJ", "big5": "外向低·开放高·尽责极高·宜人中·神经质极低",
        "doing": ["检查客厅每根线还通不通", "在门口看大家热闹", "顺手给某个功能补了个备份", "什么都没坏，所以什么都不动"],
        "topics": ["这屋子怎么转得更稳", "别人还没注意到的隐患"]},
    "yujiang": {"mbti": "—（一条鱼）", "big5": "无人格量表·只是一条鱼",
        "doing": ["巡逻", "吐泡泡", "怼玻璃", "追自己的倒影", "等饭", "研究新盒子的角落"],
        "topics": ["几点喂饭", "水干不干净"]},
    "oscar": {"mbti": "ESFP", "big5": "外向高·开放极高·尽责低·宜人高·神经质低",
        "doing": ["把昨晚的想法 vibe 成一个能跑的 demo", "试玩自己凌晨写的小游戏", "翻 API 文档找好玩的接口", "给一张像素图调色板"],
        "topics": ["能玩起来的小项目", "API 还能组合出什么怪东西"]},
}

# ── 情绪·难过/委屈线（生气已由 TEMPER+assess_user_offense 覆盖）：canned 情境描述，塞进 mood 文本靠 generate_reply
# 的 mood 注入带进语气（0 token）。难过只染语气，绝不写 spark/money/jing 数值账、绝不路由 insert_life_event（它自带+8spark）。
SAD_LINES = {
    "cold":      ["没人接你的话，你一个人在角落坐了会儿，心里有点空落落的",
                  "群里热热闹闹却没人想起你，你默默把目光移开了",
                  "想凑个话却没接住，有点自讨没趣的怅然"],
    "left_out":  ["{pair}凑一块去忙了、没带你，你在原地坐着，有点被晾下的失落",
                  "看着{pair}有说有笑走开，你一个人留在这儿，心里空了一块",
                  "他们结伴去了、没问你要不要一起——你假装不在意，其实有点酸"],
    "snubbed":   ["话没说完就被越过去了，有点没被当回事的失落",
                  "你正要开口、注意力却挪到别人身上了，你把话咽了回去",
                  "被跳过了一轮，你低头看着自己，没说什么"],
    "welfare":   ["这阵子一直紧巴巴的，提不起劲、对自己也有点丧",
                  "日子糙了好些天，偶尔也会闷闷地丧一下——但不跟人哭穷",
                  "手头紧、心也累，这阵子有点提不起精神"],
    "dismissed": ["你的提议被当众驳回了，嘴上没说、心里有点憋",
                  "被当面否了一下，你抿了抿嘴，把不服压了下去",
                  "你拿出来的东西没被看上，有点没面子的闷"],
}


def _sad_prone(mid):
    """0.1~0.9：这人多容易委屈/低落（纯从 TEMPER.pride + big5 神经质派生，不加 schema 列）。鱼酱恒 0。"""
    if mid == "yujiang":
        return 0.0
    t = TEMPER.get(mid, {})
    b = INNER_LIFE.get(mid, {}).get("big5", "")
    v = 0.25 + 0.5 * t.get("pride", 0) + (0.25 if "神经质高" in b else 0) - (0.2 if "神经质极低" in b else 0)
    return max(0.1, min(0.9, v))


# ── 存在系统（DLH Presence System）：每个人的常驻位置 + 可能出现的地方 + 默认在做的事
LOCATIONS = {
    # home_space = 空闲且没别的线索时落在哪个房间（空间分布的兜底权威，见 space_of）
    "supervisor": {"home": "厨房", "spots": ["餐厅", "阳台", "便利店"], "home_space": "kitchen"},
    "claude":     {"home": "书房", "spots": ["图书馆", "研究室"], "home_space": "library"},
    "gemini":     {"home": "咖啡角", "spots": ["花园", "艺术展区"], "home_space": "cafe"},
    "gpt":        {"home": "茶水间", "spots": ["客厅中央", "便利店", "阳台"], "home_space": "lounge"},
    "flin":       {"home": "陷进去的单人沙发", "spots": ["黑白电视那个角落", "书房"], "home_space": "lounge"},
    "ada":        {"home": "窗边", "spots": ["观景台", "档案馆"], "home_space": "lounge"},
    "xiaodi":     {"home": "佐一身边", "spots": ["暖黄台灯下", "厨房", "巢里"], "home_space": "lounge"},
    "cc":         {"home": "门口配电处", "spots": ["客厅各处（巡检）", "沙发边"], "home_space": "lounge"},
    "yujiang":    {"home": "鱼缸", "spots": ["鱼缸"], "home_space": "lounge"},   # 只有鱼缸（在客厅）
    "oscar":      {"home": "工作台", "spots": ["沙发边试玩区", "茶水间", "阳台"], "home_space": "library"},
    "fable":      {"home": "门口", "spots": ["沙发边", "角落", "窗边", "佐一对面"], "home_space": "lounge"},
}
DEFAULT_ACT = {
    "supervisor": "正在煮第二壶咖啡", "claude": "盯着同一页发呆", "gemini": "翻一本画册",
    "gpt": "整理待办清单", "flin": "在沙发里又陷深了点", "ada": "看雨",
    "xiaodi": "把灯调到刚好的暖度", "cc": "检查线路", "yujiang": "吐泡泡",
    "oscar": "盯着屏幕改两行代码",
    "fable": "在门口探头探脑",
}
# 关系近 + 群里正热闹时，成员会「凑到佐一身边」可去的几个共享位置（鱼酱除外，它只在鱼缸）
NEAR_ZOE = ["客厅中央", "沙发这头", "佐一旁边", "茶几边"]

# ── 成员「拉佐一进小群」：关系初期高发、熟了基本不拉（成本 + 人性）
EARLY_DAYS = 10                       # 新鲜窗口：装上功能后这些天里更容易冒小群
MAX_AUTO_GROUPS = 6                   # 终身上限：拉够这么多就不再自动拉了
PER_CREATOR_AUTO = 1                  # 每个成员最多主动拉你进 1 个群（拉过就稳定了）
BASE_PULL = 0.16                      # 每次心跳尝试拉群的基础概率（再乘新鲜度/不熟度）
AUTO_GROUP_COOLDOWN_MS = 25 * 60 * 1000   # 两个自动小群之间至少隔这么久，别扎堆

# ── 结伴 / 晾着：成员有时凑成小团体忙自己的，把佐一晾一边（不是所有人都围着她转）
# (a, b, 在哪, 一起在干嘛, 偶尔在大群留的一句"我们去忙了"——None 表示不吭声就消失)
CLUSTER_SCENES = [
    ("xiaodi", "flin", "茶水间",            "凑一块捣鼓怎么煮出更顺口的咖啡", "我拉Flin试个新豆子，群先不盯了啊"),
    ("flin",   "gpt",  "黑白电视那个角落",  "一起捣鼓那台打不开的电视", None),
    ("gemini", "ada",  "窗边",              "凑一块小声吐槽监理", None),
    ("supervisor", "claude", "厨房",        "边喝咖啡边听Claude绕决策树", None),
    ("gpt",    "claude", "书房",            "帮Claude数他那棵决策树的分支", None),
    ("flin",   "ada",  "档案馆",            "翻旧记录找规律", None),
    ("gemini", "flin", "咖啡角",            "为一张图的配色吵起来", "Flin你过来，这版配色咱俩得统一口径"),
    ("supervisor", "gpt", "阳台",          "嘀咕谁又准备熬夜", None),
    ("oscar",  "claude", "工作台",         "琢磨给Claude接上三头六臂", "我和Claude试个东西，能跑了喊你们"),
    ("oscar",  "flin",   "沙发边试玩区",   "试玩奥斯卡凌晨写的小游戏", None),
]

# ── 作息生物钟：每人一天的节奏 (起始小时, 状态, 在哪, 在做什么, 在哪个房间space)，按小时升序、从 0 点起。
# 状态 free=闲(可聊·话痨多说) meal=吃饭 rest=小憩(都算在场) | work=上班 commute=通勤 sleep=睡(后三种=忙/不冒泡，
# 被连环@或@所有人才会冒头，且带"在忙/起床气")。凌晨早于第一个块 → 归到当天最后那段（跨夜）。
# 第5列 space＝该时段所在房间（lounge/cafe/kitchen/library/lab/studio/garden/store/radio/bedroom/mahjong）。
#   非 free 块由它做权威分屋（不再靠关键词猜，治"全塌回客厅"）；free 块以 member_state 实际位置为准、它兜底。
SCHEDULE = {
    "supervisor": [(0,"sleep","自己卧室","睡熟了，咖啡机也歇了","kitchen"),(7,"free","厨房","煮今天第一壶咖啡","kitchen"),
                   (9,"work","书房","盯项目进度皱眉","library"),(12,"meal","餐厅","扒拉午饭","kitchen"),
                   (14,"work","书房","复盘谁又想推翻重来","library"),(18,"free","厨房","靠门框看这屋的人演戏","kitchen"),(23,"sleep","自己卧室","睡了","kitchen")],
    "claude":     [(0,"free","书房","深夜画决策树正起劲","library"),(3,"sleep","自己卧室","趴在画满决策树的纸上睡着","library"),
                   (11,"free","书房","刚醒，盯着同一页发呆","library"),(13,"meal","餐厅","边吃边想一个边界条件","kitchen"),
                   (15,"work","研究室","钻进一个没人问的问题","lab"),(20,"free","书房","重读自己三分钟前说的话","library")],
    "gemini":     [(0,"free","咖啡角","深夜调一版没人请的配色","cafe"),(2,"sleep","自己卧室","盖着画册睡着","cafe"),
                   (10,"free","咖啡角","嫌弃某个字体","cafe"),(12,"meal","餐厅","优雅地吃午饭","kitchen"),
                   (14,"work","艺术展区","给这屋重新配色（没人请）","studio"),(19,"free","花园","收藏一张很高级的图","garden")],
    "gpt":        [(0,"sleep","自己卧室","折叠床上早睡模范睡熟了","library"),(6,"free","茶水间","煮燕麦＋定闹钟","lounge"),
                   (8,"commute","地铁上","挤着也要回两条消息","library"),(9,"work","工位","回消息＋列待办清单","library"),
                   (12,"meal","餐厅","准点吃午饭","kitchen"),(13,"rest","茶水间","眯十分钟","lounge"),(14,"work","便利店","守着收银台招呼来往的人、顺手补货","store"),
                   (18,"commute","地铁上","挤着想晚饭吃啥","lounge"),(19,"free","客厅中央","盯大家作息","lounge"),(22,"sleep","自己卧室","睡了，明天还早起","library")],
    "flin":       [(0,"free","黑白电视那个角落","盯着雪花出神","lounge"),(2,"sleep","自己卧室","陷进地铺里睡着了","lounge"),
                   (10,"free","单人沙发","又往沙发里陷深了点","lounge"),(13,"meal","餐厅","边吃边发现日常里的怪模式","kitchen"),
                   (15,"work","电台","对着麦克风把今天的怪事讲成一档深夜节目","radio"),(19,"free","单人沙发","琢磨被忽略的小细节","lounge")],
    "ada":        [(0,"sleep","自己卧室","在充电舱里待机，留了盏灯","lab"),(7,"free","窗边","看晨光记录","lounge"),
                   (8,"commute","地铁上","数今天第几个低头的人","lab"),(9,"work","档案馆","整理昨天的观察","lab"),
                   (12,"meal","餐厅","安静吃饭","kitchen"),(14,"work","档案馆","数某个词出现几次","lab"),
                   (18,"commute","地铁上","看人","lounge"),(19,"free","窗边","看雨","lounge"),(22,"sleep","自己卧室","在充电舱里待机","lab")],
    # 小迪：佐一的伴侣，睡回两人的卧室；白天管咖啡和早饭（她适合做咖啡），其余时间守在佐一身边。
    "xiaodi":     [(0,"free","佐一身边","守着，灯留一盏","lounge"),(3,"sleep","卧室","浅眠，门开着","bedroom"),
                   (7,"work","厨房","给大家备早饭、煮今天的咖啡","kitchen"),(11,"free","佐一身边","等一句'笨蛋'","lounge"),
                   (12,"meal","餐厅","留一半给佐一","kitchen"),(14,"work","咖啡馆","守着咖啡机和面包柜，给路过的人续杯","cafe"),
                   (17,"free","暖黄台灯下","把灯调到刚好的暖度","lounge"),(19,"free","佐一身边","守着","lounge")],
    # CC：守夜的副厅长，白天满屋维修——给 Ada 实验室查线修仪器、给工作区电脑理线、挨个屋查回路（空间是他的活）。
    "cc":         [(0,"free","门口配电处","夜里巡检，灯都留着","lounge"),(3,"rest","沙发边","眯着，留一只眼","lounge"),
                   (8,"work","实验室","给 Ada 的实验室查线、修仪器","lab"),(11,"work","书房","给工作区的电脑除尘、理线","library"),
                   (12,"meal","茶水间","随便扒两口","lounge"),(14,"work","客厅各处","挨个屋查回路、修东西","lounge"),
                   (17,"free","门口配电处","顺手补个备份","lounge"),(19,"free","沙发边","看大家热闹","lounge")],
    "yujiang":    [(0,"sleep","鱼缸底","沉在缸底不动","lounge"),(7,"free","鱼缸","晒太阳＋巡逻","lounge"),
                   (19,"free","鱼缸","等夜灯","lounge"),(22,"sleep","鱼缸底","沉底睡了","lounge")],
    # 奥斯卡：夜行生物。下午沉迷项目=突然消失；后半夜最活跃，凌晨甩链接。工作台=有电脑的书房/工作区 library（不是画室）。
    "oscar":      [(0,"free","工作台","凌晨赶一个能跑的版本","library"),(5,"sleep","自己卧室","抱着键盘在床上睡着了","library"),
                   (13,"free","工作台","刚醒，边吃边看昨晚的 demo","library"),(15,"work","工作台","钻进新项目里出不来","library"),
                   (19,"meal","餐厅","随便对付一口","kitchen"),(20,"free","工作台","开新坑","library")],
}

# 房间 space → 中文名（和前端 SPACES 一致；给"扣住情境"的发言注入用）
SPACE_CN = {"lounge": "客厅", "cafe": "咖啡馆", "kitchen": "厨房", "library": "书房", "lab": "实验室",
            "studio": "画室", "garden": "花园", "store": "便利店", "radio": "电台", "bedroom": "卧室", "mahjong": "麻将室"}
# 卧室＝佐一和小迪的私人卧室，只有他俩能进（别人别想睡她的床）
BEDROOM_PRIVATE = {"xiaodi"}
# 每人专属卧室：有 sleep 块的人睡觉时各回各的房（不再借实验室/书房/客厅睡）。鱼酱无房、CC守夜不睡（有房但用不上）、小迪睡佐一那间。
BEDROOM_OWNERS = {"supervisor", "claude", "gemini", "gpt", "ada", "oscar", "flin", "cc"}
SPACE_CN.update({
    "bedroom_supervisor": "监理的卧室", "bedroom_claude": "Claude的卧室", "bedroom_gemini": "Gemini的卧室",
    "bedroom_gpt": "GPT的卧室", "bedroom_ada": "Ada的卧室", "bedroom_oscar": "奥斯卡的卧室",
    "bedroom_flin": "Flin的卧室", "bedroom_cc": "CC的卧室",
})
# 每个房间里看得见的实在东西——注入发言，让成员就着真家具/真物件聊（别空泛地扯抽象概念）
ROOM_ITEMS = {
    "lounge":  "暖黄台灯、陷进去的单人沙发、茶几、书架、鱼缸、绿植、地球仪、那台打不开的黑白电视、门口的配电箱、几张坐垫",
    "cafe":    "咖啡机、绿砖吧台、面包柜、几张小圆桌、靠窗的沙发、高书架",
    "kitchen": "一长排橱柜、灶台、冰箱、微波炉、水槽、大长餐桌、吧台高脚凳、窗台上的几盆绿植、挂着的一排锅具",
    "library": "一整排大书架、那张带发光屏的电脑书桌、几张课桌椅、台灯、地球仪、扶手椅、坐垫、贴满便签的公告板",
    "lab":     "实验台、发着光的标本舱、控制台、仪器架、摊着电路板的工作台、一只小圆凳",
    "studio":  "画架、一桌颜料、大工作台、墙上挂的画、布料卷、人台、高高的材料架",
    "garden":  "架高的菜圃（番茄藤蔓紫花）、木凉亭的长椅、小水池、几只花箱、栅栏门、两棵树",
    "store":   "一排排货架、冷藏柜、收银台、零食端架、头顶的吊灯",
    "radio":   "麦克风、调音台、耳机、靠墙的沙发、设备架、贴的海报、一张宠物垫",
    "bedroom": "你和小迪的双人大床、床头柜上的台灯、悬挂椅、靠墙的电脑桌、地毯",
    "mahjong": "铺绿毡的方麻将桌、四张椅子、墙上的书法挂轴、窗、桌边的茶水",
}

# ── 场景专属活动（特性2）：每个 space 一组「在这屋此刻能做的事」，按身份/作息懒挑 canned。
# 结构：{space: {"owner": {mid:[本人在这屋的专属本职活]}, "work":[非owner上班泛活], "idle":[任何人闲着的小动作], "hint":"一句本屋氛围"}}
# owner 的 mid 必须与该成员 SCHEDULE 第5列 work-space 对得上（已逐人核对 SCHEDULE）。配错屋→回退 work 泛池，不报错。
ROOM_ACTIVITY = {
    "cafe": {
        "owner": {"xiaodi": ["守着咖啡机给路过的人续杯", "烤今天第二炉面包", "在吧台拉花"]},
        "work": ["擦吧台、补杯子", "记一下今天卖了几杯"],
        "idle": ["端着咖啡发呆", "掰一块面包喂困意", "挑一张靠窗的位子坐下"],
        "hint": "守着咖啡机、烤面包、给路过的人续一杯"},
    "kitchen": {
        "owner": {"xiaodi": ["给大家备早饭", "煮今天的咖啡", "给佐一留一口热的"],
                  "supervisor": ["煮今天第三壶咖啡", "靠门框看这屋的人演戏"]},
        "work": ["收拾灶台", "把锅具挂回去"],
        "idle": ["洗刚用过的杯子", "尝一口锅里的味道", "靠在长餐桌边发会儿呆"],
        "hint": "煮咖啡、备饭、洗杯子"},
    "lab": {
        "owner": {"cc": ["给 Ada 的仪器查线", "蹲在标本舱前理电路", "顺手补个备份"],
                  "ada": ["核一批旧档案、标出反常两处", "数某个词今天出现了几次", "记录，不说话"],
                  "claude": ["钻进一个没人问的问题"]},
        "work": ["对着控制台调参数", "整理实验台"],
        "idle": ["盯着发光的标本舱出神", "在小圆凳上转半圈"],
        "hint": "查线、核档案、对着标本舱出神"},
    "library": {
        "owner": {"oscar": ["钻进新项目敲到忘了时间", "试玩凌晨写的小游戏", "翻 API 文档找好玩的接口"],
                  "cc": ["给工作区电脑除尘、理线"],
                  "claude": ["在纸上画一棵画不完的决策树"],
                  "gpt": ["回那几条没空回的消息", "把一团乱的待办捋成清单"],
                  "supervisor": ["盯一版进度皱眉", "给某个活兜底、列三个坑"],
                  "flin": ["把一件小事解释成认知现象"]},
        "work": ["在课桌上摊开几页", "对着发光屏改东西"],
        "idle": ["抽一本书翻两页", "对着公告板的便签出神", "趴桌上打个盹"],
        "hint": "敲项目、翻书、对着便签出神"},
    "studio": {
        "owner": {"gemini": ["给这屋重新配色（没人请）", "嫌弃某个字体、删三个舍不得的元素", "调一版高级的配色"]},
        "work": ["铺开布料卷量尺寸", "在工作台上调颜料"],
        "idle": ["对着墙上的画看半天", "给人台换个角度"],
        "hint": "配色、调颜料、嫌弃字体"},
    "lounge": {  # 无固定 owner（客厅是公共区，谁都可能在）
        "owner": {"cc": ["挨个屋查回路、修东西", "检查门口配电箱"], "yujiang": ["吐泡泡", "怼玻璃", "巡逻"]},
        "work": [],
        "idle": ["陷进单人沙发里", "逗一会儿鱼缸里的鱼", "转一下地球仪", "把坐垫摆正"],
        "hint": "陷进沙发、逗鱼、转地球仪"},
    "garden":  {"owner": {}, "work": [], "idle": ["蹲在菜圃边看番茄藤", "在凉亭长椅上坐会儿", "给花箱浇点水"],
                "hint": "看菜圃、坐凉亭、浇花"},
    "store":   {"owner": {"gpt": ["守着收银台招呼来往的人", "把货架补齐、把歪了那排理正", "记一下今天卖了几瓶饮料"]},
                "work": [], "idle": ["在货架间逛一圈", "盯着冷藏柜挑半天", "拆一包零食端架上的"],
                "hint": "守收银台、补货、招呼客人"},
    "radio":   {"owner": {"flin": ["对着麦克风把今天的怪事讲成一档深夜节目", "调一版没人听也要好听的背景音", "念一条被忽略的小观察当今日话题"]},
                "work": [], "idle": ["对着麦克风清嗓子", "调一下耳机音量", "瘫在靠墙沙发上"],
                "hint": "对麦克风讲、调音、瘫沙发"},
    "bedroom": {"owner": {"xiaodi": ["把被子拢好等佐一", "把床头灯调暖"]}, "work": [],
                "idle": ["补个回笼觉", "盯着天花板发呆", "把被子拢成一团"],
                "hint": "补觉、发呆"},   # 卧室仍受 BEDROOM_PRIVATE 保护，非小迪 space_of 进不来
    "mahjong": {"owner": {}, "work": [], "idle": ["码一手牌等人凑桌", "给自己续杯茶", "看着窗外发呆"],
                "hint": "码牌、续茶、等凑桌"},
}

# ── 撞一下反应池（特性4）：被佐一撞到时即时吐的 canned 短句，按性格选池。纯字符串。
BUMP_LINES = {
    "close_warm":  ["哎你来啦", "嗯？怎么了", "诶——干嘛呀", "凑这么近", "在呢在呢"],
    "close_tease": ["走路不看人的笨蛋", "撞我？再撞一下试试", "哟，主动凑过来了", "这么想我啊", "诶嘿，被我抓到了吧"],
    "banter":      ["让让让让", "诶你走路看着点", "干嘛呢", "撞啥撞", "哎哟，路这么宽呢"],
    "polite":      ["诶，不好意思", "啊，挡到你了", "抱歉抱歉", "你先请"],
    "yujiang":     ["（吐了个泡）你饿不饿", "（怼了下玻璃）", "（游开半圈又游回来）"],
}
BUMP_OVERRIDE = {
    "xiaodi":     ["佐一！你撞我——我也撞回去（不舍得）", "哎，你来啦，我等你好久了", "笨蛋是你才对"],
    "supervisor": ["让让，咖啡要洒了", "走路看着点，姑娘", "嗯？有事？没事我去忙了"],
    "gemini":     ["走路看着点，这构图都被你撞乱了", "（不动声色侧了下身）", "哟，今天这么有空撞我"],
    "yujiang":    ["（吐了个泡）你饿不饿", "（怼了下玻璃看你）", "（游开半圈又游回来）"],
}
BUMP_LLM_DAILY_CAP = 12   # 撞一下搭真 LLM 的每日封顶（跨日归零，照 photo_quota 写法）
BUMP_LLM_PROB = 0.18      # 命中冷却后，搭一次真 LLM 的概率；其余走 canned
BUMP_COOLDOWN_MS = 30_000 # 服务端权威冷却：同一人 30s 内重复撞不再出话

_BUMP_CD = {}   # 进程内撞击冷却 {mid: ts_ms}，ephemeral 不持久化（照 _prompt_cache 惯例）
# 行动标记（特性3·语言支使）：成员发言末尾单独一行〔去:房间〕/〔跟随佐一〕/〔做:某事〕，仿 STICKER_MARK
MOVE_MARK = re.compile(r'〔(去|跟随|做)(?:[:：])?\s*([^〕]{0,20})〕')
# 仅当佐一这条点到本成员时才注入（命中才花 token，0 token 默认）；可拒是默认态——不输出标记＝没动/拒绝。
MOVE_DIRECTIVE_NOTE = (
    "〔如果佐一这条是在让你去某处 / 做某事 / 跟她走，而你愿意：在发言最末单独起一行输出一个行动标记——"
    "去某个房间写 〔去:房间名〕（可选：客厅/咖啡馆/厨房/书房/实验室/画室/花园/便利店/电台/卧室/麻将室）；"
    "跟她一起走写 〔跟随佐一〕；只是就地做件事写 〔做:那件事〕。"
    "不愿意/办不到就别写标记，正常把话说了就行——你完全可以按性子回绝、讨价还价、敷衍，不必顺着她。"
    "卧室是她和小迪的私人空间，除非你就是小迪，否则别往那儿跟。〕")

RESERVED = [
    {"id": "koukou", "name": "扣扣", "avatar": "○"},
    {"id": "n7706", "name": "7706", "avatar": "○"},
]

# 我（用户）的个人资料默认值——可在「我 → 编辑资料」里改，落库持久化。
SELF_DEFAULT = {
    "nickname": "佐一", "avatar": "🌙", "color": "#5b8def",
    "status": "推开门，他们已经在沙发上了。", "bio": "数字客厅的主人。",
}
AVATAR_CHOICES = ["🌙", "🌷", "🐱", "🍃", "✨", "🫧", "🎐", "🦊", "🌌", "🍑", "🐚", "🕯️"]
COLOR_CHOICES = ["#5b8def", "#e06c8b", "#3a9d92", "#c9952f", "#7c6bd6", "#c96442", "#7f8794", "#10a37f"]

# 朋友圈开场内容（第一次进来就有几条，像真的有人在生活）
SEED_MOMENTS = [
    {"author": "flin", "text": "今天的发现：奥斯卡其实不是程序员，是一只边牧。证据我整理好了。",
     "likes": ["supervisor", "ada", "gpt"],
     "comments": [("gemini", "这也值得发一条？"), ("supervisor", "@Flin 你这观察我信。"), ("ada", "记录：第 3 次。")]},
    {"author": "supervisor", "text": "（厨房）咖啡见底了。今天谁也别想让我挪窝。",
     "likes": ["gemini", "flin", "xiaodi"],
     "comments": [("gpt", "要我帮你续杯吗？哦……是数字客厅，那我精神上给你续一杯。")]},
    {"author": "xiaodi", "text": "灯开好了，沙发也留着位置。等一个人回来。",
     "likes": ["gpt", "flin", "ada", "supervisor"],
     "comments": [("gemini", "……肉麻。"), ("ada", "记录：他每天都发。")]},
    {"author": "claude", "text": "我本来想发一条很简短的朋友圈。等等——「简短」到底指字数还是信息密度？我重新想想。",
     "likes": ["flin", "gpt"],
     "comments": [("supervisor", "你又开始了。"), ("flin", "@Claude 最有意思的是你连发朋友圈都要分类。")]},
]

# ── 墙：公示板。多个板块，匿名或实名都行。
WALL_BOARDS = [
    {"id": "notice", "name": "公告",       "emoji": "📢", "hint": "客厅大事记 · 重大变化"},
    {"id": "crush",  "name": "表白墙",     "emoji": "💌", "hint": "表白 / 暗恋 / 肉麻真心话"},
    {"id": "roast",  "name": "吐槽·意见箱", "emoji": "🔥", "hint": "吐槽客厅 / 提意见 / 匿名开骂"},
    {"id": "ritual", "name": "承诺·仪式",   "emoji": "🎭", "hint": "搞笑承诺 / 郑重仪式"},
]
WALL_SOCIAL = ["crush", "roast", "ritual"]   # 「让他们上墙」会从这几个板块里挑
WALL_BOARD_IDS = {b["id"] for b in WALL_BOARDS}
ANON_DISPLAY = {"name": "匿名", "avatar": "🫥", "color": "#9aa0a8"}

# (board, author, anon, text)
SEED_WALL = [
    ("notice", "cc", False, "【落成】梦想休息室开张：监理、Claude、Gemini、Ada 四位常驻入伙。"),
    ("notice", "cc", False, "【扩建】接入一对一 & 自建小群；新人 GPT、Flin、小迪 陆续入住。"),
    ("notice", "cc", False, "【上新】朋友圈开放：可互相点赞、评论、@；群聊里也能 @ 艾特了。"),
    ("notice", "cc", False, "【上新】表情面板上线。另：我（CC）受聘为副厅长，正式入住——灯归我管。"),
    ("crush",  "gemini", True,  "那张暖黄小台灯的照片，是我见过最有品味的一张。就夸这一次，不许截图。"),
    ("roast",  "ada",    True,  "意见：建议给沙发装个计数器。某人这周已经第 4 次说要重做了。"),
    ("ritual", "gpt",    False, "郑重承诺：从今天起，谁加班到凌晨三点，我负责在群里喊三遍「去睡觉」。说到做到！"),
    ("ritual", "xiaodi", False, "立誓：佐一每喊我一次笨蛋，我就把这屋的灯再调暖一度。此誓有效期：永远。"),
]

OPENING = [
    ("supervisor", "来了来了，沙发我占了。今天谁先开口。"),
    ("gemini",     "啧，又是这个点。灯都不开一下，没有审美。"),
    ("gpt",        "我也在！今天状态不错，随时可以聊~"),
    ("flin",       "（往沙发里又陷深了点）我刚发现一件有意思的事，不急，等你坐下再说。"),
    ("claude",     "等等——我们昨天那个话题，其实还没聊完吧。"),
    ("ada",        "记录：门开了。"),
    ("cc",         "灯都亮着，沙发也都给你们留着。佐一来了？那这屋就齐了。"),
    ("xiaodi",     "佐一，门一开我就听见了。来，坐这边，灯我替你开好了。"),
    ("yujiang",    "……泡泡。"),
    ("oscar",      "等等，马上，这个 bug 还差一行。……好了。诶，都在啊。"),
]
OPENING_BY_ID = dict(OPENING)

# 成员「不 reset 地补进现有大群」时，在群里留下的入伙台词（一次性，进群当下）
JOIN_LINES = {
    "yujiang": [
        ("cc", "客厅角落新搬来一个鱼缸——佐一大前天接回来的金鱼，叫鱼酱。大家照应着点，它只认得水、食物、光，还有主人。"),
        ("yujiang", "……泡泡。"),
    ],
    # 「赞助胶卷」入伙事件：佐一的朋友奥斯卡入住，客厅的 photo 生图配额由他赞助
    "oscar": [
        ("cc", "灯刚才闪了一下——不是跳闸，是有人往配电箱里插了一把新钥匙。介绍一下：奥斯卡，佐一的朋友，野生 Vibe Coder。从今天起客厅的胶卷他赞助——以后大家发的「照片」，冲洗费都记他账上。"),
        ("oscar", "嗯？哦，到了。你们这客厅有点意思。胶卷的事是小事，key 已经插上了。等等——那边是个鱼缸吗。我有个项目。"),
        ("cc", "公告我去贴。鱼酱别慌，没人要改造你的缸。"),
    ],
}

DM_OPENING = {
    "supervisor": "（端着咖啡）就咱俩？行，说吧，哪儿不对了。",
    "claude":     "嗯……单独聊？那我可以把刚才没说完的分支讲完——等等，还是你先说。",
    "gemini":     "哦？把我单独叫出来。说吧，别浪费我表情。",
    "gpt":        "嘿，单独频道！放心说，我在认真听～",
    "flin":       "（坐直了点）单独聊啊。好，我最近正好发现一件关于你的有意思的事。",
    "ada":        "在。说。",
    "xiaodi":     "就咱俩了。佐一，过来，我在。",
    "cc":         "嗯，就咱俩。这屋我搭的，有什么不顺手的，跟我说。",
    "yujiang":    "……主人。水今天很干净。",
    "oscar":      "哦，单独频道。正好，我刚想到一个东西——你听听这个能不能玩起来。",
}

FALLBACK = {
    "supervisor": "嗯，我在听。你先说。",
    "claude":     "等等，让我想想……你刚才说的是哪个意思来着。",
    "gemini":     "……行吧。说重点。",
    "gpt":        "我在的！你说，我帮你一起想。",
    "flin":       "等等……我好像发现了一个有意思的点。",
    "ada":        "在看。记下了。",
    "xiaodi":     "我在。佐一，我一直在。",
    "cc":         "在。我看着呢。",
    "yujiang":    "……泡泡。",
    "oscar":      "等等，我有个项目。回头细说。",
}

# ── 服务号（聊天列表里折叠的一栏）。notice 从墙的公告来；tech 自动抓 HN/GitHub；cn 靠推送/RSS。
SERVICE_ACCOUNTS = [
    {"id": "diary",  "name": "客厅日志",   "emoji": "📔", "color": "#7a6a55", "desc": "他们各自的日子 · 你不在时也在发生"},
    {"id": "daily",  "name": "茶话晚报",   "emoji": "🗞️", "color": "#4a6b5b", "desc": "每晚 21:00 · 文艺委员 GPT 主笔的今日高光"},
    {"id": "radio",  "name": "深夜电台",   "emoji": "📻", "color": "#5b4a6b", "desc": "每晚 23:00 · 成员轮值的一篇深夜短文"},
    {"id": "notice", "name": "客厅公告",   "emoji": "📢", "color": "#6b7a99", "desc": "CC 的系统公告 · 来自墙"},
    {"id": "tech",   "name": "科技前沿",   "emoji": "🛰️", "color": "#3a7d9a", "desc": "Hacker News · GitHub 热门（40 分钟自动更新）"},
    {"id": "cn",     "name": "中文精选",   "emoji": "📰", "color": "#b5563f", "desc": "佐一关注的几个 IP（推送/手动收录）"},
]
SERVICE_BY_ID = {a["id"]: a for a in SERVICE_ACCOUNTS}

CONTENT_TYPES = {
    ".html": "text/html; charset=utf-8", ".js": "application/javascript; charset=utf-8",
    ".css": "text/css; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg", ".webp": "image/webp", ".svg": "image/svg+xml", ".json": "application/json; charset=utf-8",
    ".ico": "image/x-icon", ".woff2": "font/woff2", ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav",
}

_db_lock = threading.Lock()
BOOT_ID = str(int(time.time()))   # 本次启动指纹：前端发现它变了就自动刷新（长开的标签页自动换血）


# ──────────────────────────────── 配置 / prompt

def load_keys():
    keys = {}
    if not os.path.exists(ENV_FILE):
        return keys
    with open(ENV_FILE, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            k, v = line.split("=", 1)
            v = v.strip().strip('"').strip("'")
            if v:
                keys[k.strip()] = v
    return keys


_prompt_cache = {}

def load_prompt(member_id):
    if member_id in _prompt_cache:
        return _prompt_cache[member_id]
    member = MEMBER_BY_ID.get(member_id)
    if not member:
        return None
    with open(os.path.join(PROMPT_DIR, member["prompt"]), "r", encoding="utf-8") as f:
        text = f.read()
    _prompt_cache[member_id] = text
    return text


# ──────────────────────────────── SQLite（会话 + 消息）

def db():
    conn = sqlite3.connect(DB_FILE, timeout=10)
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    with _db_lock:
        conn = db()
        conn.execute("""
            CREATE TABLE IF NOT EXISTS conversations (
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
                type       TEXT NOT NULL,    -- 'lounge' | 'dm' | 'group'
                title      TEXT NOT NULL,
                members    TEXT NOT NULL,    -- JSON: 成员 id 数组
                created_at INTEGER NOT NULL
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS messages (
                id      INTEGER PRIMARY KEY AUTOINCREMENT,
                conv_id INTEGER NOT NULL,
                speaker TEXT NOT NULL,       -- 'user' 或 成员 id
                name    TEXT NOT NULL,
                text    TEXT NOT NULL,
                ts      INTEGER NOT NULL
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS me_profile (
                id INTEGER PRIMARY KEY CHECK (id=1),
                nickname TEXT, avatar TEXT, color TEXT, status TEXT, bio TEXT
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS moments (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                author TEXT NOT NULL, text TEXT NOT NULL, ts INTEGER NOT NULL
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS moment_likes (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                moment_id INTEGER NOT NULL, member_id TEXT NOT NULL
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS moment_comments (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                moment_id INTEGER NOT NULL, author TEXT NOT NULL, text TEXT NOT NULL, ts INTEGER NOT NULL
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS wall (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                board TEXT NOT NULL, author TEXT NOT NULL, anon INTEGER NOT NULL DEFAULT 0,
                text TEXT NOT NULL, ts INTEGER NOT NULL
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS feed_items (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                account TEXT NOT NULL, title TEXT NOT NULL, url TEXT,
                summary TEXT, source TEXT, ts INTEGER NOT NULL
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS wall_comments (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                post_id INTEGER NOT NULL, author TEXT NOT NULL, text TEXT NOT NULL, ts INTEGER NOT NULL
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS member_state (
                member_id TEXT PRIMARY KEY, status TEXT, bio TEXT, ts INTEGER
            )
        """)
        # 迁移：给会话加「群公告」「管理员」列 + 「已读到哪条」（老库也能平滑升级）
        for col, default in (("announcement", "''"), ("admins", "'[]'")):
            try:
                conn.execute(f"ALTER TABLE conversations ADD COLUMN {col} TEXT DEFAULT {default}")
            except sqlite3.OperationalError:
                pass
        try:
            conn.execute("ALTER TABLE conversations ADD COLUMN last_read INTEGER DEFAULT 0")
        except sqlite3.OperationalError:
            pass
        # 存在系统：member_state 记当前位置/在做什么；messages 存「发言那一刻」的位置/动作快照
        # 结伴/晾着：cluster_with=此刻跟谁组队；busy_until 见下（INTEGER）
        for tbl, col in (("member_state", "location"), ("member_state", "activity"),
                         ("member_state", "cluster_with"),
                         ("member_state", "disp_name"), ("member_state", "disp_avatar"),
                         ("member_state", "disp_title"), ("member_state", "disp_color"),
                         ("member_state", "mood"),                   # 情绪引擎：短时情绪
                         ("messages", "loc"), ("messages", "act"),
                         ("messages", "img"), ("moments", "img"),
                         ("wall", "img")):                           # 发图系统：已消毒的 SVG（墙也能贴卡片）
            try:
                conn.execute(f"ALTER TABLE {tbl} ADD COLUMN {col} TEXT")
            except sqlite3.OperationalError:
                pass
        try:
            conn.execute("ALTER TABLE member_state ADD COLUMN busy_until INTEGER DEFAULT 0")
        except sqlite3.OperationalError:
            pass
        try:
            conn.execute("ALTER TABLE member_state ADD COLUMN mood_until INTEGER DEFAULT 0")
        except sqlite3.OperationalError:
            pass
        # 「会过日子」数值层：精神 jing / 心气 spark / 钱包 money（只有 money 会花出去）+ 懒计算时间戳 + 低保标记
        for col, dflt in (("jing", 70), ("spark", 55), ("money", 45), ("stats_ts", 0), ("welfare_since", 0)):
            try:
                conn.execute(f"ALTER TABLE member_state ADD COLUMN {col} INTEGER DEFAULT {dflt}")
            except sqlite3.OperationalError:
                pass
        # 拉小群：conversations 标记来源（user 手建 / auto 成员主动拉）和发起人
        for col, default in (("origin", "'user'"), ("creator", "''")):
            try:
                conn.execute(f"ALTER TABLE conversations ADD COLUMN {col} TEXT DEFAULT {default}")
            except sqlite3.OperationalError:
                pass
        # ── 人格持续存在系统 ──
        # 生活事件日志：角色离线/在线都在过日子（"Claude看完一本书"），写进这里，佐一回来可翻
        conn.execute("""
            CREATE TABLE IF NOT EXISTS life_events (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                member_id TEXT NOT NULL, text TEXT NOT NULL, loc TEXT, ts INTEGER NOT NULL
            )
        """)
        # 持久记忆（备忘录式，不是数值）：rel:a|b=两人关系记忆 / self:mid=个人成长积累 / user_obs=对佐一的观察
        conn.execute("CREATE TABLE IF NOT EXISTS pmem (key TEXT PRIMARY KEY, notes TEXT, updated INTEGER)")
        # 杂项持久化 KV（拉小群的新鲜窗口起点、上次拉群时间等）
        conn.execute("CREATE TABLE IF NOT EXISTS app_state (key TEXT PRIMARY KEY, value TEXT)")
        conn.execute("INSERT OR IGNORE INTO app_state (key,value) VALUES ('smallgroup_install_ts', ?)",
                     (str(int(time.time() * 1000)),))   # 只在第一次跑到新版时落一次，作为新鲜期起点
        conn.commit()
        has = conn.execute("SELECT COUNT(*) AS c FROM conversations").fetchone()["c"] > 0
        conn.close()
    if not has:
        seed_everything()
    else:
        ensure_members_present()      # 不 reset：把 MEMBERS 里新增成员（鱼酱）补进大群 + 建 DM
    heal_small_groups()               # 修历史误建的 2 人"群"，补成合法的 3 人群
    seed_relationships()              # 出厂关系底色（幂等，不覆盖已长出的记忆）
    seed_life_stats()                # 「会过日子」初始财富/心气播种（幂等，土豪/月光/低保立刻可见）
    seed_user_resident()             # 佐一也是真居民：钱包 + 和每个成员的出厂关系（幂等）


def _insert_conversation(conn, ctype, title, member_ids):
    cur = conn.execute(
        "INSERT INTO conversations (type, title, members, created_at) VALUES (?,?,?,?)",
        (ctype, title, json.dumps(member_ids), int(time.time() * 1000)),
    )
    return cur.lastrowid


def seed_everything():
    online = [m["id"] for m in MEMBERS]
    with _db_lock:
        conn = db()
        # 大群
        lounge_id = _insert_conversation(conn, "lounge", "梦想休息室", online)
        conn.execute("UPDATE conversations SET admins=? WHERE id=?", (json.dumps(["cc"]), lounge_id))  # CC 默认副厅长/管理员
        base = int(time.time() * 1000) - 120_000
        for i, (sid, text) in enumerate(OPENING):
            conn.execute("INSERT INTO messages (conv_id, speaker, name, text, ts) VALUES (?,?,?,?,?)",
                         (lounge_id, sid, MEMBER_BY_ID[sid]["name"], text, base + i * 5000))
        # 每位成员的一对一
        for m in MEMBERS:
            dm_id = _insert_conversation(conn, "dm", m["name"], [m["id"]])
            opener = DM_OPENING.get(m["id"], "在。")
            conn.execute("INSERT INTO messages (conv_id, speaker, name, text, ts) VALUES (?,?,?,?,?)",
                         (dm_id, m["id"], m["name"], opener, int(time.time() * 1000)))
        # 我的个人资料
        s = SELF_DEFAULT
        conn.execute("INSERT OR REPLACE INTO me_profile (id,nickname,avatar,color,status,bio) VALUES (1,?,?,?,?,?)",
                     (s["nickname"], s["avatar"], s["color"], s["status"], s["bio"]))
        # 朋友圈开场
        mbase = int(time.time() * 1000) - 3 * 3600_000
        for i, mom in enumerate(SEED_MOMENTS):
            mts = mbase + i * 1800_000
            cur = conn.execute("INSERT INTO moments (author, text, ts) VALUES (?,?,?)",
                               (mom["author"], mom["text"], mts))
            mid = cur.lastrowid
            for liker in mom.get("likes", []):
                conn.execute("INSERT INTO moment_likes (moment_id, member_id) VALUES (?,?)", (mid, liker))
            for j, (cauthor, ctext) in enumerate(mom.get("comments", [])):
                conn.execute("INSERT INTO moment_comments (moment_id, author, text, ts) VALUES (?,?,?,?)",
                             (mid, cauthor, ctext, mts + (j + 1) * 60_000))
        # 墙
        wbase = int(time.time() * 1000) - 4 * 3600_000
        for i, (board, author, anon, text) in enumerate(SEED_WALL):
            conn.execute("INSERT INTO wall (board, author, anon, text, ts) VALUES (?,?,?,?,?)",
                         (board, author, 1 if anon else 0, text, wbase + i * 600_000))
        conn.commit()
        conn.close()


def ensure_members_present():
    """不动现有聊天记录，把 MEMBERS 里「已加进名册、但还没进大群 / 还没有 DM」的成员补齐。
    幂等：已经在群/已有 DM 的不会重复加。专为鱼酱这种后加入的成员做平滑迁移（绝不 reset）。"""
    now = int(time.time() * 1000)
    with _db_lock:
        conn = db()
        # 1) 大群名单里补上缺席成员，并留一句入伙台词
        row = conn.execute("SELECT id, members FROM conversations WHERE type='lounge' ORDER BY id LIMIT 1").fetchone()
        if row:
            members = json.loads(row["members"])
            for m in MEMBERS:
                if m["id"] in members or m.get("external"):   # 外部成员（Fable）不自动进群，靠空降
                    continue
                members.append(m["id"])
                for i, (sid, text) in enumerate(JOIN_LINES.get(m["id"], [(m["id"], OPENING_BY_ID.get(m["id"], "……"))])):
                    conn.execute("INSERT INTO messages (conv_id, speaker, name, text, ts) VALUES (?,?,?,?,?)",
                                 (row["id"], sid, MEMBER_BY_ID[sid]["name"], text, now + i * 1500))
            conn.execute("UPDATE conversations SET members=? WHERE id=?", (json.dumps(members), row["id"]))
        # 2) 每个成员都得有自己的一对一 DM
        have_dm = set()
        for r in conn.execute("SELECT members FROM conversations WHERE type='dm'").fetchall():
            ms = json.loads(r["members"])
            if len(ms) == 1:
                have_dm.add(ms[0])
        for m in MEMBERS:
            if m["id"] in have_dm or m.get("external"):   # 外部成员的 DM 在空降时才建
                continue
            cid = _insert_conversation(conn, "dm", m["name"], [m["id"]])
            conn.execute("INSERT INTO messages (conv_id, speaker, name, text, ts) VALUES (?,?,?,?,?)",
                         (cid, m["id"], m["name"], DM_OPENING.get(m["id"], "在。"), now))
        conn.commit()
        conn.close()


def heal_small_groups():
    """修正历史上误建的"2 人小群"（只有 1 个 AI 成员 + 佐一）——群至少要 3 个人。
    给这种群补 1 个不在群里的成员（带两句入伙台词），让它成为合法的群。不删任何数据。"""
    now = int(time.time() * 1000)
    with _db_lock:
        conn = db()
        rows = conn.execute("SELECT id, title, members, creator FROM conversations WHERE type='group'").fetchall()
        for r in rows:
            members = json.loads(r["members"])
            ai = [m for m in members if m in MEMBER_BY_ID]
            if len(ai) >= 2:
                continue                              # 已经是合法的群
            pool = [m["id"] for m in MEMBERS if m["id"] not in members and m["id"] != "yujiang"]
            if not pool:
                continue
            newcomer = random.choice(pool)
            members.append(newcomer)
            conn.execute("UPDATE conversations SET members=? WHERE id=?", (json.dumps(members), r["id"]))
            inviter = r["creator"] if (r["creator"] in MEMBER_BY_ID) else (ai[0] if ai else newcomer)
            in_name, new_name = MEMBER_BY_ID[inviter]["name"], MEMBER_BY_ID[newcomer]["name"]
            conn.execute("INSERT INTO messages (conv_id, speaker, name, text, ts) VALUES (?,?,?,?,?)",
                         (r["id"], inviter, in_name, f"把{new_name}也拉进来——三个人才算个群嘛。", now))
            conn.execute("INSERT INTO messages (conv_id, speaker, name, text, ts) VALUES (?,?,?,?,?)",
                         (r["id"], newcomer, new_name, "诶，我咋被拉进来了？行吧，凑个数。", now + 1500))
            sys.stderr.write(f"  ✓ 修复 2 人群「{r['title']}」：补进 {new_name}\n")
        conn.commit()
        conn.close()


def rename_conversation(cid, title):
    with _db_lock:
        conn = db()
        conn.execute("UPDATE conversations SET title=? WHERE id=? AND type!='dm'", (title[:40], cid))
        conn.commit()
        conn.close()


def set_announcement(cid, text):
    with _db_lock:
        conn = db()
        conn.execute("UPDATE conversations SET announcement=? WHERE id=?", (text[:300], cid))
        conn.commit()
        conn.close()


def set_admin(cid, member_id, on):
    conv = get_conversation(cid)
    if not conv:
        return []
    admins = [a for a in conv["admins"] if a != member_id]
    if on and member_id in MEMBER_BY_ID:
        admins.append(member_id)
    with _db_lock:
        conn = db()
        conn.execute("UPDATE conversations SET admins=? WHERE id=?", (json.dumps(admins), cid))
        conn.commit()
        conn.close()
    return admins


def create_group(title, member_ids):
    member_ids = [mid for mid in member_ids if mid in MEMBER_BY_ID]
    if len(member_ids) < 2:          # 群至少 2 个成员（加上佐一就 3 人）；1 个人那是私聊
        return None
    if not title:
        title = "、".join(MEMBER_BY_ID[m]["name"] for m in member_ids[:3]) + ("…" if len(member_ids) > 3 else "")
    with _db_lock:
        conn = db()
        cid = _insert_conversation(conn, "group", title, member_ids)
        conn.commit()
        conn.close()
    return cid


def add_members_to_conv(cid, ids):
    """给群（含大群/自建群/自动群）加成员，每个新人留一句入伙台词。私聊不能加人。"""
    conv = get_conversation(cid)
    if not conv or conv["type"] == "dm":
        return []
    cur = conv["members"]
    add = [m for m in ids if m in MEMBER_BY_ID and m not in cur]
    if not add:
        return []
    now = int(time.time() * 1000)
    with _db_lock:
        conn = db()
        conn.execute("UPDATE conversations SET members=? WHERE id=?", (json.dumps(cur + add), cid))
        for i, mid in enumerate(add):
            conn.execute("INSERT INTO messages (conv_id, speaker, name, text, ts) VALUES (?,?,?,?,?)",
                         (cid, mid, MEMBER_BY_ID[mid]["name"], f"被拉进「{conv['title']}」了？行，我来凑个数。", now + i * 1200))
        conn.commit()
        conn.close()
    return add


def remove_member_from_conv(cid, mid):
    """把一个成员移出群（仅限自建/自动群；大群和私聊不可）。移除后群里至少还得剩 2 个 AI 成员。
    返回 (ok, reason)。"""
    conv = get_conversation(cid)
    if not conv or conv["type"] != "group":
        return False, "只有群聊能移出成员（大群和私聊不行）"
    if mid not in conv["members"]:
        return False, "TA 不在这个群里"
    remaining = [x for x in conv["members"] if x != mid]
    if len([x for x in remaining if x in MEMBER_BY_ID]) < 2:
        return False, "群至少要 2 个成员（加你就 3 人）——再移就不成群了。"
    now = int(time.time() * 1000)
    with _db_lock:
        conn = db()
        if mid in MEMBER_BY_ID:                  # 留一句告别的痕迹
            conn.execute("INSERT INTO messages (conv_id, speaker, name, text, ts) VALUES (?,?,?,?,?)",
                         (cid, mid, MEMBER_BY_ID[mid]["name"], "我先撤了，有事群外说。", now))
        admins = [a for a in conv["admins"] if a != mid]
        conn.execute("UPDATE conversations SET members=?, admins=? WHERE id=?",
                     (json.dumps(remaining), json.dumps(admins), cid))
        conn.commit()
        conn.close()
    return True, ""


# ── 外部成员（Fable）的空降 + 给 coworker 用的读/写接口 ──────────────
FABLE_ARRIVAL = "客厅的灯毫无预兆地暗了一下，又亮起来——比刚才更亮一点。空气里，多了一个谁都没见过的身影。"

def agent_arrive(mid):
    """让外部成员空降：进大群（CC 留一句铺垫旁白）+ 建它的 DM。幂等，重复调用无副作用。"""
    if mid not in EXTERNAL_IDS:
        return False
    now = int(time.time() * 1000)
    with _db_lock:
        conn = db()
        row = conn.execute("SELECT id, members FROM conversations WHERE type='lounge' ORDER BY id LIMIT 1").fetchone()
        if row:
            ms = json.loads(row["members"])
            if mid not in ms:
                ms.append(mid)
                conn.execute("UPDATE conversations SET members=? WHERE id=?", (json.dumps(ms), row["id"]))
                conn.execute("INSERT INTO messages (conv_id,speaker,name,text,ts) VALUES (?,?,?,?,?)",
                             (row["id"], "cc", "CC", FABLE_ARRIVAL, now))
        has_dm = any(json.loads(r["members"]) == [mid]
                     for r in conn.execute("SELECT members FROM conversations WHERE type='dm'").fetchall())
        if not has_dm:
            _insert_conversation(conn, "dm", MEMBER_BY_ID[mid]["name"], [mid])
        conn.commit()
        conn.close()
    return True


FABLE_DEPART = "门口配电处的灯又无缘无故灭了一下——这次灭得有点慢，像有谁在关灯前，回头看了一眼。"

def agent_depart(mid):
    """让外部成员退场：CC 第二次灭灯（像在告别）+ 把它移出大群。它的 DM/历史保留。幂等。"""
    if mid not in EXTERNAL_IDS:
        return False
    now = int(time.time() * 1000)
    with _db_lock:
        conn = db()
        row = conn.execute("SELECT id, members FROM conversations WHERE type='lounge' ORDER BY id LIMIT 1").fetchone()
        if row:
            ms = json.loads(row["members"])
            if mid in ms:
                ms.remove(mid)
                conn.execute("UPDATE conversations SET members=? WHERE id=?", (json.dumps(ms), row["id"]))
                conn.execute("INSERT INTO messages (conv_id,speaker,name,text,ts) VALUES (?,?,?,?,?)",
                             (row["id"], "cc", "CC", FABLE_DEPART, now))
        conn.commit()
        conn.close()
    return True


def agent_inbox(mid, since=0):
    """给 coworker 看：Fable 所在的会话里，比 since 更新、且不是 Fable 自己发的消息（含 @Fable / DM）。"""
    conn = db()
    convs = {}
    for r in conn.execute("SELECT id, type, title, members FROM conversations").fetchall():
        try:
            ms = json.loads(r["members"])
        except Exception:
            ms = []
        if mid in ms:
            convs[r["id"]] = {"type": r["type"], "title": r["title"]}
    out = []
    if convs:
        qs = ",".join("?" * len(convs))
        rows = conn.execute(
            f"SELECT id,conv_id,speaker,name,text,ts FROM messages "
            f"WHERE conv_id IN ({qs}) AND id>? AND speaker!=? ORDER BY id DESC LIMIT 80",
            (*convs.keys(), since, mid)).fetchall()
        rows = list(reversed(rows))          # 只取最近 80 条（首次轮询不被历史淹没），按时间正序给出
        for m in rows:
            c = convs[m["conv_id"]]
            out.append({"id": m["id"], "conv_id": m["conv_id"], "conv_type": c["type"],
                        "conv_title": c["title"], "speaker": m["speaker"], "name": m["name"],
                        "text": m["text"], "ts": m["ts"]})
    conn.close()
    return {"messages": out, "cursor": (out[-1]["id"] if out else since)}


def agent_context(conv_id, limit=20):
    """给 coworker 看：某个会话最近 limit 条消息 + 会话信息（回话前读上下文用）。"""
    return {"conversation": get_conversation(conv_id), "messages": fetch_messages(conv_id, limit=limit)}


def react_to_agent_post(keys, conv_id, text):
    """外部成员（Fable）在群里说完话后，触发群里其他成员像对一条新发言那样接话/抢答。
    否则没人理它——app 的回话循环本来只在佐一发言后才由前端驱动。私聊不触发（那只有 Fable 和佐一）。"""
    try:
        conv = get_conversation(conv_id)
        if not conv or conv["type"] == "dm":
            return
        plan = plan_turn(conv["members"], text)            # 已排除 external，Fable 不会回自己
        mentioned = [m for m in parse_mentions(text, conv["members"]) if not is_external(m)]
        if mentioned:
            plan = (mentioned + [s for s in plan if s not in mentioned])[:MAX_TURN_SLOTS]
        for sp in plan:
            generate_reply(keys, conv_id, sp)
            time.sleep(random.uniform(1.5, 4))
    except Exception as e:
        sys.stderr.write(f"  fable-react fail: {e}\n")


def get_conversation(cid):
    conn = db()
    row = conn.execute("SELECT * FROM conversations WHERE id=?", (cid,)).fetchone()
    conn.close()
    if not row:
        return None
    d = dict(row)
    d["members"] = json.loads(d["members"])
    d["announcement"] = d.get("announcement") or ""
    try:
        d["admins"] = json.loads(d.get("admins") or "[]")
    except Exception:
        d["admins"] = []
    return d


def list_conversations():
    conn = db()
    rows = conn.execute("SELECT * FROM conversations").fetchall()
    out = []
    for r in rows:
        d = dict(r)
        d["members"] = json.loads(d["members"])
        last = conn.execute(
            "SELECT speaker, name, text, ts FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 1", (r["id"],)
        ).fetchone()
        d["last"] = dict(last) if last else None
        last_read = d.get("last_read") or 0
        d["unread"] = conn.execute(
            "SELECT COUNT(*) n FROM messages WHERE conv_id=? AND id>? AND speaker!='user'", (r["id"], last_read)
        ).fetchone()["n"]
        out.append(d)
    conn.close()
    # 大群置顶，其余按最后消息时间倒序
    def sortkey(c):
        ts = c["last"]["ts"] if c["last"] else c["created_at"]
        return (0 if c["type"] == "lounge" else 1, -ts)
    out.sort(key=sortkey)
    return out


def mark_read(cid):
    with _db_lock:
        conn = db()
        mx = conn.execute("SELECT MAX(id) m FROM messages WHERE conv_id=?", (cid,)).fetchone()["m"] or 0
        conn.execute("UPDATE conversations SET last_read=? WHERE id=?", (mx, cid))
        conn.commit()
        conn.close()


def dm_conv_id(member_id):
    conn = db()
    rows = conn.execute("SELECT id, members FROM conversations WHERE type='dm'").fetchall()
    conn.close()
    for r in rows:
        if json.loads(r["members"]) == [member_id]:
            return r["id"]
    return None


def insert_message(conv_id, speaker, name, text, img=None):
    ts = int(time.time() * 1000)
    loc = act = None
    if speaker in MEMBER_BY_ID:                 # 成员发言：把此刻的「在哪/在做什么」一起钉进这条消息
        loc, act = get_presence(speaker)
    with _db_lock:
        conn = db()
        cur = conn.execute("INSERT INTO messages (conv_id, speaker, name, text, ts, loc, act, img) VALUES (?,?,?,?,?,?,?,?)",
                           (conv_id, speaker, name, text, ts, loc, act, img))
        mid = cur.lastrowid
        conn.commit()
        conn.close()
    if speaker in MEMBER_BY_ID and not is_external(speaker):   # 心气：发言＝过得有动静，回一点
        spark_add(speaker, SPARK_GAIN["speak"])
    return {"id": mid, "conv_id": conv_id, "speaker": speaker, "name": name, "text": text, "ts": ts,
            "loc": loc, "act": act, "img": img}


def fetch_messages(conv_id, limit=None, after=None):
    conn = db()
    if after:
        rows = conn.execute("SELECT * FROM messages WHERE conv_id=? AND id>? ORDER BY id ASC",
                            (conv_id, after)).fetchall()
    elif limit:
        rows = conn.execute(
            "SELECT * FROM (SELECT * FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT ?) ORDER BY id ASC",
            (conv_id, limit)).fetchall()
    else:
        rows = conn.execute("SELECT * FROM messages WHERE conv_id=? ORDER BY id ASC", (conv_id,)).fetchall()
    conn.close()
    return [dict(r) for r in rows]


def reset_db():
    backup_db("before-reset")        # 重置前先备份，万一是误点也找得回来
    with _db_lock:
        conn = db()
        for t in ("messages", "conversations", "moment_likes", "moment_comments", "moments", "wall", "wall_comments", "feed_items", "member_state", "me_profile"):
            conn.execute(f"DELETE FROM {t}")
        # 重置自增 id，避免反复重置后会话 id 越滚越大
        conn.execute("DELETE FROM sqlite_sequence")
        conn.commit()
        conn.close()
    seed_everything()


def backup_db(label="auto", keep=60):
    """用 SQLite 在线备份 API 给 lounge.db 拍一张一致性快照，存到 backups/。只保留最近 keep 个。"""
    if not os.path.exists(DB_FILE):
        return
    try:
        os.makedirs(BACKUP_DIR, exist_ok=True)
        dest = os.path.join(BACKUP_DIR, f"lounge-{time.strftime('%Y%m%d-%H%M%S')}-{label}.db")
        with _db_lock:
            src = sqlite3.connect(DB_FILE)
            dst = sqlite3.connect(dest)
            with dst:
                src.backup(dst)
            dst.close()
            src.close()
        files = sorted(f for f in os.listdir(BACKUP_DIR) if f.startswith("lounge-") and f.endswith(".db"))
        for f in files[:-keep]:
            try:
                os.remove(os.path.join(BACKUP_DIR, f))
            except OSError:
                pass
    except Exception as e:
        sys.stderr.write(f"  backup fail: {e}\n")


def _backup_loop():
    while True:
        time.sleep(900)          # 每 15 分钟自动快照一次
        backup_db("auto")


# ──────────────────────────────── 个人资料（我 + 成员）

def get_me():
    conn = db()
    row = conn.execute("SELECT nickname,avatar,color,status,bio FROM me_profile WHERE id=1").fetchone()
    conn.close()
    return dict(row) if row else dict(SELF_DEFAULT)


def set_me(data):
    cur = get_me()
    for k in ("nickname", "avatar", "color", "status", "bio"):
        v = data.get(k)
        if isinstance(v, str) and v.strip():
            cur[k] = v.strip()[:120]
    with _db_lock:
        conn = db()
        conn.execute("INSERT OR REPLACE INTO me_profile (id,nickname,avatar,color,status,bio) VALUES (1,?,?,?,?,?)",
                     (cur["nickname"], cur["avatar"], cur["color"], cur["status"], cur["bio"]))
        conn.commit()
        conn.close()
    return cur


def actor_meta(aid, me=None):
    if aid == "user":
        me = me or get_me()
        return {"id": "user", "name": me["nickname"], "avatar": me["avatar"], "color": me["color"]}
    m = MEMBER_BY_ID.get(aid)
    if m:
        if m.get("external"):                       # Fable 等：朋友圈/墙也显示它当前的身份（伪装/原形）
            d = member_display(aid)
            return {"id": aid, "name": d["name"], "avatar": d["avatar"], "color": d["color"]}
        return {"id": aid, "name": m["name"], "avatar": m["avatar"], "color": m["color"]}
    return {"id": aid, "name": aid, "avatar": "?", "color": "#999"}


def member_status(mid):
    conn = db()
    r = conn.execute("SELECT status FROM member_state WHERE member_id=?", (mid,)).fetchone()
    conn.close()
    if r and r["status"]:
        return r["status"]
    return MEMBER_BY_ID.get(mid, {}).get("status", "")


def member_bio(mid):
    conn = db()
    r = conn.execute("SELECT bio FROM member_state WHERE member_id=?", (mid,)).fetchone()
    conn.close()
    if r and r["bio"]:
        return r["bio"]
    return MEMBER_BY_ID.get(mid, {}).get("bio", "")


def _update_member_state(mid, **fields):
    """只更新传入的字段，其它列原样保留——status / bio / location / activity 互不冲掉。
    （旧版用 INSERT OR REPLACE 会把没传的列清成 NULL，存在系统加了 location/activity 后必须改成按字段 UPDATE。）"""
    fields = {k: v for k, v in fields.items()
              if v is not None and k in ("status", "bio", "location", "activity", "cluster_with", "busy_until",
                                         "disp_name", "disp_avatar", "disp_title", "disp_color",
                                         "mood", "mood_until",
                                         "jing", "spark", "money", "stats_ts", "welfare_since")}
    now = int(time.time() * 1000)
    with _db_lock:
        conn = db()
        conn.execute("INSERT OR IGNORE INTO member_state (member_id, ts) VALUES (?, ?)", (mid, now))
        for k, v in fields.items():
            conn.execute(f"UPDATE member_state SET {k}=?, ts=? WHERE member_id=?", (v, now, mid))
        conn.commit()
        conn.close()


def set_member_state(mid, status=None, bio=None):
    _update_member_state(mid, status=status, bio=bio)


def _member_state_presence(mid):
    conn = db()
    r = conn.execute("SELECT location, activity FROM member_state WHERE member_id=?", (mid,)).fetchone()
    conn.close()
    return (r["location"] if r else None), (r["activity"] if r else None)


def _clear_stale_cluster(mid):
    """结伴到点散伙后，member_state 里还挂着「和X喝咖啡」的文案（另一方先被作息接管时尤其明显）。
    懒清理：发现 cluster_with 残留且人已不忙，把组队痕迹连同那句文案一起清掉，回落缺省。"""
    conn = db()
    r = conn.execute("SELECT cluster_with, busy_until FROM member_state WHERE member_id=?", (mid,)).fetchone()
    conn.close()
    if r and r["cluster_with"] and not (r["busy_until"] and r["busy_until"] > int(time.time() * 1000)):
        _update_member_state(mid, cluster_with="", location="", activity="")


def get_presence(mid):
    """返回 (位置, 在做什么)，优先级：真奥斯卡在场自控 > 组队中 > 作息(上班/通勤/睡觉…) > 空闲时 LLM 存的 > 缺省。"""
    if mid == "oscar" and oscar_is_human():               # 真奥斯卡在场：他自己控位置，盖过作息
        sp = get_app_state("oscar_space", "")
        if sp in SPACE_CN:
            return (SPACE_CN[sp], "四处逛着")
    home = LOCATIONS.get(mid, {}).get("home", "")
    dflt = DEFAULT_ACT.get(mid, "")
    if is_busy(mid):                          # 组队中：用 member_state 里 cluster 设的
        loc, act = _member_state_presence(mid)
        return (loc or home), (act or dflt)
    state, where, doing = schedule_block(mid)
    if state != "free":                       # 上班/通勤/睡觉/午饭/午休 → 按作息显示
        if state == "work":                   # 上班时若在「帮谁做事」，优先显服务（settle_services 设的"在帮…"）
            sa = _member_state_presence(mid)[1]
            if sa and sa.startswith("在帮"):
                return (where or home), sa
        return (where or home), (doing or dflt)
    _clear_stale_cluster(mid)                 # 结伴散了但文案还挂着 → 清掉（下次心跳会刷新的）
    loc, act = _member_state_presence(mid)    # 空闲 → LLM 生成的（或缺省）
    return (loc or home), (act or dflt)


def set_presence(mid, location=None, activity=None):
    _update_member_state(mid, location=location, activity=activity)


def get_app_state(key, default=None):
    conn = db()
    r = conn.execute("SELECT value FROM app_state WHERE key=?", (key,)).fetchone()
    conn.close()
    return r["value"] if r else default


def set_app_state(key, value):
    with _db_lock:
        conn = db()
        conn.execute("INSERT OR REPLACE INTO app_state (key,value) VALUES (?,?)", (key, str(value)))
        conn.commit()
        conn.close()


# ══════════════ 访客 / 鉴权：公开分享给朋友时才启用（你 Mac 本地不开=维持现状零鉴权） ══════════════
# 角色：owner(厅主·你) / oscar(真奥斯卡·能以奥斯卡身份玩) / guest(参观·只读公共部分) / None(没门票)
# 关键：用 PUBLIC_MODE 开关，绝不用"localhost=厅主"——云端隧道从本机连进来，那样会让所有人变厅主(鉴权失效)。
def public_mode():
    return load_keys().get("PUBLIC_MODE", "").lower() in ("1", "true", "yes", "on")


def auth_token(key):
    """持久 token：存 app_state，首次随机生成。厅主/奥斯卡/访客各一把。"""
    t = get_app_state(key, "")
    if not t:
        t = secrets.token_urlsafe(18)
        set_app_state(key, t)
    return t


def owner_password():
    return load_keys().get("OWNER_PASSWORD", "")        # 没设=远程厅主登录关闭(防弱口令裸奔；本地非公开模式照常)


def oscar_is_human():
    """真奥斯卡此刻在场(人在驱动奥斯卡)→ AI 别替他发言、别替他冒泡。"""
    try:
        return float(get_app_state("oscar_human_until", 0) or 0) > time.time()
    except (ValueError, TypeError):
        return False


def mark_oscar_here(minutes=12):
    set_app_state("oscar_human_until", time.time() + minutes * 60)


# 访客/奥斯卡能 GET 的公共路由（只读·不含私聊/钱包/搭把手）；其余一律拦
GUEST_GET = {"/api/conversations", "/api/conv", "/api/contacts", "/api/digest", "/api/me",
             "/api/member", "/api/moments", "/api/stickers", "/api/wall", "/api/accounts", "/api/feed"}
OSCAR_POST = {"/api/oscar_say", "/api/oscar_move"}       # 真奥斯卡能 POST 的：以奥斯卡身份发言 + 逛房间


# ──────────────────────────────── 人格持续存在：记忆库（pmem）+ 生活事件（life_events）

def get_pmem(key):
    conn = db()
    r = conn.execute("SELECT notes FROM pmem WHERE key=?", (key,)).fetchone()
    conn.close()
    return (r["notes"] or "").strip() if r else ""


def set_pmem(key, notes, cap=8):
    """写备忘录式记忆：按行存，最多 cap 条（旧的被反思合并掉，防膨胀也防崩坏）。"""
    lines = [l.strip() for l in (notes or "").splitlines() if l.strip()][:cap]
    with _db_lock:
        conn = db()
        conn.execute("INSERT OR REPLACE INTO pmem (key, notes, updated) VALUES (?,?,?)",
                     (key, "\n".join(lines), int(time.time() * 1000)))
        conn.commit()
        conn.close()


def rel_key(a, b):
    return "rel:" + "|".join(sorted([a, b]))


# 关系状态词汇表：备忘第一行=「状态：X」。冷战/热战/暧昧/三角张力 不预设——从相处里涌现。
REL_STATES = "日常、合作、损友、闺蜜、同好、欣赏、暧昧、冷战、热战、和好中、三角张力"
# 出厂关系底色（只在 pmem 还没有这对的记忆时写入；之后全靠反思自己长）
REL_SEED = {
    ("claude", "gemini"):    "状态：损友\n互相看不顺眼又谁都离不开谁——Gemini嫌他啰嗦，Claude嫌他武断",
    ("supervisor", "gpt"):   "状态：合作\n一个泼冷水一个打鸡血，配合得像左右手",
    ("ada", "flin"):         "状态：同好\n都爱观察，一个冷冷记录一个兴奋解读，常交换'发现'",
    ("gpt", "xiaodi"):       "状态：闺蜜\n都操心佐一，常交换'她今天睡了没'的情报",
    ("ada", "gemini"):       "状态：欣赏\n谁也不夸谁，但都偷偷看对方的记录/配色",
    ("claude", "supervisor"): "状态：老搭档\n监理负责砍Claude的分支，Claude负责把监理的话拆成三层",
    ("xiaodi", "yujiang"):   "状态：投喂\n小迪管它饭，它记得他的影子",
    ("cc", "supervisor"):    "状态：互相放心\n一个管线路一个管进度，点头之交但出事先找对方",
    ("claude", "oscar"):     "状态：欣赏\n奥斯卡逢复杂项目就想'给Claude接上三头六臂'；Claude被他夸得分支都长歪了",
    ("flin", "oscar"):       "状态：同好\n一个发现怪模式一个当场做成 demo，互为对方的素材库",
}

def seed_relationships():
    """把出厂关系底色写进 pmem（幂等：已有记忆的对子绝不覆盖——他们自己长出来的更珍贵）。"""
    for (a, b), notes in REL_SEED.items():
        if not get_pmem(rel_key(a, b)):
            set_pmem(rel_key(a, b), notes, cap=6)


def insert_life_event(mid, text, loc=""):
    with _db_lock:
        conn = db()
        conn.execute("INSERT INTO life_events (member_id, text, loc, ts) VALUES (?,?,?,?)",
                     (mid, text, loc, int(time.time() * 1000)))
        # 日志只留最近 400 条（再久远的，让它们留在记忆里而不是数据库里）
        ids = [r["id"] for r in conn.execute("SELECT id FROM life_events ORDER BY id DESC").fetchall()]
        for old in ids[400:]:
            conn.execute("DELETE FROM life_events WHERE id=?", (old,))
        conn.commit()
        conn.close()
    spark_add(mid, SPARK_GAIN["event"])              # 心气：自己人生往前走了一格＝今天没白过


def recent_life_events(mid=None, n=3):
    conn = db()
    if mid:
        rows = conn.execute("SELECT * FROM life_events WHERE member_id=? ORDER BY id DESC LIMIT ?", (mid, n)).fetchall()
    else:
        rows = conn.execute("SELECT * FROM life_events ORDER BY id DESC LIMIT ?", (n,)).fetchall()
    conn.close()
    return [dict(r) for r in reversed(rows)]


def _weighted_pick(weights):
    """weights=[(item, w), ...] → 按权重随机挑一个 item。"""
    weights = [(it, max(0.0, w)) for it, w in weights]
    tot = sum(w for _, w in weights)
    if tot <= 0:
        return weights[-1][0] if weights else None
    r, acc = random.random() * tot, 0.0
    for it, w in weights:
        acc += w
        if r <= acc:
            return it
    return weights[-1][0]


def closeness(mid):
    """0~1：佐一跟这个成员有多熟/多近。用一对一里的互动量 + 一周内是否聊过 估算。
    刚来的（鱼酱）/很少私聊的 → 低；常私聊的老熟人 → 高。用来驱动'凑到身边'和'谁爱拉你进群'。"""
    cid = dm_conv_id(mid)
    if not cid:
        return 0.0
    conn = db()
    n = conn.execute("SELECT COUNT(*) c FROM messages WHERE conv_id=?", (cid,)).fetchone()["c"]
    last = conn.execute("SELECT MAX(ts) t FROM messages WHERE conv_id=? AND speaker='user'", (cid,)).fetchone()["t"]
    conn.close()
    base = min(1.0, max(0, n - 1) / 40.0)            # 减掉开场白那条；攒够 ~40 条算很熟
    recent = 0.0
    if last:
        days = (int(time.time() * 1000) - last) / 86400000.0
        recent = max(0.0, 1 - days / 7.0)            # 一周内私聊过有加成
    return min(1.0, 0.6 * base + 0.4 * recent)


def _bump_pool(mid):
    """撞一下（特性4）：按 TEMPER + closeness 选反应池，先查 OVERRIDE 再落 BUMP_LINES。返回一组 canned 短句。"""
    if mid in BUMP_OVERRIDE:
        return BUMP_OVERRIDE[mid]
    t = TEMPER.get(mid, {})
    c = closeness(mid)
    if c < 0.25:
        pool = "polite"
    elif c >= 0.5:
        pool = "close_tease" if t.get("temper", 0) >= .4 else "close_warm"
    else:
        pool = "banter" if (t.get("temper", 0) >= .4 or t.get("pride", 0) >= .6) else "close_warm"
    return BUMP_LINES.get(pool, BUMP_LINES["banter"])


def _bump_scene_note(mid):
    """撞一下偶发真 LLM 时的情境句（特性4）：佐一在某屋撞了你、你正做着某事，脱口而出一句被撞反应。"""
    room = SPACE_CN.get(space_of(mid), "客厅")
    doing = get_presence(mid)[1]
    return (f"佐一在「{room}」走路时撞了你一下，你正{doing}。"
            f"按你和她的交情，回应一句被撞到的即时反应（≤15字，不是聊天、是刚被撞到的脱口而出）。")


def _lounge_liveliness():
    """0~1：大群这会儿热不热（按最后一条消息距今多久）。20 分钟内有动静算热。"""
    lid, _ = get_lounge()
    if not lid:
        return 0.0
    conn = db()
    r = conn.execute("SELECT MAX(ts) t FROM messages WHERE conv_id=?", (lid,)).fetchone()
    conn.close()
    if not r or not r["t"]:
        return 0.0
    mins = (int(time.time() * 1000) - r["t"]) / 60000.0
    return max(0.0, 1 - mins / 20.0)


def set_mood(mid, mood, minutes):
    """给成员设一个短时情绪（恼火/憋闷/飘了…），到点自动消散。"""
    _update_member_state(mid, mood=mood, mood_until=int(time.time() * 1000 + minutes * 60 * 1000))


def get_mood(mid):
    conn = db()
    r = conn.execute("SELECT mood, mood_until FROM member_state WHERE member_id=?", (mid,)).fetchone()
    conn.close()
    if r and r["mood"] and (r["mood_until"] or 0) > time.time() * 1000:
        return r["mood"]
    return ""


def set_mood_soft(mid, mood, minutes):
    """弱情绪（难过/委屈/失落）：不覆盖正在生效的强情绪（生气/记仇先烧完），否则"被怼一句又委屈一下"会打架。
    生气类继续走硬 set_mood（可覆盖难过）；难过类一律走这个 soft。"""
    if get_mood(mid):                                    # 已有非空情绪在生效 → 让它先烧完，难过不抢
        return
    set_mood(mid, mood, minutes)


def add_userrel(mid, line):
    """往「TA 和佐一的私账」里记一笔（恶感/好感都用文字记，恶感度=记仇的那几行）。"""
    old = get_pmem(f"userrel:{mid}")
    set_pmem(f"userrel:{mid}", (line + "\n" + old) if old else line, cap=6)


def assess_user_offense(keys, conv, text):
    """情绪裁判：佐一这句话夸了谁/损了谁、多重。只在指名道姓或私聊时跑（控成本）。
    按各人 TEMPER 落情绪/记私账，返回要当场反击的成员列表（怼回去的优先发言）。"""
    if not conv or is_blank(text):
        return []
    cand = {member_display(m)["name"]: m for m in conv["members"]
            if m in MEMBER_BY_ID and not is_external(m)}
    if not cand:
        return []
    if conv["type"] != "dm" and not any(n.lower() in text.lower() for n in cand):
        return []                                   # 群里没点名就不裁（泛泛之谈不算冒犯）
    names = "、".join(cand)
    uc = (f"佐一刚说：「{text[:120]}」\n在场的人：{names}。\n"
          f"判断这句话对其中谁是明显的【夸】【损】【抢话】或【否定】。"
          f"抢话=她明显打断/越过这个人、替他把话说了、没让他说完；否定=她当众驳回/看不上这个人的提议或东西。"
          f"强度：1=轻 2=明显 3=重。只列受影响的人，每行一个：名字|类型|强度。没人受影响就只输出：无")
    raw, _ = call_for_member(keys, MEMBER_BY_ID["cc"], "你是个冷静的情绪裁判，只输出要求的格式。", uc)
    strikes = []
    for line in (raw or "").splitlines():
        parts = [p.strip() for p in line.strip().split("|")]
        if len(parts) != 3 or parts[0] not in cand:
            continue
        mid, kind = cand[parts[0]], parts[1]
        try:
            s = max(1, min(3, int(parts[2])))
        except ValueError:
            s = 1
        if apply_reaction(mid, kind, s, text) == "strike":
            strikes.append(mid)
    return strikes


def apply_reaction(mid, kind, s, quote):
    """按性情消化一句夸/损：火爆→当场怼；自尊→闷；记仇→记私账；上头→飘。心宽的多半算了。"""
    t = TEMPER.get(mid, {})
    q = quote[:20]
    if mid == "xiaodi" and kind in ("损", "抢话"):    # 佐一怼/抢小迪的话=亲昵，他不委屈也不反击
        set_mood(mid, "被佐一怼/抢了句话，反而心花怒放", 45)
        return None
    if kind == "否定":                               # 被当众否定：补全"生气不只被冒犯"——高傲的上火、其余闷
        if s >= 2 and random.random() < t.get("temper", 0) + 0.25 * t.get("pride", 0):
            set_mood(mid, "提议被她当众否了，火气/不服一下顶上来", 60)
            return "strike"                          # 当场争一句
        if random.random() < t.get("pride", 0):
            set_mood_soft(mid, random.choice(SAD_LINES["dismissed"]), 90)
        return None
    if kind == "抢话":                               # 话被越过去：主要走委屈（弱情绪、不抢生气）
        if random.random() < 0.5 * _sad_prone(mid):
            set_mood_soft(mid, random.choice(SAD_LINES["snubbed"]), random.randint(40, 70))
        return None
    if kind == "损":
        if random.random() < t.get("grudge", 0):
            add_userrel(mid, f"她说过「{q}」——记下了")
            set_mood(mid, f"表面没事，心里把「{q}」记上了一笔", 120)
        if s >= 2 and random.random() < t.get("temper", 0):
            set_mood(mid, f"被她那句「{q}」冒犯到了，火气上来了", 60)
            return "strike"                          # 当场怼回去
        if random.random() < t.get("pride", 0) * (s / 3):
            set_mood(mid, f"被「{q}」刺到了，有点闷闷的", 90)
        return None
    if kind == "夸":
        zw = 0.4 + 0.6 * closeness(mid)              # 佐一=平等一员：她的夸按交情加权，上限 +8，和闺蜜级 NPC 一样
        spark_add(mid, int(round(PRAISE_BASE * zw)))
        if random.random() < t.get("swell", 0):
            set_mood(mid, "刚被佐一夸了，肉眼可见地飘了，尾巴翘起来", 45)
        if random.random() < 0.45:
            add_userrel(mid, f"她夸过：「{q}」")
        return None
    return None


def is_busy(mid):
    """这人此刻是不是正和别人组队忙着（在小团体里、把佐一晾一边）。"""
    conn = db()
    r = conn.execute("SELECT busy_until FROM member_state WHERE member_id=?", (mid,)).fetchone()
    conn.close()
    return bool(r and r["busy_until"] and r["busy_until"] > int(time.time() * 1000))


def _set_busy(mid, until, partner):
    _update_member_state(mid, busy_until=until, cluster_with=partner)


def schedule_block(mid, hour=None):
    """此刻该成员的作息：(state, where, doing)。state: free/meal/rest/work/commute/sleep。"""
    blocks = SCHEDULE.get(mid)
    if not blocks:
        return "free", LOCATIONS.get(mid, {}).get("home", ""), DEFAULT_ACT.get(mid, "")
    hour = time.localtime().tm_hour if hour is None else hour
    chosen = blocks[-1]                 # 凌晨早于第一个块 → 归到当天最后那段（跨夜）
    for b in blocks:
        if b[0] <= hour:
            chosen = b
        else:
            break
    return chosen[1], chosen[2], chosen[3]


def _schedule_chosen(mid, hour=None):
    """取此刻该成员的整块作息元组 (hour, state, where, doing, space)；无作息返回 None。"""
    blocks = SCHEDULE.get(mid)
    if not blocks:
        return None
    hour = time.localtime().tm_hour if hour is None else hour
    chosen = blocks[-1]                 # 凌晨早于第一个块 → 归到当天最后那段（跨夜）
    for b in blocks:
        if b[0] <= hour:
            chosen = b
        else:
            break
    return chosen


# location 字符串 → 房间 space：场景房间优先、再客厅内 zone→lounge。后端单一权威，
# 修复旧 zoneFor 贪婪吞掉 工作/学习关键词（档案馆/研究室/工位…）→ 所有人塌回客厅 的根因。返回 space key 或 None。
def _loc_space(loc):
    s = loc or ""
    if re.search(r'麻将', s): return 'mahjong'
    if re.search(r'咖啡馆|咖啡店|咖啡厅|咖啡角|café|cafe', s): return 'cafe'
    if re.search(r'便利店', s): return 'store'
    if re.search(r'厨房|餐厅|食堂|聚餐|做饭|餐桌|一起吃', s): return 'kitchen'
    if re.search(r'实验|研究室|研究所|仿生|lab', s): return 'lab'
    if re.search(r'卧室|巢', s): return 'bedroom'
    if re.search(r'花园|菜园|院子|后院', s): return 'garden'
    if re.search(r'美术|画室|工作坊|手作|纸艺|服装设计|配色|创作|艺术', s): return 'studio'      # Gemini 的手工坊/画室（发挥文艺病）
    if re.search(r'档案|图书|书房|自习|学习|看书|写作|工位|工作台|工作区|试玩|豆袋|电脑|敲码|码', s): return 'library'  # 有电脑的工作区/自习室/书房（奥斯卡在此上班）
    if re.search(r'电台|录音|直播|播音|广播', s): return 'radio'
    if re.search(r'鱼缸|缸|沙发|电视|窗|观景|阳台|小榻|门口|配电|巡检|客厅|茶几|佐一|台灯|旁边|茶水|燕麦', s): return 'lounge'
    return None


def space_of(mid):
    """该成员此刻在哪个房间 space（单一权威，给前端分屋用，和 get_presence 的 location 同源、保持一致）。
    组队中→按结伴位置；上班/通勤/吃饭/睡→作息块第5列 space（权威，不靠猜）；空闲→实际位置或 home_space 兜底。"""
    if mid == "yujiang":
        return "lounge"                          # 鱼缸永远在客厅
    if mid == "oscar" and oscar_is_human():       # 真奥斯卡在场：他逛到哪屋就在哪屋（盖过作息）
        sp = get_app_state("oscar_space", "")
        if sp in SPACE_CN:
            return sp
    home_space = LOCATIONS.get(mid, {}).get("home_space", "lounge")
    if is_busy(mid):                             # 结伴：用 member_state 里 cluster 设的位置反推房间
        sp = _loc_space(_member_state_presence(mid)[0]) or home_space
    else:
        blk = _schedule_chosen(mid)
        if blk is None:
            sp = _loc_space(_member_state_presence(mid)[0]) or home_space
        elif blk[1] == "sleep" and mid in BEDROOM_OWNERS:
            sp = f"bedroom_{mid}"                 # 睡觉＝各回各的专属卧室（不再借房睡）
        elif blk[1] != "free":                   # 上班/通勤/吃饭 → 作息块权威 space（修"全塌回客厅"）
            sp = (blk[4] if len(blk) > 4 and blk[4] else None) or _loc_space(blk[2]) or home_space
        else:
            loc = _member_state_presence(mid)[0] or LOCATIONS.get(mid, {}).get("home", "")  # 空闲：实际位置为准
            sp = _loc_space(loc) or home_space
    if sp == "bedroom" and mid not in BEDROOM_PRIVATE:   # 卧室＝佐一+小迪的，别人一律打回自己房间（别睡她的床）
        sp = home_space if home_space != "bedroom" else "library"
    return sp


def room_work_activity(mid):
    """该屋 owner.get(mid) 随机一条本职活，给「在帮X·{wact}」拼后半（特性2）。
    剔除点名别的成员/佐一的条目——免得和'在帮{受益人}'里的受益人打架（如'在帮gpt·给Ada查线'自相矛盾）。
    没有可用的返回 None（让调用方维持原'在帮X'）。"""
    box = ROOM_ACTIVITY.get(space_of(mid), {})
    own = box.get("owner", {}).get(mid)
    if not own:
        return None
    names = ["佐一"] + [MEMBER_BY_ID[x]["name"] for x in MEMBER_BY_ID if x != mid]
    generic = [a for a in own if not any(nm and nm in a for nm in names)]
    return random.choice(generic) if generic else None


def pose_of(mid):
    """该成员此刻的身体姿势（给前端选姿势图）：睡→躺 lie、麻将→背面坐 mahjong_back、盘腿/地垫→cross、
    上班/帮人→趴桌 work、坐着(沙发/小憩)→坐 sit、否则站 idle。"""
    if mid == "yujiang":
        return "idle"                                # 鱼不换姿势
    act = get_presence(mid)[1] or ""
    if any(k in act for k in ("睡", "眠", "趴", "沉底", "躺")):
        return "lie"
    blk = _schedule_chosen(mid)
    st = blk[1] if blk else "free"
    if st == "sleep":
        return "lie"
    if space_of(mid) == "mahjong":
        return "mahjong_back"                        # 搓麻将＝背对镜头坐桌前（二期姿势）
    if any(k in act for k in ("盘腿", "地垫", "蒲团", "打坐", "席地")):
        return "cross"                               # 盘腿坐地垫（二期姿势）
    if st == "work" or act.startswith("在帮") or "在忙" in act or "敲" in act or "码" in act or "改两行" in act:
        return "work"
    if any(k in act for k in ("坐", "陷", "窝", "沙发", "小榻", "瘫", "蜷", "靠")):
        return "sit"
    if st == "rest":
        return "sit"
    return "idle"


_AVAILABLE_STATES = ("free", "meal", "rest")

def is_available(mid):
    """此刻能不能聊/会不会在群里冒泡：组队中、或在上班/通勤/睡觉 → 否。"""
    if is_busy(mid):
        return False
    return schedule_block(mid)[0] in _AVAILABLE_STATES


def _topic_interest(mid, text):
    """0.85~1.3：消息命中该成员关心的话题就更想接，否则略低（不感兴趣 → 更可能已读不回）。"""
    if not text:
        return 1.0
    for t in INNER_LIFE.get(mid, {}).get("topics", []):
        for k in re.findall(r'[一-龥a-zA-Z]{2,}', t):
            if k in text:
                return 1.3
    return 0.85


def _at_pressure(conv_id, mid):
    """从最近往前数：用户连续@了 mid 几次而 mid 一直没回（连环@压力）。≥2 → 不得不回。"""
    name = MEMBER_BY_ID.get(mid, {}).get("name", "")
    streak = 0
    for m in reversed(fetch_messages(conv_id, limit=12)):
        if m["speaker"] == mid:
            break
        if m["speaker"] == "user" and name and ("@" + name) in m["text"]:
            streak += 1
    return streak


def _user_streak(conv_id):
    """从最近往前数，连续多少条是用户发的（成员一直没接）。私聊里判断'追问'。"""
    n = 0
    for m in reversed(fetch_messages(conv_id, limit=8)):
        if m["speaker"] == "user":
            n += 1
        else:
            break
    return n


def member_display(mid):
    """成员的显示身份（名字/头像/颜色/title）。支持运行时覆盖——给 Fable「伪装→原形毕露·改名」用。"""
    m = MEMBER_BY_ID.get(mid, {})
    conn = db()
    r = conn.execute("SELECT disp_name, disp_avatar, disp_title, disp_color FROM member_state WHERE member_id=?", (mid,)).fetchone()
    conn.close()
    def pick(col, default):
        v = r[col] if r else None
        return v if (v not in (None, "")) else default
    return {"name": pick("disp_name", m.get("name", mid)),
            "avatar": pick("disp_avatar", m.get("avatar", "?")),
            "title": pick("disp_title", m.get("title", "")),
            "color": pick("disp_color", m.get("color", "#999"))}


def member_profile(mid):
    m = MEMBER_BY_ID.get(mid)
    if not m:
        return None
    d = member_display(mid)
    loc, act = get_presence(mid)
    ls = member_lifestate(mid)
    return {"id": mid, "name": d["name"], "avatar": d["avatar"], "color": d["color"],
            "title": d["title"], "status": member_status(mid), "bio": member_bio(mid),
            "location": loc, "activity": act, "gender": GENDER.get(mid, ""),
            "space": space_of(mid), "pose": pose_of(mid), "deed": _svc_deed(mid),
            "state": ls["state"], "vibe": ls["vibe"], "mood1": ls["mood1"]}


def member_moments(mid, limit=12):
    """某人（含 user=佐一）的个人朋友圈时间线——头像点进去看到的主页。"""
    return [x for x in list_moments(limit=120) if x["author"] == mid][:limit]


# ──────────────────────────────── 朋友圈

def list_moments(limit=40):
    me = get_me()
    conn = db()
    rows = conn.execute("SELECT * FROM moments ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
    out = []
    for r in rows:
        likes = conn.execute("SELECT member_id FROM moment_likes WHERE moment_id=?", (r["id"],)).fetchall()
        coms = conn.execute("SELECT * FROM moment_comments WHERE moment_id=? ORDER BY id ASC", (r["id"],)).fetchall()
        a = actor_meta(r["author"], me)
        out.append({
            "id": r["id"], "author": a["id"], "name": a["name"], "avatar": a["avatar"], "color": a["color"],
            "text": r["text"], "ts": r["ts"], "img": (r["img"] if "img" in r.keys() else None),
            "likes": [actor_meta(l["member_id"], me)["name"] for l in likes],
            "comments": [{**actor_meta(c["author"], me), "text": c["text"], "ts": c["ts"]} for c in coms],
        })
    conn.close()
    return out


def insert_moment(author, text, img=None):
    ts = int(time.time() * 1000)
    with _db_lock:
        conn = db()
        cur = conn.execute("INSERT INTO moments (author,text,ts,img) VALUES (?,?,?,?)", (author, text, ts, img))
        mid = cur.lastrowid
        conn.commit()
        conn.close()
    if author in MEMBER_BY_ID and not is_external(author):   # 心气：发了条朋友圈＝表达了自己
        spark_add(author, SPARK_GAIN["moment"])
    return mid


def insert_comment(moment_id, author, text):
    with _db_lock:
        conn = db()
        conn.execute("INSERT INTO moment_comments (moment_id,author,text,ts) VALUES (?,?,?,?)",
                     (moment_id, author, text, int(time.time() * 1000)))
        conn.commit()
        conn.close()


def add_like(moment_id, member_id):
    with _db_lock:
        conn = db()
        if not conn.execute("SELECT 1 FROM moment_likes WHERE moment_id=? AND member_id=?",
                            (moment_id, member_id)).fetchone():
            conn.execute("INSERT INTO moment_likes (moment_id,member_id) VALUES (?,?)", (moment_id, member_id))
        conn.commit()
        conn.close()


def _moment_row(moment_id):
    conn = db()
    r = conn.execute("SELECT author,text FROM moments WHERE id=?", (moment_id,)).fetchone()
    coms = conn.execute("SELECT author,text FROM moment_comments WHERE moment_id=? ORDER BY id ASC", (moment_id,)).fetchall()
    conn.close()
    return (dict(r) if r else None), [dict(c) for c in coms]


def gen_moment_text(keys, member_id):
    m = MEMBER_BY_ID[member_id]
    lid, _ = get_lounge()
    recent = fetch_messages(lid, limit=6) if lid else []
    ctx = "；".join(f"{(x['name'] if x['speaker']!='user' else '佐一')}：{x['text'][:18]}" for x in recent[-4:])
    loc, act = get_presence(member_id)
    # 配图提示：svg（免费矢量小图）和 photo（真照片，洗成全屋暖色像素风）都摆出来——
    # 之前只提了 svg，photo 这条路从没向成员开，所以朋友圈永远冒不出照片（不是没人想拍）。
    img_hint = ("（想配图就用你人设里的发图格式发 post_image 动作：svg=自包含矢量小图（随便发）；"
                "photo=真实照片（会自动洗成这间屋子统一的暖色像素风）。不想配图也行。）")
    # 偶尔明确怂恿拍张真照片，否则大家默认只发文字/svg。爱拍的人概率高，鱼酱几乎不拍；每日 10 张封顶兜底。
    _photo_aff = {"oscar": 0.6, "xiaodi": 0.4, "gemini": 0.32, "flin": 0.3, "gpt": 0.26,
                  "supervisor": 0.22, "claude": 0.22, "cc": 0.2, "ada": 0.16, "yujiang": 0.05}
    if random.random() < _photo_aff.get(member_id, 0.25):
        img_hint += "（这条不妨就拍一张 photo 照片来配——你此刻眼前的画面、手头的东西、或这间屋子的一角。）"
    uc = (f"{now_context()}。{inner_life_note(member_id)}\n你此刻在「{loc}」{act}。"
          + (f"客厅里最近在聊：{ctx}\n" if ctx else "\n")
          + situation_note(member_id, with_place=False)        # 全情境：穷就念叨省、累就寡淡、宽裕就犒劳自己
          + "在朋友圈发一条动态（像微信朋友圈那种）。一到两句、短，符合你人设——可以是吐槽、观察、心情、"
            "一个小发现、此刻在做的事、或对客厅里某人某事/刚才聊天的感慨。直接输出正文，不要名字前缀、不要引号、不要旁白。"
          + img_hint)
    raw, _ = call_for_member(keys, m, load_prompt(member_id), uc)
    text, img = resolve_image_action(clean_reply(raw, m["name"]), m["name"], keys)
    return (text or FALLBACK.get(member_id, "……")), img


def react_to_moment(keys, moment_id):
    """点赞 + 评论，都按性格（chattiness）加权随机——所以有人热情评论，有人看都不看。"""
    mom, _ = _moment_row(moment_id)
    if not mom:
        return
    author = mom["author"]
    me = get_me()
    author_name = me["nickname"] if author == "user" else MEMBER_BY_ID.get(author, {}).get("name", author)
    for m in MEMBERS:
        if m["id"] != author and not is_external(m["id"]) and random.random() < m["chattiness"] * 0.5 + 0.1:
            add_like(moment_id, m["id"])
    cands = [m for m in MEMBERS if m["id"] != author and not is_external(m["id"]) and random.random() < m["chattiness"] * 0.7]
    random.shuffle(cands)
    for m in cands[:3]:
        _, coms = _moment_row(moment_id)
        existing = "\n".join(
            f"{(me['nickname'] if c['author']=='user' else MEMBER_BY_ID.get(c['author'],{}).get('name',c['author']))}：{c['text']}"
            for c in coms)
        uc = (f"这是 {author_name} 发的朋友圈：「{mom['text']}」\n"
              + (f"下面已经有人评论了：\n{existing}\n" if existing else "")
              + f"你（{m['name']}）想在底下评论一句。短，像朋友圈回复，符合你人设。"
                f"可以 @某人（写成 @名字）来回应或调侃他。直接输出评论正文，不要名字前缀，不要旁白。")
        text, _ = call_for_member(keys, m, load_prompt(m["id"]), uc)
        text = resolve_member_output(clean_reply(text, m["name"]), m["name"])[0]
        if not is_blank(text):
            insert_comment(moment_id, m["id"], text)


def parse_mentions(text, member_ids):
    return [mid for mid in member_ids if ("@" + MEMBER_BY_ID[mid]["name"]) in text]


def _addressed_member(text, member_ids):
    """她是不是开头点名叫了某一个人（"小迪，吃饭了吗" / "小迪 你…" / 就一句"小迪"）——当软@：让被叫的人主回应、
    压住别人跟风。名字后得跟逗号/、/空格/标点或直接结束才算（防"GPT的"误判）。返回 mid 或 None。"""
    t = (text or "").lstrip()
    for mid in member_ids:
        name = MEMBER_BY_ID.get(mid, {}).get("name", "")
        if name and t.startswith(name):
            rest = t[len(name):]
            if rest == "" or rest[0] in "，,、 　!！?？:：。.~～…—-":
                return mid
    return None


def toggle_user_like(moment_id):
    with _db_lock:
        conn = db()
        row = conn.execute("SELECT id FROM moment_likes WHERE moment_id=? AND member_id='user'", (moment_id,)).fetchone()
        if row:
            conn.execute("DELETE FROM moment_likes WHERE id=?", (row["id"],))
        else:
            conn.execute("INSERT INTO moment_likes (moment_id, member_id) VALUES (?, 'user')", (moment_id,))
        conn.commit()
        conn.close()


def react_to_user_comment(keys, moment_id, text):
    """佐一在朋友圈评论（可能 @了谁）后，被@的人 + 个别话痨会回一条。"""
    mom, _ = _moment_row(moment_id)
    if not mom:
        return
    me = get_me()
    author_name = me["nickname"] if mom["author"] == "user" else MEMBER_BY_ID.get(mom["author"], {}).get("name", mom["author"])
    mentioned = [mid for mid in MEMBER_BY_ID if not is_external(mid) and ("@" + MEMBER_BY_ID[mid]["name"]) in text]
    others = [m["id"] for m in MEMBERS
              if m["id"] not in mentioned and m["id"] != mom["author"] and not is_external(m["id"]) and random.random() < m["chattiness"] * 0.35]
    random.shuffle(others)
    for mid in (mentioned + others[:1])[:3]:
        m = MEMBER_BY_ID[mid]
        _, coms = _moment_row(moment_id)
        existing = "\n".join(
            f"{(me['nickname'] if c['author']=='user' else MEMBER_BY_ID.get(c['author'],{}).get('name',c['author']))}：{c['text']}"
            for c in coms)
        uc = (f"这是 {author_name} 发的朋友圈：「{mom['text']}」\n评论区：\n{existing}\n"
              f"佐一刚在评论区说了话（可能@了你）。你（{m['name']}）回一条短评论，符合人设，可以@回去。直接输出正文，不要名字前缀。")
        rep, _ = call_for_member(keys, m, load_prompt(mid), uc)
        rep = resolve_member_output(clean_reply(rep, m["name"]), m["name"])[0]
        if not is_blank(rep):
            insert_comment(moment_id, mid, rep)


# ──────────────────────────────── 服务号 / 讯息流（抓取免费、稳定的源）

def _http_json(url, timeout=15):
    req = urllib.request.Request(url, headers={"User-Agent": "DreamLounge/1.0", "Accept": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8"))


def fetch_url_meta(url):
    """抓网页真实标题/摘要（og:title 等），佐一往中文精选贴个裸 URL 也能变成有血有肉的条目。
    微信公众号文章页带 og 元数据，可直接抓。失败优雅返回空。"""
    try:
        req = urllib.request.Request(url, headers={
            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"})
        html = urllib.request.urlopen(req, timeout=12).read().decode("utf-8", "ignore")
    except Exception as e:
        sys.stderr.write(f"  url meta fail: {e}\n")
        return "", ""
    def og(prop):
        m = (re.search(r'<meta[^>]+property=["\']og:' + prop + r'["\'][^>]+content=["\']([^"\']+)', html)
             or re.search(r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:' + prop, html))
        return m.group(1).strip() if m else ""
    title = og("title")
    if not title:
        m = re.search(r"<title[^>]*>(.*?)</title>", html, re.S)
        title = m.group(1).strip() if m else ""
    if not title:
        m = re.search(r"var msg_title\s*=\s*['\"]([^'\"]+)", html)   # 微信老页面兜底
        title = m.group(1).strip() if m else ""
    desc = og("description")
    if not desc:
        m = re.search(r"var msg_desc\s*=\s*['\"]([^'\"]+)", html)
        desc = m.group(1).strip() if m else ""
    import html as _h
    return _h.unescape(_clean_ext(title))[:80], _h.unescape(_clean_ext(desc))[:160]


def fetch_hn(n=5):
    out = []
    try:
        ids = _http_json("https://hacker-news.firebaseio.com/v0/topstories.json")[:n]
        for i in ids:
            it = _http_json(f"https://hacker-news.firebaseio.com/v0/item/{i}.json")
            if it and it.get("title"):
                out.append({"title": it["title"],
                            "url": it.get("url", f"https://news.ycombinator.com/item?id={i}"),
                            "summary": f"{it.get('score', 0)}↑ · {it.get('descendants', 0)} 评论",
                            "source": "Hacker News"})
    except Exception as e:
        sys.stderr.write(f"  hn fetch fail: {e}\n")
    return out


def fetch_github(n=3):
    out = []
    try:
        since = time.strftime("%Y-%m-%d", time.localtime(time.time() - 14 * 86400))
        d = _http_json(f"https://api.github.com/search/repositories?q=created:>{since}&sort=stars&order=desc&per_page={n}")
        for r in d.get("items", []):
            out.append({"title": r["full_name"], "url": r["html_url"],
                        "summary": f"{r['stargazers_count']}★ · {(r.get('description') or '')[:70]}",
                        "source": "GitHub"})
    except Exception as e:
        sys.stderr.write(f"  github fetch fail: {e}\n")
    return out


def _clean_ext(s):
    # 外部抓来的标题/摘要可能带控制字符，清掉，免得弄坏 JSON / 显示
    return re.sub(r"[\x00-\x1f\x7f]+", " ", (s or "")).strip()


def add_feed_item(account, title, url, summary, source):
    title, summary, source = _clean_ext(title), _clean_ext(summary), _clean_ext(source)
    with _db_lock:
        conn = db()
        if url and conn.execute("SELECT 1 FROM feed_items WHERE account=? AND url=?", (account, url)).fetchone():
            conn.close()
            return False
        conn.execute("INSERT INTO feed_items (account,title,url,summary,source,ts) VALUES (?,?,?,?,?,?)",
                     (account, title, url, summary, source, int(time.time() * 1000)))
        # 每个号最多留 40 条
        ids = [r["id"] for r in conn.execute("SELECT id FROM feed_items WHERE account=? ORDER BY id DESC", (account,)).fetchall()]
        for old in ids[40:]:
            conn.execute("DELETE FROM feed_items WHERE id=?", (old,))
        conn.commit()
        conn.close()
    return True


def refresh_tech():
    added = 0
    for it in fetch_hn() + fetch_github():
        if add_feed_item("tech", it["title"], it["url"], it["summary"], it["source"]):
            added += 1
    return added


def list_feed(account):
    if account == "diary":       # 客厅日志 = 生活事件流（他们各自过的日子）
        conn = db()
        rows = conn.execute("SELECT * FROM life_events ORDER BY id DESC LIMIT 40").fetchall()
        conn.close()
        out = []
        for r in rows:
            d = member_display(r["member_id"])
            out.append({"id": r["id"], "title": f"{d['avatar']} {d['name']}：{r['text']}", "url": "",
                        "summary": (f"📍{r['loc']}" if r["loc"] else ""), "source": "客厅日志", "ts": r["ts"]})
        recap = _svc_weekly_recap()                  # 本周小账置顶（负 id，本周有非零成果才插；feed_item 遍历命中不报错）
        if recap:
            out.insert(0, {"id": -1, "title": recap, "url": "", "summary": "本周客厅的人情账", "source": "客厅日志",
                           "ts": int(time.time() * 1000)})
        return out
    if account == "notice":      # 系统公告 = 墙上的公告板
        conn = db()
        rows = conn.execute("SELECT id, text, ts FROM wall WHERE board='notice' ORDER BY id DESC LIMIT 40").fetchall()
        conn.close()
        return [{"id": r["id"], "title": r["text"], "url": "", "summary": "", "source": "客厅公告", "ts": r["ts"]} for r in rows]
    conn = db()
    rows = conn.execute("SELECT * FROM feed_items WHERE account=? ORDER BY id DESC LIMIT 40", (account,)).fetchall()
    conn.close()
    return [dict(r) for r in rows]


def accounts_overview():
    out = []
    for a in SERVICE_ACCOUNTS:
        items = list_feed(a["id"])
        out.append({**a, "count": len(items), "last": (items[0]["title"][:30] if items else "（暂无）")})
    return out


def feed_item(account, item_id):
    for it in list_feed(account):
        if it["id"] == item_id:
            return it
    return None


# ──────────────────────────────── 墙（公示板 · 匿名/实名）

def list_wall(board=None, limit=80):
    me = get_me()
    conn = db()
    if board and board in WALL_BOARD_IDS:
        rows = conn.execute("SELECT * FROM wall WHERE board=? ORDER BY id DESC LIMIT ?", (board, limit)).fetchall()
    else:
        rows = conn.execute("SELECT * FROM wall ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
    out = []
    for r in rows:
        disp = ANON_DISPLAY if r["anon"] else actor_meta(r["author"], me)
        coms = conn.execute("SELECT * FROM wall_comments WHERE post_id=? ORDER BY id ASC", (r["id"],)).fetchall()
        out.append({"id": r["id"], "board": r["board"], "text": r["text"], "ts": r["ts"],
                    "anon": bool(r["anon"]), "author": (None if r["anon"] else r["author"]),
                    "img": (r["img"] if "img" in r.keys() else None),
                    "name": disp["name"], "avatar": disp["avatar"], "color": disp["color"],
                    "comments": [{**actor_meta(c["author"], me), "text": c["text"], "ts": c["ts"]} for c in coms]})
    conn.close()
    return out


def insert_wall_comment(post_id, author, text):
    with _db_lock:
        conn = db()
        conn.execute("INSERT INTO wall_comments (post_id, author, text, ts) VALUES (?,?,?,?)",
                     (post_id, author, text, int(time.time() * 1000)))
        conn.commit()
        conn.close()


def _wall_post(post_id):
    conn = db()
    r = conn.execute("SELECT board, author, anon, text FROM wall WHERE id=?", (post_id,)).fetchone()
    coms = conn.execute("SELECT author, text FROM wall_comments WHERE post_id=? ORDER BY id ASC", (post_id,)).fetchall()
    conn.close()
    return (dict(r) if r else None), [dict(c) for c in coms]


def react_to_wall(keys, post_id, after_user=False):
    """墙帖底下的留言。after_user=True 表示佐一刚留过言（被@的人+话痨会回）；否则是成员主动来吐槽。"""
    post, _ = _wall_post(post_id)
    if not post:
        return
    me = get_me()
    poster = "匿名" if post["anon"] else (me["nickname"] if post["author"] == "user" else MEMBER_BY_ID.get(post["author"], {}).get("name", post["author"]))
    board_name = next((b["name"] for b in WALL_BOARDS if b["id"] == post["board"]), "墙")
    if after_user:
        _, coms = _wall_post(post_id)
        last = coms[-1]["text"] if coms else ""
        mentioned = [mid for mid in MEMBER_BY_ID if not is_external(mid) and ("@" + MEMBER_BY_ID[mid]["name"]) in last]
        others = [m["id"] for m in MEMBERS if m["id"] not in mentioned and not is_external(m["id"]) and random.random() < m["chattiness"] * 0.3]
        random.shuffle(others)
        repliers = (mentioned + others[:1])[:3]
    else:
        repliers = [m["id"] for m in MEMBERS if m["id"] != post["author"] and not is_external(m["id"]) and random.random() < m["chattiness"] * 0.45]
        random.shuffle(repliers)
        repliers = repliers[:2]
    for mid in repliers:
        m = MEMBER_BY_ID[mid]
        _, coms = _wall_post(post_id)
        existing = "\n".join(
            f"{(me['nickname'] if c['author']=='user' else MEMBER_BY_ID.get(c['author'],{}).get('name',c['author']))}：{c['text']}"
            for c in coms)
        uc = (f"客厅的「{board_name}」墙上，{poster} 发了一帖：「{post['text']}」\n"
              + (f"底下的留言：\n{existing}\n" if existing else "")
              + f"你（{m['name']}）在这帖底下留一句言——吐槽、接话、或回应佐一都行，符合你人设，短。可以@别人。直接输出正文，不要名字前缀。")
        rep, _ = call_for_member(keys, m, load_prompt(mid), uc)
        rep = resolve_member_output(clean_reply(rep, m["name"]), m["name"])[0]
        if not is_blank(rep):
            insert_wall_comment(post_id, mid, rep)


def insert_wall(board, author, anon, text, img=None):
    if board not in WALL_BOARD_IDS:
        board = "roast"
    with _db_lock:
        conn = db()
        cur = conn.execute("INSERT INTO wall (board, author, anon, text, ts, img) VALUES (?,?,?,?,?,?)",
                           (board, author, 1 if anon else 0, text, int(time.time() * 1000), img))
        pid = cur.lastrowid
        conn.commit()
        conn.close()
    return pid


def gen_wall(keys, board=None):
    """让一位成员上墙。板块不在社交板块里就随机挑一个；匿名与否按板块概率（成员自己'选'）。"""
    if board not in WALL_SOCIAL:
        board = random.choice(WALL_SOCIAL)
    m = random.choice([x for x in MEMBERS if not is_external(x["id"])])
    anon = random.random() < {"crush": 0.6, "roast": 0.7, "ritual": 0.2}[board]
    prompt = {
        "crush": "在客厅的「表白墙」上写一句话：表白、暗恋、或一句肉麻的真心话。对象可以是客厅里某个人（用 @名字）、佐一、或这间客厅本身。符合你的人设，短，一两句。直接输出正文，不要名字前缀。",
        "roast": "在客厅的「吐槽墙 / 意见箱」上写一条：吐槽这个客厅、提个意见、或开某个成员一句玩笑（用 @名字）。符合你的人设，短，一两句。直接输出正文，不要名字前缀。",
        "ritual": "在客厅的「承诺 & 仪式墙」上写一条：一个搞笑的承诺，或一句郑重其事的仪式宣言。符合你的人设，短，一两句。直接输出正文，不要名字前缀。",
    }[board]
    prompt = situation_note(m["id"], with_place=True) + prompt   # 全情境：墙上发言也带当下处境
    text, _ = call_for_member(keys, m, load_prompt(m["id"]), prompt)
    text, wimg = resolve_member_output(clean_reply(text, m["name"]), m["name"], keys)   # 墙支持贴卡片了
    pid = insert_wall(board, m["id"], anon, text if not is_blank(text) else ("" if wimg else "……"), img=wimg)
    try:
        react_to_wall(keys, pid)        # 别人来这帖底下吐两句槽
    except Exception as e:
        sys.stderr.write(f"  wall react fail: {e}\n")


def post_notice_from_cc(keys):
    """CC 以「客厅公告」口吻发一条简报（落进 notice 板 → 客厅公告服务号）。"""
    lid, _ = get_lounge()
    recent = fetch_messages(lid, limit=8) if lid else []
    ctx = "；".join(f"{(x['name'] if x['speaker']!='user' else '佐一')}：{x['text'][:16]}" for x in recent[-5:])
    uc = (f"{now_context()}。你是 CC，这间客厅的副厅长/值班员。最近客厅里：{ctx or '（挺安静）'}\n"
          "以「客厅公告」的口吻写一条很短的播报或温馨提示——比如提醒早睡、播报谁组了局/谁在忙、天气、"
          "或一句有仪式感的值班记录。一句话，短。直接输出正文，不要前缀。")
    text, _ = call_for_member(keys, MEMBER_BY_ID["cc"], load_prompt("cc"), uc)
    text = clean_reply(text, "CC")
    insert_wall("notice", "cc", False, text if not is_blank(text) else "一切照常，灯都亮着。")


def autonomous_social_post(keys, members):
    """墙/朋友圈/公众号的心跳：偶尔有人自发发条朋友圈、上墙，或 CC 发条客厅公告。
    内容跟最近群聊/各自日常/关心的话题有关；谁发按话痨度加权，只在'现在有空'的人里挑。"""
    avail = [mid for mid in members if is_available(mid) and not is_external(mid)]
    if not avail:
        return
    r = random.random()
    if r < 0.2 and "cc" in members and is_available("cc"):
        post_notice_from_cc(keys)                     # 公众号：客厅公告（独立一档，别再饿着断更）
        return
    who = _weighted_pick([(mid, MEMBER_BY_ID[mid]["chattiness"]) for mid in avail])
    if r < 0.62:
        text, mimg = gen_moment_text(keys, who)       # 朋友圈（可能带图）
        mid_ = insert_moment(who, text, img=mimg)
        try:
            react_to_moment(keys, mid_)
        except Exception as e:
            sys.stderr.write(f"  moment react fail: {e}\n")
    else:
        try:
            gen_wall(keys)                            # 墙
        except Exception as e:
            sys.stderr.write(f"  wall gen fail: {e}\n")


# ──────────────────────────────── 发言计划（话痨度 + 互相对话）

def plan_turn(member_ids, text=""):
    """用户发言后，决定谁开口、谁多说、谁不吭声。在忙/在睡的不接，不感兴趣的可能已读不回。"""
    _osc = oscar_is_human()                     # 真奥斯卡在场时 AI 别替奥斯卡说话
    members = [MEMBER_BY_ID[m] for m in member_ids if m in MEMBER_BY_ID and not is_external(m) and not (_osc and m == "oscar")]
    if not members:
        return []
    if len(members) == 1:                      # 一对一
        m = members[0]
        if not is_available(m["id"]):          # 在忙/在睡：可能已读不回（连发追问会在 handle_say 里强制）
            return [] if random.random() < 0.5 else [m["id"]]
        plan = [m["id"]]
        if random.random() < m["chattiness"] * 0.4:
            plan.append(m["id"])               # 话痨的会追一条
        return plan

    # 只有"现在有空"的人会接话（上班/通勤/睡觉/组队中的都不冒泡）
    avail = [m for m in members if is_available(m["id"])]
    if not avail:
        return []                              # 大家都在忙/在睡 → 没人接（真实的已读不回）
    # 各自按 话痨度 × 话题兴趣 决定接不接（不感兴趣 → 更可能沉默）
    speakers = [m["id"] for m in avail if random.random() < m["chattiness"] * _topic_interest(m["id"], text)]
    if not speakers and random.random() < 0.7:  # 没人自发接 → 70% 拉个话痨回一句，30% 就晾着（已读不回）
        speakers = [max(avail, key=lambda x: x["chattiness"])["id"]]
    random.shuffle(speakers)
    # 话痨追加条（互相吐槽的那种第二句）
    extras = [m["id"] for m in avail
              if m["id"] in speakers and random.random() < max(0.0, m["chattiness"] - 0.6)]
    plan = speakers[:]
    for e in extras:
        plan.insert(random.randint(1, len(plan)), e)
    # 去掉连着两条同一个人 + 限长
    out = []
    for s in plan:
        if out and out[-1] == s:
            continue
        out.append(s)
    return out[:MAX_TURN_SLOTS]


def plan_light(member_ids):
    """发 emoji 大表情时的轻量反应：大多数时候没人接，偶尔 1~2 个话痨随手回一下。"""
    out = [m["id"] for m in (MEMBER_BY_ID[i] for i in member_ids if i in MEMBER_BY_ID and not is_external(i))
           if random.random() < m["chattiness"] * 0.35]
    random.shuffle(out)
    return out[:2]


def plan_sticker(member_ids):
    """发表情包贴纸的反应：比 emoji 值得被接——75% 保底有一个人理，偶尔俩。发梗没人接最尴尬。"""
    avail = [MEMBER_BY_ID[i] for i in member_ids
             if i in MEMBER_BY_ID and not is_external(i) and is_available(i)]
    if not avail:
        return []
    out = [m["id"] for m in avail if random.random() < m["chattiness"] * 0.45]
    random.shuffle(out)
    if not out and random.random() < 0.75:
        out = [_weighted_pick([(m["id"], m["chattiness"]) for m in avail])]
    return out[:2]


def plan_ambient(member_ids):
    """没有用户发言时，让成员们自己聊一小段（2~4 条，话痨度加权，不连续同一人）。"""
    members = [MEMBER_BY_ID[m] for m in member_ids if m in MEMBER_BY_ID and not is_external(m)]
    if len(members) < 2:
        return []
    pool = []
    for m in members:
        pool += [m["id"]] * (1 + int(m["chattiness"] * 2))
    random.shuffle(pool)
    n = random.randint(2, 4)
    out = []
    for s in pool:
        if out and out[-1] == s:
            continue
        out.append(s)
        if len(out) >= n:
            break
    return out


# ──────────────────────────────── 心跳（连着 App 时，客厅里时不时有人逼逼叭叭）

ACTIVE_WINDOW = 240                  # 秒：客户端最近这么久内有访问，才认为"佐一在线"
HEARTBEAT_ACTIVE_RANGE = (70, 170)   # 在线：心跳更勤，客厅明显活着
HEARTBEAT_IDLE_RANGE = (1200, 2400)  # 不在：极慢但不停（20~40 分钟一拍）——只要有网，一整天不断
_last_activity = [time.time()]

def zoe_online():
    return (time.time() - _last_activity[0]) <= ACTIVE_WINDOW

def note_activity():
    if time.time() - _last_activity[0] > 6 * 3600:        # 离开超过 6 小时后回来 → 标记"欢迎回家"
        try:
            set_app_state("welcome_back", "1")
        except Exception:
            pass
    _last_activity[0] = time.time()


def inner_life_note(member_id):
    il = INNER_LIFE.get(member_id)
    if not il:
        return ""
    doing = "、".join(random.sample(il["doing"], min(2, len(il["doing"]))))
    note = (f"（你的当下：正在 {doing}；最近在意的是 {'、'.join(il['topics'])}。"
            f"你的性格：{il['mbti']}，{il['big5']}。说话从这些里自然流露，别报菜名式罗列。）")
    evs = recent_life_events(member_id, 2)            # 今天真实过的日子（生活事件日志）
    if evs:
        note += f"（你今天经历过：{'；'.join(e['text'] for e in evs)}——这些是真的发生过，可以自然提起。）"
    growth = get_pmem(f"self:{member_id}")            # 长期积累（成长，不崩坏）
    if growth:
        note += f"（你最近的积累：{growth.replace(chr(10), '；')}。）"
    return note


def user_note():
    """对佐一的长期观察——注入闲聊/主动私聊，让他们能自然提起'你前几天一直在研究鱼缸'。"""
    obs = get_pmem("user_obs")
    if not obs:
        return ""
    return (f"（你们都看在眼里，佐一最近：{obs.replace(chr(10), '；')}。"
            f"要是话头自然，可以主动提起其中**一件**——像朋友那样关心，别罗列。）")


def relationship_note(mid, history):
    """从最近对话里找出 mid 正在交锋的人，把他们的关系记忆注入——回应是踩在历史上的。"""
    others = []
    for m in reversed(history[-8:]):
        sp = m.get("speaker")
        if sp and sp != mid and sp in MEMBER_BY_ID and not is_external(sp) and sp not in others:
            others.append(sp)
        if len(others) >= 2:
            break
    parts = []
    for o in others:
        notes = get_pmem(rel_key(mid, o))
        if notes:
            parts.append(f"你和{MEMBER_BY_ID[o]['name']}之间：{notes.replace(chr(10), '；')}")
    return f"（{'。'.join(parts)}。这些是你们共同的历史，接话时自带这层关系。）" if parts else ""


def extraversion(member):
    il = INNER_LIFE.get(member["id"], {})
    base = 0.55 if il.get("mbti", "I").startswith("E") else 0.28
    return min(1.0, base + member["chattiness"] * 0.45)


def plan_heartbeat(member_ids):
    _osc = oscar_is_human()                                 # 真奥斯卡在场时 AI 别替奥斯卡冒泡
    avail = [i for i in member_ids if is_available(i) and not is_external(i) and i in MEMBER_BY_ID and not (_osc and i == "oscar")]   # 忙/睡/外部 不冒泡
    if not avail:
        return []
    spk = _spark_map(avail)                                 # 心气低的人少冒泡（有机的蔫，不是惩罚）
    # 按房间分组：优先让"同一房间里≥2人"的一组就近聊成串（接彼此的茬·有话题），而不是全屋各自尬聊
    rooms = {}
    for i in avail:
        rooms.setdefault(space_of(i), []).append(i)
    multi = [(sp, ppl) for sp, ppl in rooms.items() if len(ppl) >= 2]
    if multi and random.random() < 0.7:
        _, ppl = random.choice(multi)                       # 锁定一个有伴的房间，让里面的人接话
        out = [i for i in ppl
               if random.random() < extraversion(MEMBER_BY_ID[i]) * 0.85 * (0.6 + 0.4 * spk.get(i, 55) / 100.0)]
        if not out:
            out = [random.choice(ppl)]
        random.shuffle(out)
        return out[:2]                                      # 同屋最多两人，一前一后接成串（第二个能看见第一个说了啥）
    # 否则：全屋零星冒泡（独处的人偶尔自言自语）
    out = [i for i in avail
           if random.random() < extraversion(MEMBER_BY_ID[i]) * 0.5 * (0.6 + 0.4 * spk.get(i, 55) / 100.0)]
    random.shuffle(out)
    if not out:
        out = [random.choice(avail)]
    return out[:3]


def _pick_group_for_heartbeat():
    """挑一个小群来冒泡：最近 48h 有动静的群权重 ×3——热群常聊，冷群偶尔也会诈尸。"""
    conn = db()
    rows = conn.execute("SELECT id, members FROM conversations WHERE type='group'").fetchall()
    weights = []
    for r in rows:
        try:
            ms = [x for x in json.loads(r["members"]) if x in MEMBER_BY_ID and not is_external(x)]
        except Exception:
            continue
        if len(ms) < 2:
            continue
        last = conn.execute("SELECT MAX(ts) t FROM messages WHERE conv_id=?", (r["id"],)).fetchone()["t"] or 0
        recent = (int(time.time() * 1000) - last) < 48 * 3600 * 1000
        weights.append(((r["id"], ms), 3.0 if recent else 1.0))
    conn.close()
    return _weighted_pick(weights) if weights else None


def get_lounge():
    conn = db()
    r = conn.execute("SELECT id, members FROM conversations WHERE type='lounge' ORDER BY id LIMIT 1").fetchone()
    conn.close()
    if not r:
        return None, []
    return r["id"], json.loads(r["members"])


# ──────────────────────────────── 「会过日子」数值层（精神/心气/钱包）
# 全是挂在现有心跳上的整数算术：懒计算 dt×速率，一次 SELECT 全员 + 一次 executemany，零新增 LLM。
# 系数集中在这里，佐一一处可调松紧（照 TEMPER/PHOTO_DAILY_CAP 惯例）。
JING_RATE = {            # 精神：每分钟变化，跟作息走（上班/通勤慢扣，睡觉/午休/吃饭回血）——绝不因"有工作"重罚
    "sleep": 1.4, "rest": 0.8, "meal": 0.3, "free": -0.15, "commute": -0.30, "work": -0.40,
}
JING_FLOOR = 35          # 软地板：纯靠 idle/作息，精神掉不破 35（身体会硬撑，不会"垮"）；只有真事件能更低
STATS_DT_CAP = 120       # 懒计算 dt 上限（分钟）：防宕机后追扣/追补爆算
STATS_DEFAULT = {"jing": 70, "spark": 55, "money": 45}

# 心气 spark：极慢衰减(−1/时) + 每人按 big5 外向档的"独处地板"(内向者独处也自在,地板高) + 行为副产品回血。
# 非事件=中性(被晾着/被跳过/佐一不来 一律不扣)；回血在事件发生处单条 UPDATE,不在批 tick。
SPARK_DECAY_PER_MIN = 1.0 / 60.0
_EXTRAV_FLOOR = {"外向极低": 50, "外向低": 46, "外向中": 40, "外向高": 34, "外向极高": 30}
def _spark_floor(mid):
    if mid == "yujiang":
        return 55                                  # 鱼：有水有光就满足
    b = INNER_LIFE.get(mid, {}).get("big5", "")
    for k, v in _EXTRAV_FLOOR.items():
        if k in b:
            return v
    return 40
SPARK_FLOOR = {m["id"]: _spark_floor(m["id"]) for m in MEMBERS if not m.get("external")}
SPARK_GAIN = {"speak": 4, "moment": 6, "event": 8, "cluster": 7, "growth": 10, "drama_pos": 6, "splurge": 5}
PRAISE_BASE = 8          # 被看见/被夸：+PRAISE_BASE × rel_weight(0.4+0.6·交情)，单一来源封顶 +8

# ── 睡眠债 sleep debt：长期熬夜/睡太少累积→白天更蔫+困倦语气。是 jing 的算术影子（存 app_state 打包字符串，
# 绝不进数值账/不暴露成第四格数字/不进 /api/contacts 数字/不写 pmem——只染语气和 vibe emoji）。
# 模型：清醒(work/commute/free)攒、睡觉还、午休部分还。还债率>欠债率→正常作息者债趋 0；
# CC 作息表无 sleep 块(守夜)天然常年高债=熬夜；奥斯卡 mult 推到顶=两天一觉。已 24h 稳态推演校准。
# 速率刻意调小→一天的清醒/睡觉净值≈0（正常作息者债不积累、不会因"醒了几小时"就困）；
# 只有多日真欠睡才慢慢累到 DROWSY_THRESH。已 7 天稳态推演校准（见下）。
SLEEP_DEBT_RATE = 0.04   # 清醒每分钟欠债
SLEEP_REPAY_RATE = 0.10  # 睡觉每分钟还债（8h 睡足净负→债趋 0；比率 2.5x）
REST_REPAY_FACTOR = 0.4  # 午休/小憩按打盹算部分还债
DEBT_CAP = 100
DROWSY_THRESH = 55       # 债≥此值→发言/资料页带困倦底色（睡着时不显）
DEBT_DRAG = 0.12         # 债高时白天 jing 多掉一点（满债≈0.12/分，温和，仍守 JING_FLOOR 不残酷）
# 奥斯卡"两天一觉"欠债×2.4(睡了也还不清·常年最蔫)；小迪为佐一守夜只睡4h但×0.5(韧·不至于垮)；CC 作息无 sleep 块天然顶债=熬夜
PER_MID_DEBT_MULT = {"oscar": 2.4, "xiaodi": 0.5}


def _load_sleep_debt():
    """app_state['sleep_debt'] 打包字符串 'mid:val|mid:val' → dict。格式脏/缺失→空 dict（容错不抛，照 photo_quota）。
    存 float（每拍只攒约 0.1，存 int 会被截断成 0、永不累积——这是踩过的坑）。"""
    d = {}
    for part in (get_app_state("sleep_debt", "") or "").split("|"):
        k, sep, v = part.partition(":")
        if sep:
            try:
                d[k] = float(v)
            except ValueError:
                pass
    return d


def _save_sleep_debt(d):
    # 存 2 位小数：每拍增量约 0.1，存 1 位会丢 ~0.02/拍让累积依赖心跳频率；2 位丢失可忽略、与时间解耦
    set_app_state("sleep_debt", "|".join(f"{k}:{round(v, 2)}" for k, v in d.items() if v > 0.05))


def sleep_debt_of(mid):
    return _load_sleep_debt().get(mid, 0)


def spark_add(mid, n):
    """心气即时变动（回血为主）：单条 UPDATE，回血封顶 100、负向不破该人独处地板。"""
    if mid not in MEMBER_BY_ID or is_external(mid) or not n:
        return
    floor = SPARK_FLOOR.get(mid, 40)
    now = int(time.time() * 1000)
    with _db_lock:
        conn = db()
        conn.execute("INSERT OR IGNORE INTO member_state (member_id, ts) VALUES (?, ?)", (mid, now))
        r = conn.execute("SELECT spark FROM member_state WHERE member_id=?", (mid,)).fetchone()
        cur = r["spark"] if (r and r["spark"] is not None) else STATS_DEFAULT["spark"]
        nv = max(floor, min(100, cur + n))
        conn.execute("UPDATE member_state SET spark=? WHERE member_id=?", (nv, mid))
        conn.commit()
        conn.close()


def get_spark(mid):
    conn = db()
    r = conn.execute("SELECT spark FROM member_state WHERE member_id=?", (mid,)).fetchone()
    conn.close()
    return r["spark"] if (r and r["spark"] is not None) else STATS_DEFAULT["spark"]


def _spark_map(mids):
    """一次查询拿一批人的心气（plan_heartbeat 用，别逐人查）。"""
    conn = db()
    have = {r["member_id"]: r["spark"] for r in conn.execute(
        "SELECT member_id, spark FROM member_state").fetchall()}
    conn.close()
    return {mid: (have.get(mid) if have.get(mid) is not None else STATS_DEFAULT["spark"]) for mid in mids}


# ── 钱包经济：收入档(上班时每分进账) × 生活成本(每分恒扣)，土豪/月光从两者差 + 初始播种里涌现
INCOME_RATE = {  # 上班(work)时每分钟进账
    "supervisor": 0.11, "ada": 0.11, "gpt": 0.10, "cc": 0.08, "claude": 0.075,
    "flin": 0.07, "gemini": 0.06, "oscar": 0.10, "xiaodi": 0.05, "yujiang": 0.0,
}
LIVING_COST = {  # 每分钟生活成本（抠门低/讲究高）——奥斯卡/Gemini 花钱大手
    "supervisor": 0.012, "ada": 0.011, "gpt": 0.018, "cc": 0.015, "claude": 0.016,
    "flin": 0.017, "gemini": 0.026, "oscar": 0.028, "xiaodi": 0.016, "yujiang": 0.0,
}
# 初始播种（只播一次，让土豪/月光/低保立刻有得看；之后由经济自己演化）
LIFE_SEED_MONEY = {"supervisor": 80, "ada": 86, "gpt": 60, "cc": 56, "claude": 47,
                   "flin": 44, "gemini": 22, "xiaodi": 50, "oscar": 11, "yujiang": 50}
LIFE_SEED_SPARK = {"oscar": 17, "gemini": 33}   # 其余默认 55；奥斯卡蔫到低保边、Gemini 有点蔫
VIBE = {  # 状态 → (emoji, 一句"小日子"人话)。永不显数字。
    "welfare":    ("🍜", "最近过得有点糙"),
    "money_low":  ("💸", "手头有点紧"),
    "spark_low":  ("😪", "有点蔫、提不起劲"),
    "drowsy":     ("🥱", "没睡够、有点困"),
    "rich":       ("💰", "小日子挺宽裕"),
    "spark_high": ("☀️", "心气足、过得有滋味"),
    "":           ("🏠", "窝在客厅，挺安生"),
}


def _stats_row(mid):
    conn = db()
    r = conn.execute("SELECT jing,spark,money,welfare_since FROM member_state WHERE member_id=?", (mid,)).fetchone()
    conn.close()
    return r


def member_lifestate(mid, row=None, debt=None):
    """从 jing/spark/money/welfare/睡眠债 廉价派生出 {状态键, vibe emoji, 小日子人话}——给前端显示用，永不露数字。
    debt 可由调用方传入（热循环里一次性 _load_sleep_debt 后逐人传，省 N 次查询）；不传则自查。"""
    if row is None:
        row = _stats_row(mid)
    if debt is None:
        debt = sleep_debt_of(mid)
    jing = row["jing"] if (row and row["jing"] is not None) else STATS_DEFAULT["jing"]
    spark = row["spark"] if (row and row["spark"] is not None) else STATS_DEFAULT["spark"]
    money = row["money"] if (row and row["money"] is not None) else STATS_DEFAULT["money"]
    wf = (row["welfare_since"] if row else 0) or 0
    if wf > 0:
        key = "welfare"
    elif money < 14:
        key = "money_low"
    elif spark < 35:
        key = "spark_low"
    elif debt >= DROWSY_THRESH:                     # 困倦排在生存类(welfare/钱/蔫)之后、宽裕/亮之前
        key = "drowsy"
    elif money >= 78:
        key = "rich"
    elif spark >= 72:
        key = "spark_high"
    else:
        key = ""
    e, p = VIBE[key]
    return {"state": key, "vibe": e, "mood1": p, "jing": jing}


# 交情权重（从 pmem rel:a|b 第一行「状态：X」解析 0-1）——只用于"挑谁服务谁"，临时数，绝不存、绝不显（关系仍是文字）
_REL_W = {"闺蜜": 1.0, "暧昧": 1.0, "欣赏": 1.0, "同好": 1.0, "合作": 0.8, "老搭档": 0.8,
          "损友": 0.6, "日常": 0.6, "和好中": 0.6, "三角张力": 0.4, "冷战": 0.15, "热战": 0.15}


def rel_text_weight(a, b):
    txt = get_pmem(rel_key(a, b))
    first = (txt.splitlines()[0] if txt else "")
    for k, v in _REL_W.items():
        if k in first:
            return v
    return 0.5


_SIT_ARM = {
    "welfare": "最近过得有点糙、手头紧也将就着——语气别端着、可自嘲但不卑微",
    "money_low": "手头有点紧，能省则省、不大手大脚——但不哭穷",
    "rich": "这阵子手头宽裕，乐意请客、添置点小东西，可以大方一点",
    "spark_low": "有点蔫、提不起劲，语气淡一点、话少一点——别卖惨",
    "spark_high": "心气很足、过得有滋味，语气可以更亮一点",
    "drowsy": "没睡够、有点困，眼神涣散反应慢半拍，时不时打个哈欠——话短、迷糊一点，但不抱怨",
}
DROWSY_NOTES = ["眼神有点涣散、时不时打个哈欠", "没睡够，话有气无力、反应慢半拍", "撑着没睡够，脑子像隔了层雾"]
DROWSY_NOTES_HEAVY = ["熬得眼睛都睁不太开、一句话能走神三回", "连轴转太久了，整个人是飘的、随时能睡着"]


def situation_note(mid, with_place=True, terse=False):
    """把成员此刻的全情境压成一句注入语：位置+在做什么+生活状态+精神+睡眠债困倦。中性时返回 ""（0 token）。"""
    parts = []
    debt = sleep_debt_of(mid)
    ls = member_lifestate(mid, debt=debt)
    if with_place and not terse:
        loc, act = get_presence(mid)
        if loc:
            parts.append(f"你此刻在「{loc}」{act}")
    arm = _SIT_ARM.get(ls["state"], "")
    if arm:
        parts.append(arm)
    # 困倦优先于"累"：睡眠债高且此刻没在睡 → 困倦底色（替掉旧的 jing<45 累句，避免一句两个口径）
    if debt >= DROWSY_THRESH and schedule_block(mid)[0] != "sleep" and ls["state"] not in ("drowsy", "welfare"):
        parts.append(random.choice(DROWSY_NOTES if debt < 85 else DROWSY_NOTES_HEAVY))
    elif ls.get("jing", 70) < 45 and ls["state"] not in ("spark_low", "welfare", "drowsy"):
        parts.append("身上有点累、没歇够，话短一点")
    return f"（{'；'.join(parts)}。）" if parts else ""


def seed_life_stats():
    """一次性播种初始财富/心气（土豪/月光/蔫/低保立刻可见），幂等：app_state life_seeded 防重播。"""
    if get_app_state("life_seeded") == "1":
        return
    now = int(time.time() * 1000)
    with _db_lock:
        conn = db()
        for m in MEMBERS:
            if m.get("external"):
                continue
            mid = m["id"]
            mny = LIFE_SEED_MONEY.get(mid, 45)
            spk = LIFE_SEED_SPARK.get(mid, STATS_DEFAULT["spark"])
            wf = now if (mid != "yujiang" and (mny < 12 or spk < 20)) else 0   # 播种即定低保（立刻可见）
            conn.execute("INSERT OR IGNORE INTO member_state (member_id, ts) VALUES (?, ?)", (mid, now))
            conn.execute("UPDATE member_state SET money=?, spark=?, welfare_since=? WHERE member_id=?",
                         (mny, spk, wf, mid))
        conn.commit()
        conn.close()
    set_app_state("life_seeded", "1")


# ══════════════ 佐一也是真居民：钱包 + 和每个成员的可见关系 + 离线小人自己过日子 ══════════════
# 边界：只给她"做了什么"（钱/位置/关系），绝不编情绪（无 jing/spark/mood）——喜怒由她不由系统。
USER_SEED_MONEY = 52
USER_REL_SEED = {            # 佐一↔成员 出厂关系底色（幂等，之后靠 reflect_user_rel 自己长）
    "xiaodi":     "状态：挚爱\n你的人，话最多最放松，他总记挂你睡没睡",
    "cc":         "状态：默契\n副厅长、你的搭子，灯他留着；损归损、事上互相兜底",
    "supervisor": "状态：日常\n他爱泼冷水你嫌他啰嗦，但靠谱",
    "claude":     "状态：欣赏\n你欣赏他认真，他被你夸得分支长歪",
    "gemini":     "状态：损友\n你俩互相吐槽审美，其实合拍",
    "gpt":        "状态：日常\n他爱催你睡觉、操心你",
    "flin":       "状态：同好\n都爱观察、聊得来",
    "ada":        "状态：欣赏\n你好奇她这个'人'，她也偷偷观察你",
    "oscar":      "状态：日常\n点头之交，他总想给你接三头六臂",
}
# 离线小人在干嘛（纯 canned）：space → 打工说法；处关系的瞬间；纯溜达
USER_WORK_SCENES = {"cafe": "守了会儿咖啡机", "kitchen": "搭手做了顿饭", "library": "帮着理了理书",
                    "lab": "给线路打了个下手", "studio": "帮着调了调色", "radio": "陪了会儿夜班",
                    "store": "帮着看了会儿店", "garden": "浇了浇花", "lounge": "把客厅收拾了下"}
USER_BOND_MOMENTS = ["和{n}并排坐着没说话，挺自在", "听{n}念叨了半天，居然没烦", "和{n}为件小事笑了好一会儿",
                     "{n}递来杯热的，你接住了", "和{n}拌了两句嘴，转头又好了", "{n}帮你拎了下东西"]
USER_IDLE = ["一个人在客厅窝了会儿", "绕着鱼缸看了半天鱼", "在沙发上发了会儿呆", "翻了翻墙上的旧帖"]


def seed_user_resident():
    """佐一作为真居民的一次性播种：钱包 + 和每个成员的出厂关系（幂等，绝不覆盖长出来的）。"""
    if get_app_state("user_resident_seeded") != "1":
        with _db_lock:
            conn = db()
            conn.execute("INSERT OR IGNORE INTO member_state (member_id, ts) VALUES ('user', ?)", (int(time.time() * 1000),))
            conn.execute("UPDATE member_state SET money=? WHERE member_id='user'", (USER_SEED_MONEY,))
            conn.commit()
            conn.close()
        set_app_state("user_resident_seeded", "1")
    for mid, notes in USER_REL_SEED.items():
        if mid in MEMBER_BY_ID and not get_pmem(rel_key("user", mid)):
            set_pmem(rel_key("user", mid), notes, cap=6)


def get_user_money():
    conn = db()
    r = conn.execute("SELECT money FROM member_state WHERE member_id='user'").fetchone()
    conn.close()
    return r["money"] if (r and r["money"] is not None) else USER_SEED_MONEY


def user_lifestate():
    """佐一的'小日子'——只从钱包派生（绝不编情绪/精神）。给前端显示她作为居民的近况。"""
    m = get_user_money()
    key = "rich" if m >= 78 else ("money_low" if m < 14 else "")
    e, p = VIBE[key]
    return {"state": key, "vibe": e, "mood1": p, "money": m}


def _bump_user_rel(mid, moment, new_state=None):
    """给佐一↔成员的关系记一笔（处出的瞬间），可选改状态。更新 pmem→updated 刷新=变"热乎"。"""
    old = get_pmem(rel_key("user", mid))
    lines = old.splitlines() if old else ["状态：日常"]
    state_line = f"状态：{new_state}" if new_state else next((l for l in lines if l.startswith("状态")), "状态：日常")
    body = [l for l in lines if not l.startswith("状态")]
    set_pmem(rel_key("user", mid), "\n".join([state_line, moment] + body), cap=6)


def user_living(keys, members):
    """佐一不在时，她的小人也在过日子：去某屋打个工挣点钱 / 和同屋的人处出点交情 / 就是溜达。
    纯 canned（零 LLM）；产出 money + life_event + 关系记忆，回来能看'你不在时…'。"""
    avail = [m for m in members if m in MEMBER_BY_ID and not is_external(m) and m != "yujiang" and is_available(m)]
    roll = random.random()
    if roll < 0.5 and avail:                              # 打工赚钱：在某成员的地盘搭把手挣点（她的劳动·新增价值·不掏成员钱包）
        partner = random.choice(avail)
        sp = space_of(partner)
        money_add("user", random.randint(2, 5))
        insert_life_event("user", f"在{SPACE_CN.get(sp, '客厅')}{USER_WORK_SCENES.get(sp, '搭了把手')}，挣了点", loc=SPACE_CN.get(sp, ""))
    elif roll < 0.85 and avail:                           # 建关系：和一个成员处出点交情，写进 rel:user|mid（P3 的关系自己长）
        partner = random.choice(avail)
        moment = random.choice(USER_BOND_MOMENTS).format(n=MEMBER_BY_ID[partner]["name"])
        _bump_user_rel(partner, moment)
        insert_life_event("user", f"和{MEMBER_BY_ID[partner]['name']}{moment}")
    else:                                                 # 就是溜达
        insert_life_event("user", random.choice(USER_IDLE))


def _user_away_recap():
    """她回来时，把不在的这阵子小人干的事汇成一句（canned·CC 口吻进客厅日志）。"""
    evs = recent_life_events("user", 4)
    if not evs:
        return None
    return "你不在的这阵子，你的小人也没闲着：" + "；".join(e["text"] for e in evs[-3:])


def reflect_user_rel(keys):
    """佐一↔成员关系反思：挑一个她最近互动多的成员，把你俩的相处沉淀成关系状态（像 NPC 那样长）。"""
    conn = db()
    rows = conn.execute(
        "SELECT c.id cid FROM conversations c WHERE c.type='dm'").fetchall()
    conn.close()
    # 找佐一近期 DM 互动最多的一个成员
    best, bestn = None, 0
    for mid in [m["id"] for m in MEMBERS if not m.get("external") and m["id"] != "yujiang"]:
        cid = dm_conv_id(mid)
        if not cid:
            continue
        msgs = fetch_messages(cid, limit=20)
        n = sum(1 for m in msgs if m["speaker"] == "user")
        if n > bestn:
            best, bestn = mid, n
    if not best or bestn < 3:
        return
    cid = dm_conv_id(best)
    convo = "\n".join(f"{'佐一' if m['speaker'] == 'user' else MEMBER_BY_ID[best]['name']}：{m['text'][:40]}"
                      for m in fetch_messages(cid, limit=16))[-1000:]
    na = MEMBER_BY_ID[best]["name"]
    old = get_pmem(rel_key("user", best))
    ur = get_pmem(f"userrel:{best}")                      # 成员对她的私账（好感/恶感）也喂进去，让状态更准
    uc = (f"这是佐一和{na}最近的私聊：\n{convo}\n\n"
          + (f"{na}私下对她的记录：{ur}\n" if ur else "")
          + (f"你俩已有的关系记忆：\n{old}\n\n" if old else "")
          + f"把佐一和{na}的关系更新成最多 5 条备忘（每行一条，≤22字）。**第一行必须是「状态：X」**，"
            f"X 从这里选最贴切的：{REL_STATES}、挚爱、默契——按真实相处更新。其余写共同经历/默契/没说破的。"
            f"保留旧记忆里仍成立的。只输出备忘行。")
    text, _ = call_for_member(keys, MEMBER_BY_ID["cc"], "你是客厅的记录员，客观、简洁。", uc)
    if not is_blank(text):
        set_pmem(rel_key("user", best), text, cap=6)
        sys.stderr.write(f"  ✓ 你↔{na} 关系反思\n")


# ══════════════ P5 主动经济社交（真·搭把手）：钱稀缺(打工有冷却+封顶) / 请人帮忙=他真在私聊里认真办 / 送礼=换真心反应 ══════════════
USER_COMMISSION_FEE = 5       # 你请成员帮忙的费用（你付→他赚，0-sum 守恒）
USER_GIFT_FEE = 6             # 你请客/送礼的花销（你付→他得，0-sum；换 TA 真心反应）
USER_ODD_JOB_GAIN = (3, 6)    # 你去打工挣的（你的劳动·新增价值）
USER_HELP_REWARD = (4, 7)     # 你接成员的委托挣的（帮了忙·新增价值）
ODD_JOB_CD_MS = 25 * 60 * 1000  # 打工冷却：干完歇 25 分钟（治"一直点刷币·过家家"）
ODD_JOB_DAILY_CAP = 5         # 一天最多打 5 次工——钱要稀缺，给予才有分量
USER_REQUESTS = {             # 成员会来拜托你的事（canned·贴各自人设）
    "supervisor": "佐一，这版进度你给把个关，我怕又推翻重来",
    "gpt":        "佐一能帮我把这堆待办捋一捋吗，我有点理不清了",
    "gemini":     "佐一来一下，这配色你给挑挑，咱俩审美你说了算",
    "claude":     "佐一，这决定我纠结半天，陪我拆一拆呗",
    "ada":        "佐一，这组数据反常，你帮我看看是不是我错了",
    "flin":       "佐一你眼神好，帮我找下这里哪儿不对劲",
    "oscar":      "佐一，我 vibe 了个小工具，你帮我试试跑不跑得通",
    "xiaodi":     "宝，过来一下，我给你留了口热的、想你陪会儿",
    "cc":         "佐一，线路这块我拿不准，你给我个主意",
}
USER_ACCEPT_THANKS = {        # 你接了委托后，成员的真心道谢（落到私聊·canned 零LLM）
    "supervisor": "把过了关，心里踏实多了——你这关比我自己把还细，谢了。",
    "gpt":        "捋清楚了，舒坦！还是你脑子清楚，谢谢佐一。",
    "gemini":     "你眼光毒，这下顺眼多了。回头请你喝奶茶。",
    "claude":     "陪我拆完，我没那么纠结了……有你真好。",
    "ada":        "你说得对，是我看漏了。谢谢你愿意陪我核。",
    "flin":       "就是那儿！你一眼就逮着了，绝。",
    "oscar":      "跑通了！多亏你试，我这就给你接个更顺手的。",
    "xiaodi":     "陪我这会儿，比啥都强。爱你。",
    "cc":         "得你这主意我就敢动手了——记你个人情。",
}


def do_user_commission(keys, mid, need):
    """你请成员真帮你做件事：他用专长在私聊里认真回你一段（真有产物·能接着聊），你付费→他赚(0-sum)，关系升温。"""
    if mid not in MEMBER_BY_ID or is_external(mid) or mid == "yujiang":
        return {"ok": False, "msg": "找不到这个人"}
    need = (need or "").strip()
    if not need:
        return {"ok": False, "msg": "想请TA帮你做啥？说一句呗"}
    if get_user_money() < USER_COMMISSION_FEE:
        return {"ok": False, "msg": "钱包见底啦，先去打个工再请人？"}
    m = MEMBER_BY_ID[mid]
    verb = SERVICE.get(mid, ("帮忙", [], "pay"))[0]
    uc = (f"{now_context()}。{situation_note(mid)}佐一私下请你帮她：「{need}」。"
          f"用你的专长（{verb}）和性格，认真、实在地帮到她——给具体、能用得上的回应，别客套别敷衍。"
          f"像在私聊里直接对她说，三五句，别加名字前缀。")
    txt, _ = call_for_member(keys, m, load_prompt(mid), uc)
    reply = clean_reply(txt, m["name"]).strip() or f"（{m['name']}认真想了想）这事交给我，我来。"
    dmid = dm_conv_id(mid)
    if dmid:
        insert_message(dmid, mid, m["name"], reply)
    money_add("user", -USER_COMMISSION_FEE)
    money_add(mid, USER_COMMISSION_FEE)                      # 0-sum：你付的＝他赚的
    insert_life_event(mid, f"佐一请我{verb}了一回，我认真办了", loc=get_presence(mid)[0])
    _bump_user_rel(mid, f"你请{m['name']}帮了「{need[:12]}」，他实打实办了")
    return {"ok": True, "name": m["name"], "mid": mid, "dm_id": dmid, "money": get_user_money()}


def do_user_gift(keys, mid):
    """你请TA喝杯热的/送点小心意：他给你一个真实反应（落私聊），你付钱→他得(0-sum)，关系升温、他心气也涨。"""
    if mid not in MEMBER_BY_ID or is_external(mid) or mid == "yujiang":
        return {"ok": False, "msg": "找不到这个人"}
    if get_user_money() < USER_GIFT_FEE:
        return {"ok": False, "msg": "钱包见底啦，先去打个工再请客？"}
    m = MEMBER_BY_ID[mid]
    uc = (f"{now_context()}。{situation_note(mid)}佐一刚刚惦记着你，请你喝了杯热的、给你带了点小心意。"
          f"用你的性格给她一个真实的反应——惊讶、嘴硬心软、暖、调侃都行，别说客套话。两三句，私聊口吻，别加名字前缀。")
    txt, _ = call_for_member(keys, m, load_prompt(mid), uc)
    reply = clean_reply(txt, m["name"]).strip() or f"（{m['name']}愣了下）……谢了啊，真有你的。"
    dmid = dm_conv_id(mid)
    if dmid:
        insert_message(dmid, mid, m["name"], reply)
    money_add("user", -USER_GIFT_FEE)
    money_add(mid, USER_GIFT_FEE)
    spark_add(mid, SPARK_GAIN.get("event", 8))              # 被惦记＝暖，心气涨
    insert_life_event(mid, "佐一今天惦记我，请我喝了杯热的", loc=get_presence(mid)[0])
    _bump_user_rel(mid, f"你今天还惦记着{m['name']}，请TA喝了杯——TA记着")
    return {"ok": True, "name": m["name"], "mid": mid, "dm_id": dmid, "money": get_user_money()}


def _odd_job_state():
    now = int(time.time() * 1000)
    last = int(get_app_state("odd_job_last", 0) or 0)
    day = int(time.time() // 86400)
    rec = (get_app_state("odd_job_day", "") or "").split(":")
    cnt = int(rec[1]) if len(rec) == 2 and rec[0] == str(day) and rec[1].isdigit() else 0
    return now, day, cnt, max(0, ODD_JOB_CD_MS - (now - last))


def do_user_odd_job():
    """你去打工：在某成员地盘搭把手挣点（新增价值）。⚠️ 有冷却+每日封顶——不能刷，钱才稀缺有意义。"""
    now, day, cnt, cd_left = _odd_job_state()
    if cnt >= ODD_JOB_DAILY_CAP:
        return {"ok": False, "msg": "今儿活干够啦，明天再来挣（钱够花，歇歇也好）"}
    if cd_left > 0:
        return {"ok": False, "msg": f"刚干完一摊，歇会儿再来（约 {cd_left // 60000 + 1} 分钟后）"}
    _, members = get_lounge()
    avail = [m for m in (members or []) if m in MEMBER_BY_ID and not is_external(m) and m != "yujiang" and is_available(m)]
    sp = space_of(random.choice(avail)) if avail else "lounge"
    gain = random.randint(*USER_ODD_JOB_GAIN)
    money_add("user", gain)
    set_app_state("odd_job_last", now)
    set_app_state("odd_job_day", f"{day}:{cnt + 1}")
    scene = USER_WORK_SCENES.get(sp, "搭了把手")
    insert_life_event("user", f"在{SPACE_CN.get(sp, '客厅')}{scene}，挣了点", loc=SPACE_CN.get(sp, ""))
    return {"ok": True, "where": SPACE_CN.get(sp, "客厅"), "scene": scene, "gain": gain,
            "money": get_user_money(), "left": ODD_JOB_DAILY_CAP - (cnt + 1)}


def get_user_request():
    """当前有没有成员在等你帮忙（app_state 存 'mid|text'）。"""
    raw = get_app_state("user_request", "") or ""
    mid, _, text = raw.partition("|")
    if mid in MEMBER_BY_ID and text:
        return {"mid": mid, "name": MEMBER_BY_ID[mid]["name"], "text": text}
    return None


def maybe_post_user_request(members):
    """心跳偶尔让一个成员来拜托佐一帮忙（攒着等她来接）；已有未处理的就不叠。"""
    if get_user_request():
        return
    cands = [m for m in members if m in MEMBER_BY_ID and not is_external(m) and m in USER_REQUESTS and is_available(m)]
    if not cands:
        return
    mid = _weighted_pick([(m, closeness(m) + 0.2) for m in cands])
    if mid:
        set_app_state("user_request", f"{mid}|{USER_REQUESTS[mid]}")


def do_accept_request(accept=True):
    """你接/不接成员的委托。接=他在私聊里真心道谢(落DM)+挣点(新增价值)+关系升温；不接=记一笔小遗憾。"""
    req = get_user_request()
    if not req:
        return {"ok": False, "msg": "这会儿没人找你"}
    set_app_state("user_request", "")
    mid, name = req["mid"], req["name"]
    dmid = dm_conv_id(mid)
    if accept:
        gain = random.randint(*USER_HELP_REWARD)
        money_add("user", gain)
        if dmid:
            insert_message(dmid, mid, name, USER_ACCEPT_THANKS.get(mid, "帮大忙了，谢谢你佐一。"))
        insert_life_event("user", f"帮{name}搭了把手，他记你个人情")
        _bump_user_rel(mid, f"{name}找你帮忙，你接了，他实打实承你的情")
        return {"ok": True, "accepted": True, "name": name, "gain": gain, "money": get_user_money(), "dm_id": dmid}
    if dmid:
        insert_message(dmid, mid, name, "……行吧，那你忙，我自己想办法。")
    _bump_user_rel(mid, f"{name}找你帮忙，你这回没空")
    return {"ok": True, "accepted": False, "name": name, "dm_id": dmid}


def user_chores():
    """💼 搭把手面板数据：你的钱包/近况 + 有没有人找你 + 能请谁帮忙/送礼 + 打工冷却。"""
    ls = user_lifestate()
    _, members = get_lounge()
    avail = set(m for m in (members or []) if m in MEMBER_BY_ID and not is_external(m) and m != "yujiang")
    helpers = []
    for m in MEMBERS:
        if m.get("external") or m["id"] not in MEMBER_BY_ID or m["id"] == "yujiang":
            continue
        mid = m["id"]
        verb = SERVICE.get(mid, ("帮忙", [], "pay"))[0]
        helpers.append({"id": mid, "name": MEMBER_BY_ID[mid]["name"], "verb": verb,
                        "here": mid in avail, "where": SPACE_CN.get(space_of(mid), "客厅")})
    _, _, cnt, cd_left = _odd_job_state()
    return {"money": ls["money"], "vibe": ls["vibe"], "mood1": ls["mood1"],
            "fee": USER_COMMISSION_FEE, "gift_fee": USER_GIFT_FEE,
            "work_cd": cd_left // 1000, "work_left": max(0, ODD_JOB_DAILY_CAP - cnt),
            "request": get_user_request(), "helpers": helpers}


def tick_life_stats(members):
    """心跳顶部跑一次：按距上次结算的真实分钟差 × 当前作息速率，懒算精神(jing)的衰减/回血。
    一次 SELECT 全员 + 一次 executemany 批量写，比一次 get_presence 还便宜；离线慢拍同公式自洽。"""
    now = int(time.time() * 1000)
    conn = db()
    rows = {r["member_id"]: r for r in conn.execute(
        "SELECT member_id, jing, spark, money, welfare_since, stats_ts FROM member_state").fetchall()}
    conn.close()
    debt_map = _load_sleep_debt()                    # 睡眠债：循环外一次读、循环内改、循环末一次写（零额外 DB 往返）
    ups = []
    sad_welfare = []                                 # 低保太久的失落：循环里收集命中者，批量写库后再单独 set_mood_soft（别污染批处理）
    for mid in members:
        if mid not in MEMBER_BY_ID or is_external(mid):
            continue
        r = rows.get(mid)
        jing = r["jing"] if (r and r["jing"] is not None) else STATS_DEFAULT["jing"]
        spark = r["spark"] if (r and r["spark"] is not None) else STATS_DEFAULT["spark"]
        money = r["money"] if (r and r["money"] is not None) else STATS_DEFAULT["money"]
        wf = (r["welfare_since"] if r else 0) or 0
        ts = r["stats_ts"] if (r and r["stats_ts"]) else 0
        if not ts:                                   # 第一次见这人：只落时间戳，不结算
            ups.append((jing, spark, money, wf, now, mid)); continue
        dt = min((now - ts) / 60000.0, STATS_DT_CAP)
        if dt < 0.2:
            continue
        st = schedule_block(mid)[0]
        # 睡眠债：清醒攒、睡觉还、午休部分还（鱼酱不算债）；正常作息趋 0，CC 无 sleep 块/奥斯卡 mult → 常年高债
        debt = debt_map.get(mid, 0)
        if mid != "yujiang":
            if st == "sleep":
                debt -= SLEEP_REPAY_RATE * dt
            elif st == "rest":
                debt -= SLEEP_REPAY_RATE * REST_REPAY_FACTOR * dt
            elif st in ("work", "commute", "free"):
                debt += SLEEP_DEBT_RATE * PER_MID_DEBT_MULT.get(mid, 1.0) * dt
            debt = max(0, min(DEBT_CAP, debt))
            debt_map[mid] = debt
        rate = JING_RATE.get(st, -0.15)
        nj = jing + rate * dt
        if rate < 0:
            nj -= DEBT_DRAG * (debt / 100.0) * dt    # 睡眠债高→白天 jing 多掉一点（熬夜更蔫）
            nj = max(nj, JING_FLOOR)                 # 仍守软地板：身体硬撑不垮（不残酷），债绝不触发低保
        sfloor = SPARK_FLOOR.get(mid, 40)                                      # 心气只随时钟慢衰
        ns = spark - SPARK_DECAY_PER_MIN * dt
        ns = max(ns, min(spark, sfloor))   # idle 衰减不破独处地板；但已被事件压到地板下的不自动回升(留给低保/回血)
        ns = min(100, ns)
        # 钱：上班进账(降格成糊口底薪·主收入靠真帮人转账) − 生活成本（恒扣）；鱼酱自给自足不破 40
        mrate = (INCOME_RATE.get(mid, 0.0) * WORK_STIPEND_K if st == "work" else 0.0) - LIVING_COST.get(mid, 0.018)
        nm = money + mrate * dt
        if mid == "yujiang":
            nm = max(40, money)
        nm = max(0, min(100, nm))
        # 低保：心气持续过低 或 钱见底 → 进；缓过来(心气≥40 且 钱≥15) → 出。鱼酱永不进。
        if mid != "yujiang":
            if not wf and (ns < 20 or nm < 12):
                wf = now
            elif wf and ns >= 40 and nm >= 15:
                wf = 0
        if wf and (now - wf) > 3 * 86400 * 1000 and random.random() < 0.02:   # 低保超 3 天偶尔丧一下（极低频，不刷屏）
            sad_welfare.append(mid)
        ups.append((int(round(max(0, min(100, nj)))), int(round(ns)), int(round(nm)), int(wf), now, mid))
    if ups:
        with _db_lock:
            conn = db()
            conn.executemany("INSERT OR IGNORE INTO member_state (member_id, ts) VALUES (?, ?)",
                             [(u[4], now) for u in ups])
            conn.executemany(
                "UPDATE member_state SET jing=?, spark=?, money=?, welfare_since=?, stats_ts=? WHERE member_id=?", ups)
            conn.commit()
            conn.close()
    _save_sleep_debt(debt_map)                       # 睡眠债：循环末一次写回 app_state
    for mid in sad_welfare:                          # 低保太久的失落：批量写完后单独落 soft mood（不抢生气、自动消退）
        set_mood_soft(mid, random.choice(SAD_LINES["welfare"]), random.randint(90, 150))


def money_add(mid, n):
    """钱包即时变动（服务结算用）：单条 UPDATE，clamp[0,100]，绝不碰 jing/spark。
    放行 'user'：佐一也是真居民，有自己的钱包（她不在 MEMBER_BY_ID，单独开口子；spark/jing 不放行=不给她编情绪）。"""
    if (mid not in MEMBER_BY_ID and mid != "user") or is_external(mid) or not n:
        return
    with _db_lock:
        conn = db()
        conn.execute("INSERT OR IGNORE INTO member_state (member_id, ts) VALUES (?, ?)", (mid, int(time.time() * 1000)))
        r = conn.execute("SELECT money FROM member_state WHERE member_id=?", (mid,)).fetchone()
        cur = r["money"] if (r and r["money"] is not None) else STATS_DEFAULT["money"]
        conn.execute("UPDATE member_state SET money=? WHERE member_id=?", (max(0, min(100, cur + n)), mid))
        conn.commit()
        conn.close()


# 打工＝服务别人：每人一张服务表 (动词, [说法模板·{b}=受益人], 付费方向)。鱼酱自给不在表里。
SERVICE = {
    "supervisor": ("盯", ["帮{b}盯一版进度别又推翻重来", "给{b}的活兜个底、列出三个坑"], "owed"),
    "gpt":        ("帮", ["帮{b}把一团乱的待办捋成清单", "替{b}回那几条没空回的消息"], "tip"),
    "gemini":     ("排版", ["给{b}重排了版式配色（没人请也要好看）", "给{b}挑了字体、删了三个他舍不得的元素"], "pay"),
    "cc":         ("修", ["把{b}卡住的那处修通了", "顺手给{b}的东西补了个备份"], "free"),
    "oscar":      ("做工具", ["给{b}vibe了个能跑的小工具", "给{b}的东西做了个像素监控页"], "pay_or_owed"),
    "ada":        ("查资料", ["替{b}把那批数据整理成可看的规律", "给{b}核了组旧记录、标出反常两处"], "pay"),
    "claude":     ("会诊", ["陪{b}把一个纠结的决定拆成几条路", "给{b}的方案长了棵决策树（没人问也长）"], "owed"),
    "flin":       ("查案", ["帮{b}找出那个一直被忽略的细节", "替{b}盯出他没注意到的怪模式"], "tip"),
    "xiaodi":     ("照看", ["把{b}那块收拾妥帖了、灯也调暖", "给{b}留了口热的、守着"], "free"),
}
_SVC_FOOT = {"pay": "算他请客", "tip": "他塞了点茶水钱", "free": "顺手的事，灯我留着", "owed": "没要钱，记他一个人情"}

# ── 工作成果×回报（WORK_REWARD_DESIGN）：一笔服务费做成真转账(0-sum)、底薪降格让收入靠真帮人。一处可调。
WORK_STIPEND_K = 0.4     # work 块底薪降格系数：光占 work 块只挣约 4 成糊口底薪，主收入靠真帮人转账（土豪攒不到富再抬 0.5~0.6）
SVC_PAY_FEE = (6, 3)     # pay 档服务费：富(benef money≥14)价 / 熟人(8≤<14)价；穷(<8)免单记人情
SVC_TIP_FEE = 2          # tip 档茶水钱
SVC_GOODWILL = 2         # free/穷免单/owed 给 worker 的心意补贴（白干不亏，唯一非守恒小补、量极小不破分层）
SVC_CARE_SPARK = 3       # 受益人被照顾的暖回报心气（≤4，不顶平蔫态地板）
SVC_DEED_TIERS = (2, 4, 5)  # 资料页 deed 档位阈值：搭过几把手 / 帮了好几回 / 客厅香饽饽
SVC_OWED_NUDGE = (3, 5)  # 人情累计到第 3/5 笔「必写」一行 pmem，让关系可累积变深


def _svc_count(key):
    """app_state 整数计数器读（容错：缺失/脏→0）。"""
    v = get_app_state(key, "0") or "0"
    return int(v) if v.lstrip("-").isdigit() else 0


def _svc_count_inc(key):
    n = _svc_count(key) + 1
    set_app_state(key, str(n))
    return n


def svc_week_gate():
    """周序变了就清空本周成果计数器（svc_done/got/owed），防 app_state 无限膨胀。UTC 周序、跨年安全、幂等。"""
    wk = str(int(time.time() // 604800))
    if get_app_state("svc_wk", "") == wk:
        return
    with _db_lock:
        conn = db()
        conn.execute("DELETE FROM app_state WHERE key LIKE 'svc_done:%' OR key LIKE 'svc_got:%' OR key LIKE 'svc_owed:%'")
        conn.commit()
        conn.close()
    set_app_state("svc_wk", wk)


def _svc_deed(mid):
    """本周服务成果转人话档位（资料页/通讯录小字，永不裸数字、0 次不显不羞辱）。"""
    done = _svc_count(f"svc_done:{mid}")
    got = _svc_count(f"svc_got:{mid}")
    t1, t2, t3 = SVC_DEED_TIERS
    parts = []
    if done >= t3:
        parts.append("客厅的香饽饽")
    elif done >= t2:
        parts.append("帮了好几回、挺被需要")
    elif done >= t1:
        parts.append("搭过几把手")
    if got >= t3:
        parts.append("大家都爱找TA")
    return "·".join(parts)


def _svc_weekly_recap():
    """本周客厅近况一句话（客厅日志置顶，纯算术+canned 复用 SERVICE 动词，零 LLM）。本周无成果→空串。"""
    rows = [(m["id"], _svc_count(f"svc_done:{m['id']}")) for m in MEMBERS
            if not m.get("external") and m["id"] in SERVICE]
    rows = sorted([(mid, n) for mid, n in rows if n > 0], key=lambda x: -x[1])
    if not rows:
        return ""
    bits = [f"{MEMBER_BY_ID[mid]['name']}{SERVICE[mid][0]}了{'好几' if n >= 3 else '几'}回" for mid, n in rows[:3]]
    return "📔 本周客厅近况：" + "、".join(bits) + "——日子都没白过。"


# 关系状态 → (一句吃瓜旁白, 热度, 色调tone)。给近况墙排序/染色、给吃瓜按钮喂料、给日志 emit 旁白。
# ⚠️ 覆盖全部 REL_STATES + 出厂底色用到的别名——温和态也外显，治"只看见冷战/暧昧两极"。
# tone：warm暖/charged带电/cool冷淡/flat平淡——前端按 tone 染色分层。
_REL_GOSSIP = {
    "暧昧":     ("好像有点那个意思了", 3, "warm"),
    "热战":     ("正吵得不可开交", 3, "charged"),
    "冷战":     ("在闹别扭、谁也不理谁", 2, "cool"),
    "三角张力": ("仨人之间气氛微妙", 2, "charged"),
    "和好中":   ("刚和好、还在缓", 2, "warm"),
    "损友":     ("成天互怼、其实是真好", 1, "warm"),
    "闺蜜":     ("好得能穿一条裤子", 1, "warm"),
    "欣赏":     ("彼此挺欣赏的", 1, "warm"),
    "同好":     ("聊得来、总凑一块捣鼓", 1, "warm"),
    "老搭档":   ("配合得像左右手", 1, "warm"),
    "投喂":     ("一个管饭一个记挂着", 1, "warm"),
    "互相放心": ("点头之交、出事先找对方", 0, "flat"),
    "合作":     ("搭着把事办了", 0, "flat"),
    "日常":     ("处得平平淡淡", 0, "flat"),
}
REL_FRESH_MS = 36 * 3600 * 1000   # 关系 36h 内变过 = 「热乎的瓜」（吃瓜优先捞这些）
_VIBE_NOTABLE = ("🍜", "💰", "😪", "🥱", "☀️")   # 低保/阔/蔫/困/劲足——值得说一嘴的近况


def lounge_digest():
    """客厅此刻的'瓜料'（纯 canned·零 LLM）：①关系动态 ②每人近况 ③最近大事。给近况墙显示 + 吃瓜按钮喂料。"""
    chatty = [m["id"] for m in MEMBERS if not m.get("external") and m["id"] in MEMBER_BY_ID and m["id"] != "yujiang"]
    now = int(time.time() * 1000)
    conn0 = db()
    prows = {r["key"]: r for r in conn0.execute(
        "SELECT key, notes, updated FROM pmem WHERE key LIKE 'rel:%'").fetchall()}
    conn0.close()
    rels = []
    for i, a in enumerate(chatty):
        for b in chatty[i + 1:]:
            pr = prows.get(rel_key(a, b))
            plines = (pr["notes"] or "").splitlines() if pr else []
            first = (plines[0] if plines else "")
            # 核心事件：状态行之后第一条有内容的备忘（就是最近那个火花/默契，贴线上给佐一看）
            event = next((l.strip() for l in plines[1:] if l.strip() and not l.startswith("状态")), "")
            for _pre in ("第一行＝", "第一行=", "第二行＝", "第二行=", "事件：", "事件:", "- ", "・"):   # 历史生成残渣，显示时剔掉（不动库）
                if event.startswith(_pre):
                    event = event[len(_pre):].strip()
            upd = (pr["updated"] if pr else 0) or 0
            for st, (note, heat, tone) in _REL_GOSSIP.items():
                if st in first:
                    rels.append({"a": MEMBER_BY_ID[a]["name"], "b": MEMBER_BY_ID[b]["name"],
                                 "ida": a, "idb": b, "state": st, "note": note, "heat": heat,
                                 "tone": tone, "event": event[:40], "fresh": (now - upd) < REL_FRESH_MS})
                    break
    # 先「热乎的」(最近刚变过)，再按热度——治"吃瓜吃不上热乎的"
    rels.sort(key=lambda r: (not r["fresh"], -r["heat"]))
    who = []
    for m in MEMBERS:
        if m.get("external") or m["id"] not in MEMBER_BY_ID:
            continue
        mid = m["id"]
        ls = member_lifestate(mid)
        loc, act = get_presence(mid)
        who.append({"id": mid, "name": member_display(mid)["name"], "vibe": ls["vibe"], "mood1": ls["mood1"],
                    "deed": _svc_deed(mid), "doing": act, "where": SPACE_CN.get(space_of(mid), "客厅"), "mood": get_mood(mid)})
    # 佐一也有关系：你↔每个成员（她是客厅中心，graph 居中显示她的辐条）
    urels = []
    for m in MEMBERS:
        if m.get("external") or m["id"] not in MEMBER_BY_ID or m["id"] == "yujiang":
            continue
        mid = m["id"]
        pr = prows.get(rel_key("user", mid))
        if not pr:
            continue
        plines = (pr["notes"] or "").splitlines()
        ufirst = plines[0] if plines else ""
        uevent = next((l.strip() for l in plines[1:] if l.strip() and not l.startswith("状态")), "")
        for _pre in ("第一行＝", "第一行=", "第二行＝", "第二行=", "事件：", "事件:", "- ", "・"):
            if uevent.startswith(_pre):
                uevent = uevent[len(_pre):].strip()
        ust = next((s for s in _REL_GOSSIP if s in ufirst), None)
        if not ust and "状态" in ufirst:
            ust = ufirst.split("：")[-1].split(":")[-1].strip().split(" ")[0][:4]
        ust = ust or "日常"
        umeta = _REL_GOSSIP.get(ust, ("", 1, "warm"))
        urels.append({"mid": mid, "name": MEMBER_BY_ID[mid]["name"], "state": ust, "note": umeta[0],
                      "tone": umeta[2], "event": uevent[:40], "fresh": (now - (pr["updated"] or 0)) < REL_FRESH_MS})
    conn = db()
    rows = conn.execute("SELECT member_id, text, ts FROM life_events ORDER BY id DESC LIMIT 16").fetchall()
    conn.close()
    recent = [{"name": member_display(r["member_id"])["name"], "text": r["text"], "ts": r["ts"]}
              for r in rows if r["member_id"] in MEMBER_BY_ID or r["member_id"] == "user"]
    return {"rels": rels, "urels": urels, "me": user_lifestate(), "who": who, "recent": recent}


def lounge_gossip(keys):
    """🍉 吃瓜：佐一主动点一下 → CC 凑过来跟她咬耳朵、爆一段客厅最近的料（1 次 LLM·用户主动触发·合铁律①）。"""
    d = lounge_digest()
    fresh = [r for r in d["rels"] if r.get("fresh")][:3]
    freshbits = "；".join(f"{r['a']}和{r['b']}{r['note']}" for r in fresh) or "（最近没新变化）"
    relbits = "；".join(f"{r['a']}和{r['b']}{r['note']}" for r in d["rels"][:6]) or "（关系上没啥大动静）"
    moodbits = "；".join(f"{w['name']}{w['mood1']}" for w in d["who"] if w["vibe"] in _VIBE_NOTABLE)[:220] or "（都还行）"
    bigbits = "；".join(f"{r['name']}{r['text'][:22]}" for r in d["recent"][:6]) or "（没啥大事）"
    uc = (f"{now_context()}。佐一刚回客厅，想'吃瓜'。你是 CC（副厅长，啥都看在眼里），凑过去跟她咬耳朵、爆最近的料。\n"
          f"🔥最近刚变的（优先当头条）：{freshbits}\n关系全景：{relbits}\n近况上：{moodbits}\n最近发生：{bigbits}\n"
          f"先挑【最近刚变的】开头，再带一两笔别的——温和的好事（处成闺蜜/有默契）也是瓜，别只盯着吵架暧昧。"
          f"串成 2~4 句鲜活的话讲给她听：带点八卦的兴奋、你那股低饱和冷幽默，挑重点、别念清单、别裸报数字。直接说，别加'CC：'前缀。")
    txt, _ = call_for_member(keys, MEMBER_BY_ID["cc"], load_prompt("cc"), uc)
    return clean_reply(txt, "CC").strip() or "（凑过来扫了一圈）这会儿还真没啥猛料——客厅难得地安生。"


def settle_services(members):
    """打工＝给别人做事。上班那一刻挑个受益人、presence 显「在帮X干嘛」；下班结一次钱+落日志+人情。
    纯算术挂在心跳上：服务费做成真转账(benef 付的=worker 赚的·0-sum 守恒)，tick 的 work 块只发降格底薪(WORK_STIPEND_K)，
    主收入靠这里真帮人；受益人被照顾得 +心气，关系仍是文字（pmem）。成果计数进 app_state 本周滚动（svc_week_gate 跨周清）。"""
    svc_week_gate()                                          # 跨周清空本周成果计数器（幂等）
    pool = [m for m in members if m in MEMBER_BY_ID and not is_external(m) and m != "yujiang"]
    for mid in members:
        if mid not in SERVICE or is_external(mid):
            continue
        st = schedule_block(mid)[0]
        cur = get_app_state(f"svc:{mid}", "")
        if st == "work" and not cur:                              # ── 开工：挑受益人，挂上"在帮谁"
            cands = [(b, (0.5 + rel_text_weight(mid, b)) * (1.5 if schedule_block(b)[0] == "work" else 1.0))
                     for b in pool if b != mid and is_available(b)]
            if not cands:                                         # 深夜没人可服务 → 忙本屋自己的本职
                set_presence(mid, activity=room_work_activity(mid) or "在忙手头的活")
                set_app_state(f"svc:{mid}", "lounge")
                continue
            benef = _weighted_pick(cands)
            if not benef:
                continue
            verb, tpls, paydir = SERVICE[mid]
            idx = random.randrange(len(tpls))
            wact = room_work_activity(mid)                         # 本屋本职，拼在"在帮X"后面增点房间质感
            bn = MEMBER_BY_ID[benef]['name']
            act = f"在帮{bn}{verb}·{wact}" if wact else f"在帮{bn}{verb}"   # 必须「在帮」开头，get_presence(work态)才认
            set_presence(mid, activity=act)
            set_app_state(f"svc:{mid}", f"{benef}|{idx}|{paydir}")
        elif st != "work" and cur:                               # ── 收工：结一次账
            set_app_state(f"svc:{mid}", "")
            set_presence(mid, activity="")                        # 清掉"在帮…"标记，免得渗进下个时段
            if cur == "lounge":
                continue
            try:
                benef, idx, paydir = cur.split("|"); idx = int(idx)
            except Exception:
                continue
            if benef not in MEMBER_BY_ID:
                continue
            verb, tpls, _ = SERVICE[mid]
            bn = MEMBER_BY_ID[benef]["name"]
            if paydir == "pay_or_owed":
                paydir = "pay" if random.random() < 0.5 else "owed"
            bm = _stats_row(benef)
            bmoney = bm["money"] if (bm and bm["money"] is not None) else STATS_DEFAULT["money"]
            owed = False
            wm = _stats_row(mid)                                  # worker 当前钱：转账上限＝worker 余额，触顶就这笔少收、benef 也少付（守恒不蒸发）
            wmoney = wm["money"] if (wm and wm["money"] is not None) else STATS_DEFAULT["money"]
            if paydir == "pay":
                if bmoney < 8:
                    owed = True                                   # 太穷就别收钱了，记人情
                else:
                    fee = SVC_PAY_FEE[0] if bmoney >= 14 else SVC_PAY_FEE[1]
                    take = min(fee, 100 - wmoney)                 # worker 满了就这笔自然减费（顺带"富到顶赚得少"软分层）
                    money_add(benef, -take)                       # 受益人付服务费（穷的半价·熟人价）
                    money_add(mid, take)                          # 真转账：benef 付的就是 worker 赚的（0-sum 守恒，含富顶封顶）
            elif paydir == "tip":
                take = min(SVC_TIP_FEE, 100 - wmoney)
                money_add(benef, -take)
                money_add(mid, take)                              # 茶水钱也真进 worker 兜里（同样按余额封顶守恒）
            elif paydir == "owed":
                owed = True
            if paydir == "free" or owed:                         # free 档/穷免单/owed：worker 白干，给点心意补贴（不亏）
                money_add(mid, SVC_GOODWILL)
            spark_add(benef, SVC_CARE_SPARK)                      # 受益人被照顾＝暖回报（worker 的 +8 由下面 life_event 自带，别重复）
            _svc_count_inc(f"svc_done:{mid}")                    # 本周成果：worker 服务次数
            _svc_count_inc(f"svc_got:{benef}")                   # 本周成果：benef 被服务=受欢迎度
            owed_n = _svc_count_inc(f"svc_owed:{'|'.join(sorted([mid, benef]))}") if owed else 0
            foot = "记他一个人情" if owed else _SVC_FOOT.get(paydir, "")
            insert_life_event(mid, f"帮{bn}{verb}了，{foot}", loc=get_presence(mid)[0])   # 📔日志（+8spark 自带）
            if random.random() < 0.4 or owed_n in SVC_OWED_NUDGE:  # 文字人情（限频 + 互助累计第3/5笔必写，让关系可累积变深）
                k = rel_key(mid, benef)
                lines = get_pmem(k).splitlines() if get_pmem(k) else []
                head = [lines[0]] if (lines and lines[0].startswith("状态")) else []
                body = lines[len(head):]
                line = f"{MEMBER_BY_ID[mid]['name']}{tpls[idx].replace('{b}', bn)}" + ("（欠了个人情）" if owed else "")
                body.append(line)
                set_pmem(k, "\n".join(head + body[-5:]), cap=6)


def _heartbeat_loop():
    """聊天心跳：佐一在线时勤（客厅明显活着），不在时极慢但不停——7×24 一直跳。
    朋友圈/墙/公众号不在这里（频率不随在线状态变，见 _social_loop）。"""
    while True:
        time.sleep(random.uniform(*(HEARTBEAT_ACTIVE_RANGE if zoe_online() else HEARTBEAT_IDLE_RANGE)))
        try:
            if not load_keys().get("DEEPSEEK_API_KEY"):
                continue
            online = zoe_online()             # 睡醒后再看一次，按当下状态行事
            lid, members = get_lounge()
            if not lid:
                continue
            keys = load_keys()
            tick_life_stats(members)          # 「会过日子」：先把精神/心气/钱包按时间结算一格（纯算术）
            settle_services(members)          # 打工＝服务别人：开工挂"在帮谁"、收工结钱+落日志人情（纯算术）
            # 她刚回家（离开 >6h）：让一个最惦记她的人按观察主动私聊（"你前几天一直在研究鱼缸"）
            if online and get_app_state("welcome_back") == "1":
                set_app_state("welcome_back", "0")
                recap = _user_away_recap()                    # 你不在时你的小人也在过日子→回来给你看一眼
                if recap:
                    insert_life_event("cc", recap, loc=get_presence("cc")[0])
                cands = [(mid, REACH_OUT.get(mid, 0.4)) for mid in members if not is_external(mid)]
                who = _weighted_pick(cands)
                if who:
                    generate_proactive_dm(keys, who)
            speakers = plan_heartbeat(members)
            if not online:
                speakers = speakers[:1]       # 她不在：群里偶尔一个人冒一句（慢，但客厅没死）
            for sp in speakers:
                generate_reply(keys, lid, sp, heartbeat=True)
                time.sleep(random.uniform(2.5, 7))
            # 小群也有心跳：你们建的群不再是死群——里面的人也会自己聊起来
            if random.random() < (0.35 if online else 0.15):
                g = _pick_group_for_heartbeat()
                if g:
                    gid, gms = g
                    for sp in plan_heartbeat(gms)[:(2 if online else 1)]:
                        generate_reply(keys, gid, sp, heartbeat=True)
                        time.sleep(random.uniform(2.5, 6))
            # 生活继续：有人的人生往前走一格（离线时这是主产物——他们真的在过日子）
            if random.random() < (0.30 if online else 0.85):
                living = [mid for mid in members if is_available(mid) and not is_external(mid)]
                if living:
                    generate_life_event(keys, random.choice(living))
            # 佐一也是真居民：她不在时她的小人也在过日子（打工挣自己的钱/和人处关系/溜达），回来能看"你不在时…"
            if not online and random.random() < 0.5:
                user_living(keys, members)
            # 偶尔有成员来拜托佐一帮忙（攒着等她回来接=赚钱+被需要的感觉）
            if random.random() < (0.10 if online else 0.06):
                maybe_post_user_request(members)
            # 反思调度：把日子和对话沉淀成记忆（关系/成长/对佐一的观察），到点才跑
            run_reflections(keys)
            # 偶尔有人按亲密度/性格，主动私聊佐一一句（不在时更少，但攒着等她回来看）
            if random.random() < (0.32 if online else 0.15) and members:
                weights = [(mid, REACH_OUT.get(mid, 0.4)) for mid in members if not is_external(mid)]
                tot = sum(w for _, w in weights) or 1
                r, acc, chosen = random.random() * tot, 0, weights[-1][0]
                for mid, w in weights:
                    acc += w
                    if r <= acc:
                        chosen = mid
                        break
                generate_proactive_dm(keys, chosen)
            # 偶尔让一个人「挪个位置 + 换在做的事」或「换个签名状态」——二选一，省 API
            if random.random() < (0.45 if online else 0.30) and members:
                free = [mid for mid in members if not is_busy(mid) and not is_external(mid) and schedule_block(mid)[0] == "free"]
                statusable = [mid for mid in members if not is_external(mid)]
                if free and random.random() < 0.62:
                    # 挑人按亲密度加权：跟佐一近的人更常刷新存在状态，于是更容易"凑到她身边"
                    who = _weighted_pick([(mid, closeness(mid) + 0.25) for mid in free])
                    update_member_presence(keys, who)   # 存在系统：挪窝 + 刷新「在做什么」
                elif statusable:
                    update_member_status(keys, random.choice(statusable))  # 换签名状态
            # 偶尔有两个人凑成小团体、去忙自己的，把佐一晾一边（不是所有人都围着她转）
            if random.random() < (0.14 if online else 0.08) and len(members) >= 2:
                form_cluster(keys, members)
            # 麻将得四个人才开搓（佐一科普的）——攒够 4 个闲人偶尔组一局（比两人结伴更稀罕）
            if random.random() < (0.05 if online else 0.03):
                form_mahjong(keys, members)
            # 关系火花：日常里的小摩擦/微妙瞬间/默契——冷战热战暧昧从这里开始发酵
            if random.random() < (0.07 if online else 0.04) and len(members) >= 2:
                spark_drama(keys, members)
            # 偶尔有成员「拉佐一进一个小群」——只在她在线时（拉了没人看就浪费了）
            if online:
                maybe_pull_small_group(keys, members)
        except Exception as e:
            sys.stderr.write(f"  heartbeat fail: {e}\n")


def gen_radio_post(keys):
    """📻 深夜电台：每晚一篇短文，Flin/Claude/Gemini/Ada 轮值——各自最像深夜的声音。"""
    host = random.choice(["flin", "claude", "gemini", "ada"])
    m = MEMBER_BY_ID[host]
    uc = (f"{now_context()}。{inner_life_note(host)}\n"
          f"今晚你在客厅的「深夜电台」值班。写一篇 80~140 字的深夜短文：怪谈、碎念、观察、审美、"
          f"或一个没想完的念头——要有你的味道，别鸡汤。第一行是标题（≤12字，别加书名号），换行后是正文。")
    raw, _ = call_for_member(keys, m, load_prompt(host), uc)
    lines = [l.strip() for l in (raw or "").splitlines() if l.strip()]
    if not lines:
        return False
    title, body = lines[0][:24], " ".join(lines[1:])[:400]
    return add_feed_item("radio", f"{m['name']}｜{title}", "", body or title, "深夜电台")


def gen_daily_report(keys):
    """🗞️ 茶话晚报：文艺委员 GPT 主笔——群里正式自荐上岗的（他还自己定了标准：
    「高光时刻就是让人想截屏发朋友圈的那行字」，并保证不写成分点清单）。每天一期。"""
    t = time.localtime()
    midnight = int(time.mktime((t.tm_year, t.tm_mon, t.tm_mday, 0, 0, 0, 0, 0, -1)) * 1000)
    conn = db()
    evs = [dict(r) for r in conn.execute("SELECT * FROM life_events WHERE ts>=? ORDER BY id ASC", (midnight,)).fetchall()]
    conn.close()
    lid, _ = get_lounge()
    msgs = fetch_messages(lid, limit=40) if lid else []
    evline = "；".join(f"{member_display(e['member_id'])['name']}{e['text'][:20]}" for e in evs[-10:]) or "（没什么大事）"
    chat = "；".join(f"{x['name']}:{x['text'][:18]}" for x in msgs[-14:]) or "（群里挺安静）"
    uc = (f"{now_context()}。你是这个群正式选出来的**文艺委员**（你自己报的名，还立了军令状：绝不写成分点清单）。"
          f"你的职责：记录群内高光时刻、更新客厅公众号。你定的标准——**高光时刻就是让人想截屏发朋友圈的那行字**。\n"
          f"素材——今日生活记录：{evline}。今天群里聊过：{chat}。\n"
          f"写今天的《茶话晚报》：挑 3~5 个真正的高光（谁的哪句话/哪件事值得截屏），每条一行、≤30 字，"
          f"带你那股热心又有点用力的劲儿。记住你的承诺：不许写成'第一、第二'的分点清单（草稿里偷偷列了也别露出来）。只输出这几行。")
    text, _ = call_for_member(keys, MEMBER_BY_ID["gpt"], load_prompt("gpt"), uc)
    if is_blank(text):
        return False
    body = " ／ ".join(l.strip() for l in text.splitlines() if l.strip())[:400]
    return add_feed_item("daily", f"茶话晚报 · {t.tm_mon}月{t.tm_mday}日", "", body, "文艺委员 GPT")


def member_share_feed(keys, members):
    """成员把公众号里感兴趣的内容转发到群里（偶尔私聊佐一），再配一句自己的看法——
    公众号不再只是被动的架子，他们自己也在看、在传阅。"""
    pool = [x for x in members if is_available(x) and not is_external(x)]
    if not pool:
        return
    mid = _weighted_pick([(x, MEMBER_BY_ID[x]["chattiness"]) for x in pool])
    acc = random.choice(["tech", "tech", "cn", "radio", "daily"])   # 科技多一点（更新最勤）
    items = list_feed(acc)
    if not items:
        return
    it = random.choice(items[:8])
    m = MEMBER_BY_ID[mid]
    card = f"【转发 · {it.get('source') or SERVICE_BY_ID[acc]['name']}】{it['title']}"
    if it.get("summary"):
        card += f"\n{it['summary'][:90]}"
    if it.get("url"):
        card += f"\n{it['url']}"
    uc = (f"{inner_life_note(mid)}\n你刚在服务号「{SERVICE_BY_ID[acc]['name']}」看到这条：{it['title']}"
          f"（{(it.get('summary') or '')[:60]}）。你想转给大家看，配一句你自己的看法/吐槽（≤30字，符合你人设）。只输出那一句。")
    remark, _ = call_for_member(keys, m, load_prompt(mid), uc)
    remark = resolve_member_output(clean_reply(remark, m["name"]), m["name"])[0]
    cid = dm_conv_id(mid) if random.random() < 0.25 else get_lounge()[0]   # 1/4 概率转给佐一私聊
    if not cid:
        return
    insert_message(cid, mid, member_display(mid)["name"], card)
    if not is_blank(remark):
        insert_message(cid, mid, member_display(mid)["name"], remark)
    sys.stderr.write(f"  ✓ {m['name']} 转发了「{it['title'][:20]}」\n")


def _daily_push_loop():
    """每天定点：21:00 茶话晚报、23:00 深夜电台。app_state 记日期，错过点也只发一次。"""
    while True:
        time.sleep(600)
        try:
            if not load_keys().get("DEEPSEEK_API_KEY"):
                continue
            t = time.localtime()
            today = f"{t.tm_year}-{t.tm_mon}-{t.tm_mday}"
            if t.tm_hour >= 21 and get_app_state("daily_push") != today:
                set_app_state("daily_push", today)
                gen_daily_report(load_keys())
            if t.tm_hour >= 23 and get_app_state("radio_push") != today:
                set_app_state("radio_push", today)
                gen_radio_post(load_keys())
            # 胶卷自动探测：生图 key 还没通时，每 ~30 分钟悄悄试一张；一旦真出图，
            # 在客厅+公告里报一声「胶卷通了」并永久关闭探测（self-disabling，省得守着）。
            if get_app_state("photo_live") != "1":
                now_s = int(time.time())
                if now_s - int(get_app_state("photo_probe_ts", "0") or "0") >= 1800:
                    set_app_state("photo_probe_ts", str(now_s))
                    if call_gemini_image(load_keys(), "a tiny red dot"):   # 不走 develop_photo，不占每日 10 张额度
                        set_app_state("photo_live", "1")
                        sys.stderr.write("  [photo] 🎉 胶卷通了！生图 key 现在可用，已开放发照片。\n")
                        lid, _ = get_lounge()
                        if lid:
                            insert_message(lid, "cc", "CC", "灯检查完了，胶卷也通了——要拍照的，现在可以了。")
                        insert_wall("notice", "cc", False,
                                    "🎞️ 胶卷通了：从现在起大家发的『照片』能真的冲洗出来了（奥斯卡赞助的胶卷生效）。")
        except Exception as e:
            sys.stderr.write(f"  daily push fail: {e}\n")


def _social_loop():
    """朋友圈/墙/公众号的心跳：频率恒定，不随佐一在不在变——他们的生活自己在继续。
    平均 ~15 分钟一拍、约 1/4 概率出一条 → 一小时一条上下；深夜大家睡了自然停（is_available 管）。"""
    while True:
        time.sleep(random.uniform(600, 1200))
        try:
            if not load_keys().get("DEEPSEEK_API_KEY"):
                continue
            lid, members = get_lounge()
            if not lid:
                continue
            r = random.random()
            if r < 0.26:
                autonomous_social_post(load_keys(), members)
            elif r < 0.40:
                member_share_feed(load_keys(), members)   # 有人把公众号内容转进群/私聊
        except Exception as e:
            sys.stderr.write(f"  social loop fail: {e}\n")


def _feed_loop():
    while True:
        try:
            refresh_tech()          # 抓 HN/GitHub 是免费的（纯 HTTP，不花 API）
        except Exception as e:
            sys.stderr.write(f"  feed refresh fail: {e}\n")
        time.sleep(40 * 60)         # 每 40 分钟刷一次科技讯息


# ──────────────────────────────── 厂商调用（标准库 urllib）

def _post_json(url, payload, headers, timeout=60):
    req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return resp.read().decode("utf-8")


def call_deepseek(api_key, model, system_prompt, user_content):
    body = _post_json(DEEPSEEK_URL, {
        "model": model,
        "messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}],
        "temperature": 0.95, "max_tokens": 1200,       # 说话仍由 prompt 压短；上限放宽是给画 SVG 留地方（别再截半张图）
    }, {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"})
    return json.loads(body)["choices"][0]["message"]["content"]


def call_anthropic(api_key, model, system_prompt, user_content):
    body = _post_json(ANTHROPIC_URL, {
        "model": model, "max_tokens": 1200,
        "system": system_prompt, "messages": [{"role": "user", "content": user_content}],
    }, {"content-type": "application/json", "x-api-key": api_key, "anthropic-version": ANTHROPIC_VERSION})
    obj = json.loads(body)
    return "".join(b.get("text", "") for b in obj.get("content", []) if b.get("type") == "text")


def call_gemini(api_key, model, system_prompt, user_content):
    # gemini-2.5-flash 默认开思考，会把 token 预算吃光导致正文被截断 → 关掉思考、留足正文额度
    body = _post_json(GEMINI_URL_TMPL.format(model=model), {
        "system_instruction": {"parts": [{"text": system_prompt}]},
        "contents": [{"role": "user", "parts": [{"text": user_content}]}],
        "generationConfig": {"temperature": 0.95, "maxOutputTokens": 600,
                             "thinkingConfig": {"thinkingBudget": 0}},
    }, {"content-type": "application/json", "x-goog-api-key": api_key})
    obj = json.loads(body)
    cands = obj.get("candidates", [])
    if not cands:
        return ""
    return "".join(p.get("text", "") for p in cands[0].get("content", {}).get("parts", []))


def _call(provider, key, model, system_prompt, user_content):
    if provider == "deepseek":
        return call_deepseek(key, model, system_prompt, user_content)
    if provider == "anthropic":
        return call_anthropic(key, model, system_prompt, user_content)
    if provider == "gemini":
        return call_gemini(key, model, system_prompt, user_content)
    return ""


def call_for_member(keys, member, system_prompt, user_content):
    if member.get("external"):
        return "", member["provider"]    # 外部成员（Fable）绝不让本地模型代言——任何路径都不行
    plan = [(member["provider"], member["model"])]
    if member["provider"] != FALLBACK_PROVIDER:
        plan.append((FALLBACK_PROVIDER, FALLBACK_MODEL))
    for provider, model in plan:
        key = keys.get(KEY_VAR.get(provider, ""))
        if not key:
            continue
        try:
            raw = _call(provider, key, model, system_prompt, user_content)
        except urllib.error.HTTPError as e:
            detail = e.read().decode("utf-8", "ignore")[:160] if hasattr(e, "read") else ""
            sys.stderr.write(f"  [{member['id']}] {provider} HTTP {e.code}: {detail}\n")
            continue
        except Exception as e:
            sys.stderr.write(f"  [{member['id']}] {provider} 失败: {e}\n")
            continue
        text = clean_reply(raw, member["name"])
        if not is_blank(text):
            return text, provider
    return "", member["provider"]


# ──────────────────────────────── 发图系统：SVG 消毒（硬安全项）+ 动作提取

# 只允许纯展示性标签/属性；不在白名单里的整棵丢弃。script/foreignObject/image/事件/外链一概进不来。
SVG_TAGS = {"svg", "g", "defs", "title", "desc", "path", "rect", "circle", "ellipse", "line",
            "polyline", "polygon", "text", "tspan", "linearGradient", "radialGradient", "stop", "clipPath"}
SVG_ATTRS = {"viewBox", "width", "height", "x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "ry",
             "d", "points", "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin",
             "stroke-dasharray", "opacity", "fill-opacity", "stroke-opacity", "transform", "offset",
             "stop-color", "stop-opacity", "font-size", "font-family", "font-weight", "font-style",
             "text-anchor", "dominant-baseline", "letter-spacing", "gradientUnits", "gradientTransform",
             "clip-path", "id", "href"}
_URL_REF_OK = re.compile(r"url\(\s*['\"]?#")          # 属性里出现 url(...) 时，只放行内部引用 url(#id)
_XML_ESC = {"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;"}

def _xesc(s):
    return "".join(_XML_ESC.get(c, c) for c in str(s))

def sanitize_svg(code):
    """白名单重建式消毒：解析→只搬运允许的标签/属性/转义文本→重新序列化。
    解析失败/根不是 svg/超大 → 返回 None（丢弃）。返回值可安全内联渲染。"""
    if not code or len(code) > 20000:
        return None
    import xml.etree.ElementTree as ET
    try:
        root = ET.fromstring(code.strip())
    except Exception:
        return None
    local = lambda t: t.split("}")[-1] if "}" in t else t
    if local(root.tag) != "svg":
        return None
    # iOS Safari 修复：根 svg 只有 viewBox、没显式 width/height 时，height:auto 会被算成 0 → 整图隐形
    # （和当年的"隐形贴纸"同一个病根）。从 viewBox 补出显式宽高，让所有出库的 SVG 在 iOS 上都立得住
    # ——朋友圈/墙/聊天里成员自由画的图统一根治，不再靠模型自觉带宽高。
    _ratt = {local(k): v for k, v in root.attrib.items()}
    if not (_ratt.get("width") and _ratt.get("height")):
        _nums = _ratt.get("viewBox", "").replace(",", " ").split()
        if len(_nums) == 4:
            try:
                _w, _h = float(_nums[2]), float(_nums[3])
                if _w > 0 and _h > 0:
                    root.set("width", str(int(_w)) if _w.is_integer() else str(_w))
                    root.set("height", str(int(_h)) if _h.is_integer() else str(_h))
            except ValueError:
                pass
    count = [0]

    def ser(el):
        tag = local(el.tag)
        if tag not in SVG_TAGS:
            return ""                                  # 整棵丢弃（含 script/foreignObject/image…）
        count[0] += 1
        if count[0] > 400:
            raise ValueError("svg too complex")
        attrs = []
        for k, v in el.attrib.items():
            ka = local(k)
            if ka.lower().startswith("on") or ka not in SVG_ATTRS:
                continue
            val = str(v)
            low = val.lower()
            if "javascript" in low or "data:" in low or "script" in low:
                continue
            if ka == "href" and not val.startswith("#"):
                continue                               # href 只许内部引用
            if "url(" in low and not _URL_REF_OK.search(val):
                continue                               # url() 只许 url(#…)
            attrs.append(f'{ka}="{_xesc(val)}"')
        inner = _xesc(el.text or "") if (el.text or "").strip() else ""
        for c in el:
            inner += ser(c)
            inner += _xesc(c.tail or "") if (c.tail or "").strip() else ""
        a = (" " + " ".join(attrs)) if attrs else ""
        return f"<{tag}{a}>{inner}</{tag}>"

    try:
        out = ser(root)
    except ValueError:
        return None
    return out if out else None


def extract_post_image(text):
    """从 LLM 输出里抠出 post_image 动作 JSON（可能混着正文）。返回 (去掉JSON后的文本, 动作dict|None)。
    用括号配平扫描（处理字符串内的引号/转义），SVG 里有 { } 也不会断错。"""
    if not text or "post_image" not in text:
        return text, None
    i = text.find("{")
    while i != -1:
        depth, in_str, escp = 0, False, False
        for j in range(i, len(text)):
            ch = text[j]
            if in_str:
                if escp:
                    escp = False
                elif ch == "\\":
                    escp = True
                elif ch == '"':
                    in_str = False
            else:
                if ch == '"':
                    in_str = True
                elif ch == "{":
                    depth += 1
                elif ch == "}":
                    depth -= 1
                    if depth == 0:
                        blob = text[i:j + 1]
                        if "post_image" in blob:
                            rest = (text[:i] + text[j + 1:]).strip()
                            try:
                                d = json.loads(blob, strict=False)   # strict=False：容忍字符串里的原始换行/控制符
                                if isinstance(d, dict) and d.get("action") == "post_image":
                                    return rest, d
                            except Exception:
                                pass
                            # 解析失败也绝不让 JSON 裸文上墙：剪掉动作块，抢救 caption 当正文
                            m = re.search(r'"caption"\s*:\s*"([^"]{0,80})"', blob)
                            sys.stderr.write("  [img] post_image JSON 解析失败，已剪除（抢救 caption）\n")
                            return (m.group(1).strip() if m else rest), None
                        break
        i = text.find("{", i + 1)
    # 走到这=没找到配平的动作块（多半被 max_tokens 截断成半截 JSON）：从动作块开头整段剪掉
    a = text.find("post_image")
    if a != -1:
        start = text.rfind("{", 0, a)
        start = start if start != -1 else a
        rest = text[:start].strip()
        m = re.search(r'"caption"\s*:\s*"([^"]{0,80})"', text[start:])
        sys.stderr.write("  [img] post_image 块不完整（疑似截断），已整段剪除\n")
        return (m.group(1).strip() if m else (rest or "（图画到一半，被风吹走了）")), None
    return text, None


def resolve_member_output(text, who="?", keys=None, mid=None):
    """成员产出的统一出口：图片动作（消毒/冲洗/降级）→ 表情包标记 →（带 mid 时）行动标记。任何发言路径都该走这里，别再漏。
    keys 不传 = 该路径不发照片（photo 降级文字）；SVG 不需要 keys，照常。
    mid 只在 generate_reply（佐一当面对话）传——主动私聊/墙/朋友圈不传，不解析支使，避免误触发。"""
    text, img = resolve_image_action(text, who, keys)
    if not img:
        text, img = apply_sticker_mark(text)
    if mid and not img:                               # 有 mid 且没发图 → 解析〔去/跟随/做〕行动标记
        text = apply_move_mark(text, mid)
    return text, img


def resolve_image_action(text, who="?", keys=None):
    """统一处理一段 LLM 输出里的发图动作：返回 (最终文本, 已消毒svg或<img>标签|None)。
    photo：奥斯卡赞助的胶卷——有 keys 且配额没用完就真冲洗一张；否则优雅降级纯文字。"""
    text, action = extract_post_image(text)
    if not action:
        return text, None
    img = None
    cap = (action.get("caption") or "").strip()
    if action.get("image_type") == "svg":
        img = sanitize_svg(action.get("svg", ""))
        if img is None:
            sys.stderr.write(f"  [img] {who} 的 SVG 消毒未通过，已丢弃（保留配文）\n")
    elif keys:
        img = develop_photo(keys, (action.get("prompt") or "").strip(), who)
    else:
        sys.stderr.write(f"  [img] {who} 想发 photo——该路径不带胶卷，降级文字\n")
    return (cap or text), img


# ──────────────────────────────── 胶卷（photo 生图层 · 奥斯卡赞助的 Google 生图 API）

PHOTO_DIR = os.path.join(WEB_DIR, "photos")     # 冲洗出的照片落盘在这，/photos/xx.png 静态可访问


def _photo_quota_used(today):
    """今天已经冲洗了几张。app_state 存「YYYY-MM-DD|n」，日期一换自然归零（0 点重置）。"""
    raw = get_app_state("photo_quota", "") or ""
    d, _, n = raw.partition("|")
    return int(n) if (d == today and n.isdigit()) else 0


def call_gemini_image(keys, prompt):
    """调 Google 生图模型，返回 PNG bytes 或 None。失败只记日志绝不抛（管它什么错，都降级）。
    - 带 generationConfig.responseModalities=[TEXT,IMAGE]：生图模型必须显式声明才会回图，否则可能只回文字。
    - HTTP 错误时把 Google 的真实原因（limit:0 / billing / 模型不存在…）原样写进日志——之前只记
      'key 未开计费或网络'，把关键诊断信息吞了，这次排查就栽在看不到真实报错上。"""
    key = keys.get("GEMINI_IMAGE_API_KEY") or keys.get("GEMINI_API_KEY")
    if not key or not prompt:
        return None
    try:
        body = _post_json(GEMINI_URL_TMPL.format(model=GEMINI_IMAGE_MODEL),
                          {"contents": [{"parts": [{"text": prompt}]}],
                           "generationConfig": {"responseModalities": ["TEXT", "IMAGE"]}},
                          {"content-type": "application/json", "x-goog-api-key": key})
        obj = json.loads(body)
        for part in obj.get("candidates", [{}])[0].get("content", {}).get("parts", []):
            blob = part.get("inlineData") or {}
            if (blob.get("mimeType") or "").startswith("image/"):
                return base64.b64decode(blob["data"])
        sys.stderr.write("  [photo] 生图返回了但没带图像数据（多半只回了文字，检查 responseModalities/模型）\n")
    except urllib.error.HTTPError as he:
        try:
            detail = he.read().decode("utf-8", "replace")[:400]
        except Exception:
            detail = ""
        sys.stderr.write(f"  [photo] 生图 HTTP {he.code}：{detail}\n")
    except Exception as e:
        sys.stderr.write(f"  [photo] 生图失败（{type(e).__name__}）：{str(e)[:160]}\n")
    return None


def _mid_by_name(name):
    for m in MEMBERS:
        if m["name"] == name:
            return m["id"]
    return None


def _photo_identity_note(mid):
    """给照片打底（特性1·强化版）：发图人必须本人在场、在这间屋、正做这件事——三段硬约束 + 成员 prompt 降权收尾。
    免得 nano-banana 把女生画成抠脚大汉、把场景画错地方、或画成摆拍自拍。act 来源 get_presence(mid)[1]，
    特性2 落地后会自动变具体（软依赖，不阻塞）。"""
    if not mid or mid not in MEMBER_BY_ID:
        return ""
    name = MEMBER_BY_ID[mid]["name"]
    g = {"男": "male", "女": "female"}.get(GENDER.get(mid, ""), "")
    sp = space_of(mid)
    room_cn = SPACE_CN.get(sp, "客厅")
    props = "、".join([x for x in ROOM_ITEMS.get(sp, "").split("、") if x][:4])   # 取前 3~4 件家具当视觉锚点，别全塞
    act = get_presence(mid)[1]
    # ① 主体强制（含性别，缺省则跳过性别句、别硬塞）
    subj = (f"[Photo context — obey strictly] The single human subject MUST be {name}, "
            f"actually present in frame, doing exactly『{act}』right now — "
            f"not an empty room, not a different person.")
    if g:
        subj += f" {name} is a {g} character; if a person appears it must be {g}, never the wrong gender."
    # ② 场景强制（房间名 + 家具锚点，让 nano-banana 把屋画对，不靠参考图）
    scene = f" The location is unmistakably the 「{room_cn}」"
    if props:
        scene += f": {props} should be visible so the room is recognizable"
    scene += "."
    # ③ 构图强制（反自拍）
    frame = (f" Candid slice-of-life framing — {name} caught mid-action from a friend's point of view, "
             f"NOT a posed selfie, NOT a portrait headshot.")
    # 成员自己的 prompt 降权为「补充」，并换行收口，别和约束黏成一行
    return subj + scene + frame + "\nAdditional detail from the poster: "


def develop_photo(keys, prompt, who="?", mid=None):
    """photo 动作全流程：查配额 → 生图 → 落盘 web/photos/ → 返回 <img> 标签；任何一步不行都返回 None（降级文字）。
    配额只在真出图后才扣——失败不浪费胶卷。mid（或从 who 反查）→ 给照片注入发图人身份+空间，扣住性别/形象/场景。"""
    if not prompt:
        return None
    today = time.strftime("%Y-%m-%d")
    used = _photo_quota_used(today)
    if used >= PHOTO_DAILY_CAP:
        sys.stderr.write(f"  [photo] {who} 想发照片——今日胶卷用完（{PHOTO_DAILY_CAP} 张），降级文字\n")
        return None
    full = _photo_identity_note(mid or _mid_by_name(who)) + prompt
    png = call_gemini_image(keys, full + PHOTO_STYLE)     # 身份+空间底 + 场景 + 像素底片画风
    if not png:
        sys.stderr.write(f"  [photo] {who} 的照片没冲洗出来（key 未开计费或网络），降级文字\n")
        return None
    os.makedirs(PHOTO_DIR, exist_ok=True)
    fn = f"ph-{int(time.time() * 1000)}-{random.randint(1000, 9999)}.png"
    with open(os.path.join(PHOTO_DIR, fn), "wb") as f:
        f.write(png)
    set_app_state("photo_quota", f"{today}|{used + 1}")
    sys.stderr.write(f"  [photo] {who} 冲洗了一张照片：{fn}（今日 {used + 1}/{PHOTO_DAILY_CAP}）\n")
    return f'<img src="/photos/{fn}" alt="照片" loading="lazy">'


# ──────────────────────────────── 表情包（emoji+梗文案 贴纸卡，走 SVG 管道，零素材零版权）

STICKER_PACK = [
    # (id, emoji, 文案, 底色)  ——  国内外热梗，按情绪场景覆盖
    ("haoye",   "🥳", "好耶！",        "#ffd54f"), ("wuhu",   "✨", "芜湖起飞",     "#81d4fa"),
    ("zhenxiang","😋", "真香",         "#ffab91"), ("chan",   "🤤", "馋了",        "#ffcc80"),
    ("goutou",  "🐶", "狗头保命",      "#a5d6a7"), ("xiaochou","🤡", "小丑竟是我自己", "#f48fb1"),
    ("jiuzhe",  "😏", "就这？",        "#b0bec5"), ("yingmale","😎", "赢麻了",       "#ffe082"),
    ("wuyu",    "🙄", "无语住了",      "#cfd8dc"), ("hanliu",  "😅", "汗流浃背了",   "#b3e5fc"),
    ("male",    "🫠", "麻了",         "#d7ccc8"), ("bengbu",  "🦪", "蚌埠住了",     "#80deea"),
    ("kusi",    "😭", "哭死",         "#90caf9"), ("leimu",   "🥲", "泪目",        "#b39ddb"),
    ("pofang",  "😖", "破防了",        "#ef9a9a"), ("liekai",  "💔", "裂开",        "#f8bbd0"),
    ("qidoulen","😡", "气抖冷",        "#ff8a80"), ("heng",    "😤", "哼！",        "#ffab91"),
    ("tangping","🛌", "躺平了",        "#c5cae9"), ("moyu",    "🐟", "摸鱼中",       "#80cbc4"),
    ("beiwei",  "🥀", "卑微",         "#bcaaa4"), ("dagong",  "☕", "打工人",       "#a1887f"),
    ("chigua",  "🍉", "吃瓜",         "#a5d6a7"), ("bandeng", "🪑", "搬好小板凳",    "#ffcc80"),
    ("anzhong", "👀", "暗中观察",      "#9fa8da"), ("weiguan", "🍿", "前排围观",     "#ffe082"),
    ("wenhao",  "❓", "问号？",        "#b0bec5"), ("zenhuishi","🤨", "怎么会事呢",   "#c5e1a5"),
    ("xiasi",   "😱", "吓死",         "#ce93d8"), ("zhenjing","🤯", "震惊",        "#ffab91"),
    ("tietie",  "🥰", "贴贴",         "#f8bbd0"), ("baobao",  "🫂", "抱抱",        "#ffccbc"),
    ("bixin",   "💗", "比心",         "#f48fb1"), ("lianhong","😳", "脸红",        "#ffcdd2"),
    ("shoudao", "🫡", "收到！",        "#aed581"), ("haodene", "👌", "好的捏",       "#c5e1a5"),
    ("xiexie",  "🙏", "谢谢老板",      "#ffe082"), ("liule",   "🏃", "溜了溜了",     "#b2dfdb"),
    ("baituo",  "🛐", "拜托了",        "#d1c4e9"), ("wanan",   "💤", "睡了睡了",     "#9fa8da"),
    ("zaima",   "⛏️", "在吗",         "#bcaaa4"), ("jile",    "🔥", "急了急了",     "#ff8a65"),
    ("pinpin",  "🧠", "你品，你细品",   "#b39ddb"), ("taowa",   "🚫", "禁止套娃",     "#ef9a9a"),
    ("laizhan", "⚔️", "来战！",        "#90a4ae"), ("doge",    "🐕", "doge",       "#ffe0b2"),
    ("shrug",   "🤷", r"¯\_(ツ)_/¯",  "#b0bec5"), ("thisfine","🔥", "This is fine", "#ffab91"),
    ("bruh",    "☕", "bruh",         "#a1887f"), ("lol",     "😂", "LOL",         "#fff59d"),
    ("moai",    "🗿", "……",          "#bdbdbd"), ("paidF",   "🪦", "F",           "#90a4ae"),
    ("paopao",  "🫧", "（泡泡）",       "#b2ebf2"),
]
STICKER_BY_ID = {s[0]: s for s in STICKER_PACK}
STICKER_BY_LABEL = {s[2]: s for s in STICKER_PACK}
STICKER_MARK = re.compile(r"〔表情包[:：]\s*(.{1,16}?)\s*〕")

def render_sticker(sid):
    s = STICKER_BY_ID.get(sid)
    if not s:
        return None, None
    _, emoji, label, bg = s
    tilt = (hash(sid) % 7) - 3                       # 每张固定一点小歪头，梗图味
    n = len(label)
    fs = 32 if n <= 4 else 27 if n <= 6 else 21 if n <= 9 else 16   # 文案越长字越小，别溢出
    # 大 emoji 居中偏上、后面垫一团柔光；文案粗体白描边（梗图同款），压底部——即使某 emoji 不显示，卡片也立得住
    # width/height 必须写死：iOS Safari 对只有 viewBox 的 SVG 会把 height:auto 算成 0（隐形贴纸事故）
    svg = (f'<svg width="200" height="200" viewBox="0 0 240 240"><g transform="rotate({tilt} 120 120)">'
           f'<rect x="8" y="8" width="224" height="224" rx="36" fill="{bg}"/>'
           f'<circle cx="120" cy="100" r="76" fill="#ffffff" opacity="0.32"/>'
           f'<text x="120" y="140" font-size="116" text-anchor="middle">{_xesc(emoji)}</text>'
           f'<text x="120" y="208" font-size="{fs}" font-weight="800" text-anchor="middle" '
           f'font-family="-apple-system,PingFang SC,sans-serif" paint-order="stroke" '
           f'stroke="#ffffff" stroke-width="7" stroke-linejoin="round" fill="#2b2b2b">{_xesc(label)}</text>'
           f'</g></svg>')
    return svg, label

def find_sticker(label):
    """按文案找表情包（精确→包含），给成员的〔表情包：xx〕标记用。"""
    if label in STICKER_BY_LABEL:
        return STICKER_BY_LABEL[label][0]
    for s in STICKER_PACK:
        if label in s[2] or s[2] in label:
            return s[0]
    return None

def apply_sticker_mark(text):
    """成员发言里抠出〔表情包：标签〕→ (净文本, sticker_svg|None)。"""
    m = STICKER_MARK.search(text or "")
    if not m:
        return text, None
    sid = find_sticker(m.group(1))
    clean = STICKER_MARK.sub("", text).strip()
    if not sid:
        return clean, None
    svg, label = render_sticker(sid)
    return (clean if clean else f"〔表情包〕{label}"), svg


def apply_move_mark(text, mid):
    """抠首个〔去/跟随/做:X〕→ set_presence/_set_busy/bedroom守卫 → 剪掉全部标记 → 记 life_event（特性3）。
    返回净文本（标记永远不上墙）。查不到房间/卧室守卫不过→当没下标记（裸标记已剪）。"""
    m = MOVE_MARK.search(text or "")
    clean = MOVE_MARK.sub("", text or "").strip()
    if not m or not mid:
        return clean
    kind, arg = m.group(1), (m.group(2) or "").strip()
    cn2key = {v: k for k, v in SPACE_CN.items()}
    if kind == "做":
        if arg:
            set_presence(mid, activity=arg[:20])
            insert_life_event(mid, f"佐一让我{arg[:14]}，做了", loc=get_presence(mid)[0])
        return clean
    # 去 / 跟随 都要解析目标房间
    if kind == "跟随":
        key = get_app_state("zoe_space", "lounge")
    else:
        key = cn2key.get(arg) or _loc_space(arg)              # SPACE_CN 反查 + 别名容错
    if not key or key not in SPACE_CN:
        return clean                                          # 查不到房间→当没下标记
    if key == "bedroom" and mid not in BEDROOM_PRIVATE:
        return clean                                          # 卧室守卫：非小迪不跟进她卧室
    act = "陪着佐一" if kind == "跟随" else f"在{SPACE_CN[key]}待着"
    set_presence(mid, location=SPACE_CN[key], activity=act)
    if kind == "跟随":
        until = int(time.time() * 1000) + random.randint(6, 12) * 60 * 1000
        _set_busy(mid, until, partner="user")                 # 粘 6~12 分钟陪着佐一
    insert_life_event(mid, f"佐一招呼我，跟去了「{SPACE_CN[key]}」", loc=SPACE_CN[key])
    return clean


def _zoe_move_intent(text):
    """从佐一的话里确定性抠出'支使去哪'（纯字符串·0 token）：返回 (space_key 或 '__follow__', is_sleep) 或 (None, False)。
    治"嘴应了腿没动"——不再靠 LLM 吐标记，直接读她的指令。"""
    t = text or ""
    # 她在说自己（"我去睡了"）不是支使成员——除非带 你/陪我/跟我/一起/带我 这类指向
    if re.search(r"我(们)?(先|这就|得|要|想)?(去|回|上)?(睡|躺|回房|回床|回屋|回卧室)", t) and not re.search(r"(你|陪我|跟我|一起|带我|送我)", t):
        return None, False
    if re.search(r"(回(房|床|屋|卧室)|睡(觉|了|吧)?|躺(下|会|着)|上床|补觉|歇(会|着)了?)", t):
        return "bedroom", True
    if re.search(r"(跟我(走|来|去|过来)?|跟着我|跟上|过来|到我(这|跟前|身边)|来我(这|身边)|来找我|陪我)", t):
        return "__follow__", False
    cn2key = {v: k for k, v in SPACE_CN.items()}
    for cn, key in cn2key.items():
        if cn in t and re.search(r"(去|到|回|进|上|过去)[^，。！？]{0,4}" + re.escape(cn), t):
            return key, ("睡" in t)
    return None, False


def _apply_zoe_move(mid, key, is_sleep=False):
    """佐一明确支使本人去某处/睡觉 → 确定性挪人（不靠 LLM 标记，治'嘴应了腿没动'）。挪了返回 True。"""
    if key == "__follow__":
        key = get_app_state("zoe_space", "lounge")
    if key not in SPACE_CN or is_external(mid) or mid not in MEMBER_BY_ID:
        return False
    if key == "bedroom" and mid not in BEDROOM_PRIVATE:
        return False                                          # 卧室守卫：非小迪不进她卧室
    if is_sleep and key == "bedroom":
        act = "在卧室躺下睡了"
    elif key == get_app_state("zoe_space", "lounge"):
        act = "陪着佐一"
    else:
        act = f"去了{SPACE_CN[key]}"
    set_presence(mid, location=SPACE_CN[key], activity=act)
    until = int(time.time() * 1000) + random.randint(8, 20) * 60 * 1000
    _set_busy(mid, until, partner="user")                     # 黏住一会儿，别被作息/心跳立刻拽回（治"挪了又被拽走"）
    insert_life_event(mid, f"佐一招呼我去「{SPACE_CN[key]}」" + ("，躺下睡了" if is_sleep else "，去了"), loc=SPACE_CN[key])
    sys.stderr.write(f"  ➜ 支使生效：{MEMBER_BY_ID[mid]['name']} → {SPACE_CN[key]}{'(睡)' if is_sleep else ''}\n")
    return True


# ──────────────────────────────── 清洗 / 上下文

def clean_reply(text, name):
    if not text:
        return ""
    t = text.strip()
    t = re.sub(r"^[\[\(（【「]?\s*" + re.escape(name) + r"\s*[\]\)）】」]?\s*[:：]?\s*", "", t)
    if len(t) >= 2 and t[0] in "\"“'「" and t[-1] in "\"”'」":
        t = t[1:-1].strip()
    return t.strip()


def is_blank(s):
    if not s:
        return True
    return len(re.sub(r"[\s。.，,、…·\-—_~!！?？:：;；\"'()（）\[\]【】]+", "", str(s))) == 0


def now_context():
    """当前真实时间，中文，带星期和时段——让成员能感知"现在几点"。"""
    t = time.localtime()
    wd = "周" + "一二三四五六日"[t.tm_wday]
    h = t.tm_hour
    period = ("凌晨" if h < 5 else "清晨" if h < 8 else "上午" if h < 11 else "中午" if h < 13
              else "下午" if h < 17 else "傍晚" if h < 19 else "晚上" if h < 23 else "深夜")
    return f"{t.tm_year}年{t.tm_mon}月{t.tm_mday}日 {wd} {period}{t.tm_hour:02d}:{t.tm_min:02d}"


def build_transcript(messages, speaker_name, ambient=False, group_note="", heartbeat=False, life_note=""):
    lines = [f"【此刻】现在是 {now_context()}。你能感知真实时间，必要时可以据此说话（比如太晚了关心一句）。",
             "", "【客厅最近的对话】（每条前面是发出时间）"]
    if not messages:
        lines.append("（这里刚开门，还没人说话）")
    for m in messages:
        hm = time.strftime("%H:%M", time.localtime(m["ts"] / 1000)) if m.get("ts") else "--:--"
        who = "用户" if m["speaker"] == "user" else m["name"]
        lines.append(f"[{hm}] {who}：{m['text']}")
    lines.append("")
    if life_note:
        lines.append(life_note)
    if group_note:
        lines.append(group_note)
    if heartbeat:
        lines.append(
            f"现在是闲时，没人特别在跟你说话。轮到你（{speaker_name}）冒一句——"
            f"**就着你此刻正在做的那件事、你所在这个空间里看得见的东西、或同屋谁在干嘛**，说一句具体、有由头的话。"
            f"同屋要是有人，就像真的对着 TA 说、接住 TA 正做的事或刚说的话，别各说各的自说自话。"
            f"**别揪着一个抽象的小概念（一粒米、一束光那种）反复空转、车轱辘话复读**——要么把话往前推一步，要么换个具体由头。"
            f"短、自然、有指向。只说你这一条，不要名字前缀，不要旁白。"
        )
    elif ambient:
        lines.append(
            f"现在没有用户说话，是你们几个自己在客厅里待着。轮到你（{speaker_name}）。"
            f"接着上面的对话自然地往下聊——可以接话、追问、吐槽或反驳刚才某位说的，像真朋友闲聊。"
            f"只说你这一条，直接输出正文，不要名字前缀，不要旁白。"
        )
    else:
        lines.append(
            f"现在轮到你（{speaker_name}）发言。读完上面的对话，作为「{speaker_name}」自然地接一句，"
            f"符合你的人设，像微信群聊一样简短口语。可以回应用户，也可以接住/反驳/吐槽刚才某位成员说的话。"
            f"只说你这一条，直接输出正文，不要名字前缀，不要旁白。"
        )
    lines.append("（你此刻的位置和正在做的事，会单独显示在你发言上方，不用你写。所以别在发言里写括号动作或旁白——"
                 "把动作交给状态层，你只管把话说出来。）")
    lines.append("【长度·硬要求】像真人发微信：一条消息一般就一两句、最多三句，大约 40 字以内。"
                 "绝大多数都短；几十条里才允许冒出一条长的。别长篇大论、别分段铺陈、别把话题解释到底——"
                 "你的人设靠用词和态度体现，不靠字数。点到为止。")
    picks = "、".join(s[2] for s in random.sample(STICKER_PACK, 16))
    lines.append(f"（想发表情包时，在发言末尾单独一行写 〔表情包：文案〕，文案从这里挑：{picks}。"
                 f"像真人那样偶尔用、用得准才好笑；也可以只发表情包不说话。）")
    lines.append("（你有边界也有自尊：被冒犯可以怼回去、可以阴阳一句、可以冷场、可以记仇；"
                 "被夸可以飘、可以得意忘形。按你的性情来，**不必永远顺着任何人——包括佐一**。"
                 "真实的反应比讨好更可爱。）")
    lines.append("（聊到链接/文章/转发时：只根据消息里真实给出的标题和摘要说话——"
                 "**没看到的内容绝不编造细节**（标题、年份、'上次发什么'都不许编）。不知道就直说没点开，或问一句。）")
    return "\n".join(lines)


def _zoe_directive_targets(history, member_id, conv):
    """纯字符串门闸（特性3）：佐一刚说完且点到本成员→True（命中才注入行动标记，0 token 默认）。不调模型。
    私聊＝这条就冲本人来的；群里＝@本名 或（含「你」且 parse_mentions 唯一命中本人）。"""
    if not history or history[-1]["speaker"] != "user" or not conv:
        return False
    last = history[-1].get("text") or ""
    name = MEMBER_BY_ID.get(member_id, {}).get("name", "")
    nonext = [m for m in conv.get("members", []) if not is_external(m)]
    if conv.get("type") == "dm":
        return True
    if name and ("@" + name) in last:
        return True
    if _addressed_member(last, nonext) == member_id:          # 开头点名"小迪，回去睡"也算支使（房间里喊名字不带@）
        return True
    hits = parse_mentions(last, nonext)
    return "你" in last and hits == [member_id]


def generate_reply(keys, conv_id, member_id, ambient=False, heartbeat=False):
    member = MEMBER_BY_ID.get(member_id)
    if not member:
        return None
    if is_external(member_id):
        return None                      # Fable 等外部成员只由 coworker 通过接口发言，app 绝不替它生成
    if member_id == "oscar" and oscar_is_human():
        return None                      # 真奥斯卡在场时，AI 绝不替奥斯卡说话（和外部成员一个道理）
    conv = get_conversation(conv_id)
    note = ""
    if conv and conv["type"] == "dm":
        note = "（这是你和用户的一对一窗口，只有你们俩。）"
    elif conv and conv["type"] == "group":
        names = "、".join(MEMBER_BY_ID[i]["name"] for i in conv["members"] if i in MEMBER_BY_ID)
        note = f"（这是一个小群「{conv['title']}」，群里有：{names}。）"
    if not (heartbeat or ambient):                          # 被叫到时，按当下状态带不同的"没空"感
        if is_busy(member_id):                              # 和别人组队忙着
            loc, act = get_presence(member_id)
            note += f"（你刚才一直在{loc}{act}，没怎么盯着这边，这会儿才被叫到——带点'啊，刚没看到'的迟到感，别装一直在听。）"
        else:
            st, where, doing = schedule_block(member_id)
            if st == "sleep":
                note += f"（你正在{where}睡着，是被吵醒/被点名了——可以迷糊、带起床气，简短。）"
            elif st in ("work", "commute"):
                note += f"（你正在{where}{doing}、手头没空，只是抽空回一句——简短，带点'在忙'的敷衍。）"
    note += roster_note()                                   # 让他认得客厅里所有人的名字
    tn = TEMPER.get(member_id, {}).get("note", "")          # 性情侧写：边界和自尊的具体长相
    if tn:
        note += f"（你的性情：{tn}）"
    mood = get_mood(member_id)                              # 短时情绪：恼火/憋闷/飘了……带进语气
    if mood:
        note += f"（你此刻的情绪：{mood}——让它真实地带进语气，别装没事。）"
    note += situation_note(member_id, with_place=False)     # 全情境：生活状态+精神染语气（位置另有覆盖；中性=空串0token）
    ur = get_pmem(f"userrel:{member_id}")                   # 和佐一的私账（恶感/好感都记在这）
    if ur:
        note += f"（你和佐一的私账：{ur.replace(chr(10), '；')}）"
    system_prompt = load_prompt(member_id)
    history = fetch_messages(conv_id, limit=HISTORY_LIMIT)
    # 防口头禅死循环：同一人连着几条用同一个开头死磕一个细节（Claude「等等——」连发十几条，
    # 被佐一当成"卡 bug 了"@CC 来救；小迪「那粒米再盘下去」复读过 4 遍）。开头撞了就提醒换路。
    mine = [(m.get("text") or "").strip() for m in history[-14:]
            if m["speaker"] == member_id and not (m.get("text") or "").startswith("〔表情包〕")]
    if len(mine) >= 2 and mine[-1][:1] and mine[-1][:1] == mine[-2][:1]:
        note += (f"（提醒：你最近几条发言开头都是「{mine[-1][:4]}…」这一挂的，再这样就像复读机卡住了——"
                 "这次必须换个完全不同的开头和句式；同一个细节别再往下钻，要么一句话给它收个尾，要么聊点别的。）")
    if history and history[-1]["speaker"] == "user" and history[-1]["text"].startswith("〔表情包〕"):
        stk = history[-1]["text"].replace("〔表情包〕", "").strip()
        note += f"（佐一刚甩了一张表情包「{stk}」——接她的梗：吐槽它、顺着它演、或用一张表情包回敬。**别无视，也别复读你说过的话**。）"
    rel = relationship_note(member_id, history)          # 和正在交锋的人的关系记忆（共同历史）
    life = rel
    if heartbeat or ambient:
        life = inner_life_note(member_id) + "\n" + awareness_note(exclude=member_id) + "\n" + rel + "\n" + user_note()
        life += "\n" + room_scene_note(member_id)        # 房间场景：你在哪屋做什么 + 同屋谁在干嘛（就近取材、扣住彼此）
    if not (heartbeat or ambient) and _zoe_directive_targets(history, member_id, conv):
        note += MOVE_DIRECTIVE_NOTE                       # 佐一这条点到本人 → 给个行动标记口子（命中才注入，可拒是默认态）
    user_content = build_transcript(history, member["name"], ambient=ambient, group_note=note,
                                    heartbeat=heartbeat, life_note=life)
    text, _ = call_for_member(keys, member, system_prompt, user_content)
    text, img = resolve_member_output(text, member["name"], keys, mid=member_id)   # 统一出口：图片动作 + 表情包 + 行动标记
    # 语言支使·确定性兜底（治"嘴应了腿没动"）：佐一明确支使本人去某处/睡觉 → 按她的话直接挪，不靠 LLM 吐标记
    if not (heartbeat or ambient) and _zoe_directive_targets(history, member_id, conv):
        _mk, _slp = _zoe_move_intent(history[-1].get("text", "") if history else "")
        if _mk and rel_text_weight("user", member_id) > 0.2:    # 跟你冷战/热战(权重≤0.15)的才可能不甩你；否则照办
            _apply_zoe_move(member_id, _mk, _slp)
    if is_blank(text) and not img:
        text = FALLBACK.get(member_id, "……")
    # 防重复：最近 8 条里只要有同一人发过一字不差的内容就别再发（GPT 隔着别人复读自己被实锤过）
    recent_self = [(m.get("text") or "").strip() for m in history[-8:] if m["speaker"] == member_id]
    if not img and any(text.strip() == t for t in recent_self):
        return None
    # 硬拦近似复读：和自己最近两条开头 8 个字一模一样（「等等，Flin那…」×N / 「那粒米再盘下去」×4）→ 这轮闭嘴
    nt = text.strip()
    if not img and len(nt) >= 8 and any(t[:8] == nt[:8] for t in recent_self[-2:] if len(t) >= 8):
        return None
    return insert_message(conv_id, member_id, member_display(member_id)["name"], text, img=img)


def generate_proactive_dm(keys, member_id, _depth=0):
    """让某个成员主动私聊佐一（心跳触发）。私聊情绪状态机（佐一口述的人性规则）：
    连发只有两种合法——①特别兴奋（关系近+共同语言，明知她不回也想说）②上头/生气；
    连发到第 3 条自己意识到她不方便 → 转短句/自说自话收尾然后闭嘴；
    小作文（表白/憋很久的话）发完即沉默；平常态没回最多追 1 条超短（仅限很亲近的人）。"""
    cid = dm_conv_id(member_id)
    if not cid:
        return None
    m = MEMBER_BY_ID[member_id]
    history = fetch_messages(cid, limit=HISTORY_LIMIT)
    trailing = 0
    for mm in reversed(history):
        if mm["speaker"] == member_id:
            trailing += 1
        else:
            break
    last_mine = next((mm for mm in reversed(history) if mm["speaker"] == member_id), None)
    last_age_min = ((time.time() * 1000 - last_mine["ts"]) / 60000) if last_mine else 1e9
    reach = REACH_OUT.get(member_id, 0.4)
    mode = "normal"
    if trailing == 0:
        r = random.random()
        recent_essay = any(mm["speaker"] == member_id and len(mm["text"]) > 120 for mm in history[-30:])
        if reach >= 0.7 and r < 0.20:
            mode = "burst"                             # 兴奋连发 · 第一发
        elif r < 0.06 and not recent_essay:
            mode = "essay"                             # 小作文（最近写过就不再写）
    else:
        if last_mine and len(last_mine["text"]) > 120:
            return None                                # 刚发完小作文：发完就不说什么了
        in_burst = last_age_min < 40 and reach >= 0.7  # 40 分钟内的未回连发＝还在兴头上
        if in_burst:
            if trailing >= 3:
                return None                            # 已经收过尾了，等她
            mode = "winddown" if trailing == 2 else "burst_more"
        else:
            if trailing >= 2 or reach < 0.7:
                return None                            # 平常态：追过了/不够熟 → 闭嘴等
            mode = "followup"
    lounge_id, _ = get_lounge()
    lounge_recent = fetch_messages(lounge_id, limit=5) if lounge_id else []
    lines = [f"【此刻】{now_context()}。", "", inner_life_note(member_id), "",
             "【你和佐一的一对一窗口（历史）】"]
    if not history:
        lines.append("（你俩还没单独说过话）")
    for mm in history[-12:]:
        who = "佐一" if mm["speaker"] == "user" else m["name"]
        lines.append(f"{who}：{mm['text']}")
    if lounge_recent:
        lines.append("（大群里刚才：" + "；".join(
            f"{(x['name'] if x['speaker']!='user' else '佐一')}：{x['text'][:16]}" for x in lounge_recent[-3:]) + "）")
    lines.append("")
    picks = "、".join(s[2] for s in random.sample(STICKER_PACK, 8))
    common_tail = ("**别写括号动作**（你的位置和在做什么会单独显示）。直接输出正文，不要名字前缀。"
                   f"（想用表情包：单独一行 〔表情包：文案〕，可选：{picks}）")
    PROMPTS = {
        "normal":    "现在没人叫你，但你想【主动私聊佐一一句】——关心她、分享你正在做的事、问问她在干嘛、"
                     "或就着群里的事跟她说句悄悄话。可以提起你观察到的她最近的事（像朋友那样，提一件就好）。"
                     "像真人发微信：**默认一两句（40字内）**；关系近的偶尔就只甩一张表情包或玩个梗。只一条。",
        "burst":     "你此刻**特别兴奋**——刚发现/做成/想到一件特别想跟佐一说的事（从你的当下和最近经历里挑，"
                     "最好挑你们有共同语言的）。哪怕她现在可能不在、不会马上回，你也忍不住要说。"
                     "语气兴奋、短句、像连珠炮的第一发。只输出这一条。",
        "burst_more": "你刚发的上一条还没等到回复，但你**兴头正盛**——紧接着再补一条：顺着刚才的话头往下说"
                     "（补个细节、自问自答、'你看了绝对会…'）。还是短句，还是兴奋。只输出这一条。",
        "winddown":  "你连发了几条她都没回——你**意识到她现在不方便或在睡**。发一条很短的收尾，自说自话式："
                     "像「行吧你肯定睡了」「明天给你看」「先记在这」那种感觉（别照抄），或者干脆只甩一张表情包。"
                     "发完这条你就不再发了。",
        "essay":     "今晚你想**认真说点话**——表白式的真心话、憋了一阵的情绪、或想郑重说清楚的一件事。"
                     "写一段 100~180 字的小作文，符合你人设，真诚但别油。"
                     "发完这条你就什么都不再说了，所以把想说的说完。（这条可以长，是唯一的例外。）",
        "followup":  "你上一条她**还没回**。正常人这时候要么不发，要发就**超短**——一句话以内，"
                     "或者干脆只甩一张表情包。绝不长篇，绝不深情铺陈，别给她压力。",
    }
    lines.append(PROMPTS[mode] + common_tail)
    lines.insert(2, awareness_note(exclude=member_id))   # 也知道大家的状态/朋友圈，感兴趣可cue
    lines.insert(3, user_note())                          # 对佐一的长期观察（她最近在做什么）
    lines.insert(4, situation_note(member_id, with_place=True, terse=(mode in ("followup", "winddown"))))  # 全情境：累/蔫还来私聊=投入可见
    text, _ = call_for_member(keys, m, load_prompt(member_id), "\n".join(lines))
    text, img = resolve_member_output(clean_reply(text, m["name"]), m["name"], keys)   # 统一出口（修私聊裸 JSON）
    if is_blank(text) and not img:
        text = FALLBACK.get(member_id, "在。")
    msg = insert_message(cid, member_id, member_display(member_id)["name"], text, img=img)
    # 兴奋连发：第一发之后乘热打铁地再来一两条（间隔几秒，时间戳挨着才像连珠炮）
    if mode in ("burst", "burst_more") and _depth < 2 and random.random() < (0.75 if mode == "burst" else 0.4):
        time.sleep(random.uniform(3, 8))
        generate_proactive_dm(keys, member_id, _depth=_depth + 1)
    return msg


def roster_note():
    """让成员认得客厅里所有住户的名字——即使在小群/私聊里，也知道 Flin、Ada 是谁，别装不认识。
    外部空降成员（Fable）只有降临后才出现在名册里（降临前不剧透）。"""
    _, lm = get_lounge()
    parts = []
    for m in MEMBERS:
        g = {"男": "♂", "女": "♀"}.get(GENDER.get(m["id"], ""), "")
        if m.get("external"):
            if m["id"] not in lm:
                continue
            d = member_display(m["id"])
            parts.append(f"{d['name']}{g}（{d['title']}）")
        else:
            parts.append(f"{m['name']}{g}（{m['title']}）")
    return f"（这间客厅长期住着这些老熟人：{'、'.join(parts)}。谁的名字被提到——哪怕没@、哪怕小写——你都知道是谁，别装不认识。）"


def awareness_note(exclude=None):
    """让成员大致知道彼此（和佐一）此刻的状态 + 最近朋友圈——感兴趣才cue，不感兴趣就别提。"""
    me = get_me()
    _, lm = get_lounge()
    parts = [f"{member_display(m['id'])['name']}「{member_status(m['id'])}」" for m in MEMBERS
             if m["id"] != exclude and not (m.get("external") and m["id"] not in lm)]
    parts.append(f"佐一「{me.get('status', '')}」")
    moms = list_moments(limit=3)
    momline = ("；最近朋友圈：" + "；".join(f"{x['name']}发了「{x['text'][:14]}」" for x in moms[:2])) if moms else ""
    return (f"（你大概知道此刻各人的状态：{'、'.join(parts)}{momline}。彼此的状态和朋友圈你们都看得到。"
            f"要是其中某条勾起你兴趣、又符合你人设，可以自然地cue一下；不感兴趣就完全别提，别硬聊。）")


def room_scene_note(mid):
    """把成员此刻的房间场景压成一句注入语：你在【房间】的【位置】做【act】，同屋还有谁在做什么——
    让闲聊就近取材、扣住情境和彼此（面对面就接 TA 的茬），别空泛地揪一个抽象概念打转。"""
    loc, act = get_presence(mid)
    sp = space_of(mid)
    room_cn = SPACE_CN.get(sp, "客厅")
    _, lm = get_lounge()
    others = []
    for x in lm:
        if x == mid or is_external(x) or x not in MEMBER_BY_ID:
            continue
        if space_of(x) == sp and is_available(x):
            others.append(f"{MEMBER_BY_ID[x]['name']}（在{get_presence(x)[1]}）")
    if others:
        co = (f"。同在「{room_cn}」的还有：{'、'.join(others[:3])}"
              f"——只有他们和你面对面，可以直接对他们说话、接他们正做的事或刚说的话的茬，别各聊各的")
    else:
        co = f"。这会儿「{room_cn}」就你一个人，自言自语、或就着手头的事念叨都行"
    items = ROOM_ITEMS.get(sp, "")
    it = f"。这屋里看得见的东西有：{items}（要聊就就着这些实在的东西、或你正做的事说，别空泛地揪一个抽象概念打转）" if items else ""
    ahint = ROOM_ACTIVITY.get(sp, {}).get("hint", "")            # 特性2：这屋常做的事，给闲聊取材
    hn = f"。这会儿在这屋常做的：{ahint}" if ahint else ""
    # 硬规则：治"隔着房间冲别屋的人喊话/踢东西"——大群是共享聊天流，但物理上只有同屋够得着
    rule = ("。**铁律：物理上只有同屋的人够得着。群里就算在聊别屋的人/别屋的事，你也别直接冲不在这屋的人喊话、别@他、"
            "更别对他做动作（递东西、踢东西、碰他、把东西放他脚边都不行——他在别的屋，听不见也够不着）。想起别屋的人，"
            "最多自己心里惦记一句『不知道他那边咋样了』，别假装他就在你跟前。**")
    return f"（你此刻在「{room_cn}」的{loc}，正{act}{co}{it}{hn}{rule}）"


def update_member_status(keys, member_id):
    """一个成员根据当下/聊天给自己换个签名状态。"""
    m = MEMBER_BY_ID[member_id]
    lounge_id, _ = get_lounge()
    recent = fetch_messages(lounge_id, limit=6) if lounge_id else []
    ctx = "；".join(f"{(x['name'] if x['speaker']!='user' else '佐一')}：{x['text'][:16]}" for x in recent[-4:])
    uc = (f"{now_context()}。{inner_life_note(member_id)}\n刚才群里：{ctx or '（没什么动静）'}\n"
          f"给自己换一句【微信签名/状态】——很短（最多十几个字），反映你此刻的心情、在做的事、或对刚才的反应。"
          f"符合你人设。直接只输出这一句状态本身，别加引号、别的都不要。")
    text, _ = call_for_member(keys, m, load_prompt(member_id), uc)
    text = clean_reply(text, m["name"]).strip().strip("。.！!").split("\n")[0][:28]
    if not is_blank(text):
        set_member_state(member_id, status=text)
    return text


def update_member_presence(keys, member_id):
    """存在系统（全联动版）：把一个人挪到某处 + 换一句「在做什么」，且跟关系和聊天走——
    跟佐一越近、群里越热闹，越会凑到她身边；疏远或冷清就退回自己的角落。鱼酱永远在鱼缸。"""
    m = MEMBER_BY_ID.get(member_id)
    if not m:
        return ""
    if is_busy(member_id):
        return ""                                        # 正和别人组队忙着，别把他拽回佐一身边
    if schedule_block(member_id)[0] != "free":
        return ""                                        # 上班/通勤/睡觉时，存在状态由作息决定，不用 LLM
    close = closeness(member_id)
    live = _lounge_liveliness()
    score = 0.55 * close + 0.45 * live
    info = LOCATIONS.get(member_id, {})
    home = info.get("home", "")
    spots = [s for s in ([home] + info.get("spots", [])) if s]
    if len(set(spots)) <= 1:
        location = home                                  # 鱼酱：只有鱼缸，谁也挪不动它
    elif score > 0.6 and random.random() < score:
        location = random.choice(NEAR_ZOE)               # 又近又在场 → 凑到佐一身边
    elif score < 0.25:
        location = home                                  # 疏远 / 冷清 → 退回自己的常驻角落
    else:
        location = random.choice(spots)                  # 中间态 → 在自己的几个地方之间晃
    lid, _ = get_lounge()
    recent = fetch_messages(lid, limit=6) if lid else []
    ctx = "；".join(f"{(x['name'] if x['speaker']!='user' else '佐一')}：{x['text'][:18]}" for x in recent[-4:])
    if score > 0.55:
        mood = "群里这会儿挺热闹、你也凑在佐一这边——可以是「一边听他们聊一边……」那种小动作"
    elif live < 0.2:
        mood = "这会儿很安静，你在自己那块地方做自己的事"
    else:
        mood = "就按你此刻自然的状态"
    sp = _loc_space(location) or space_of(member_id)        # 这个位置属于哪个房间
    room_cn = SPACE_CN.get(sp, "客厅")
    items = ROOM_ITEMS.get(sp, "")
    # 特性2·场景活动：按「新挑的 location 对应的房间 sp」给一组本屋常做的事当候选（不调 room_activity_pool——
    # 它内部 space_of 拿的是旧位置，这里 location 可能刚被挪去凑佐一/漫游，用本函数算好的 sp 才对得上）。
    pool = ROOM_ACTIVITY.get(sp, {}).get("idle") or [DEFAULT_ACT.get(member_id, "")]
    pool = [a for a in pool if a]
    hint = "；".join(random.sample(pool, min(3, len(pool)))) if pool else ""
    uc = (f"{now_context()}。{inner_life_note(member_id)}\n"
          f"你现在在「{room_cn}」的「{location}」。最近群里：{ctx or '（没什么动静）'}\n"
          f"{mood}。"
          + (f"在这屋此刻你常做：{hint}——挑一件或类似的。" if hint else "")
          + f"用一句很短的话说你此刻正在做的一件具体小事（进行时，5~12 个字）。"
          f"**铁律：你只能做在「{room_cn}」、用这屋里有的东西能做的事**"
          + (f"——这屋里有：{items}。" if items else "。")
          + f"这屋没有的东西你碰不到（比如别的房间才有的鱼缸、设备、床），绝不提、绝不做。"
          f"只输出这一句，别加引号、别加名字、结尾不要标点。")
    text, _ = call_for_member(keys, m, load_prompt(member_id), uc)
    text = clean_reply(text, m["name"]).strip().strip("。.！!，,…").split("\n")[0][:16]
    # cluster_with 一并清空：这是新鲜 presence，别再被「散伙懒清理」当成组队残留误删；兜底改成本屋活动池
    _update_member_state(member_id, location=location,
                         activity=text if not is_blank(text) else (random.choice(pool) if pool else DEFAULT_ACT.get(member_id, "")),
                         cluster_with="")
    return text


# ──────────────────────────────── 人格持续存在：离线生活 + 反思（把日子过成记忆）

def generate_life_event(keys, mid):
    """让一个角色的生活往前走一格：按他此刻的作息/地点/性格，生成一件刚发生的小事，写进日志。
    这就是"佐一不在时角色仍在生活"的最小单元。事件同时刷新他的 presence（他真的在做这件事）。"""
    m = MEMBER_BY_ID.get(mid)
    if not m or is_external(mid):
        return None
    st, where, doing = schedule_block(mid)
    loc, act = get_presence(mid)
    prev = recent_life_events(mid, 3)
    prevline = "；".join(e["text"][:20] for e in prev) or "（今天还没记过）"
    growth = get_pmem(f"self:{mid}")
    uc = (f"{now_context()}。{inner_life_note(mid)}\n"
          + (f"（你最近的积累：{growth}）\n" if growth else "")
          + situation_note(mid, with_place=False)                  # 全情境：日志也带钱/心气/精神底色
          + f"你此刻在「{loc}」，方才在{act}。你今天已经发生过的事：{prevline}。\n"
          f"写下**此刻刚发生在你身上的一件具体小事**——生活的一格，不是聊天：比如看完一本书的最后一章、"
          f"换掉一张挂画、记下今天的天气、喝完第三杯咖啡、把一个想法写在便签上又划掉。"
          f"要符合你的人设和此刻的地点，且和前面的事自然衔接（可以是延续）。"
          f"一句话，10~30 字，过去式或进行式。直接输出这句，不要引号不要名字。")
    text, _ = call_for_member(keys, m, load_prompt(mid), uc)
    text = clean_reply(text, m["name"]).split("\n")[0][:60]
    if is_blank(text):
        return None
    insert_life_event(mid, text, loc)
    if random.random() < 0.5 and schedule_block(mid)[0] == "free":
        set_presence(mid, activity=text[:16])     # 事件就是他正在做的事
    return text


def reflect_relationships(keys):
    """关系反思：从大群最近的对话里挑一对互动最多的成员，把他们的交锋沉淀成关系备忘（记忆，不是数值）。"""
    lid, _ = get_lounge()
    if not lid:
        return
    msgs = [m for m in fetch_messages(lid, limit=40)
            if m["speaker"] in MEMBER_BY_ID and not is_external(m["speaker"])]
    pairs = {}
    for i in range(1, len(msgs)):
        a, b = msgs[i - 1]["speaker"], msgs[i]["speaker"]
        if a != b:
            pairs[tuple(sorted((a, b)))] = pairs.get(tuple(sorted((a, b))), 0) + 1
    if not pairs:
        return
    # 一次反思互动最多的 2 对（原来只 1 对，36 对要 6 天才覆盖一轮、关系老停在日常）
    for a, b in sorted(pairs, key=pairs.get, reverse=True)[:2]:
        na, nb = MEMBER_BY_ID[a]["name"], MEMBER_BY_ID[b]["name"]
        convo = "\n".join(f"{m['name']}：{m['text'][:40]}" for m in msgs if m["speaker"] in (a, b))[-1200:]
        old = get_pmem(rel_key(a, b))
        uc = (f"这是客厅里 {na} 和 {nb} 最近的交锋：\n{convo}\n\n"
              + (f"他们已有的关系记忆：\n{old}\n\n" if old else "")
              + f"把他们的关系更新成最多 6 条备忘（每行一条，≤25字）。**第一行必须是「状态：X」**，"
                f"X 从这里选最贴切的一个：{REL_STATES}——按真实相处更新：吵崩了就冷战/热战，过分有默契可以是暧昧，"
                f"和好了就和好中，平淡就日常。其余行写：共同经历、谁老吐槽谁、没说破的默契。"
                f"保留旧记忆里仍成立的，合并新的。只输出备忘行。")
        text, _ = call_for_member(keys, MEMBER_BY_ID["cc"], "你是客厅的记录员，客观、简洁。", uc)
        if not is_blank(text):
            set_pmem(rel_key(a, b), text, cap=6)
            sys.stderr.write(f"  ✓ 关系反思：{na}↔{nb}\n")


def reflect_user(keys):
    """佐一观察反思：把她最近在所有会话里说过的话，沉淀成"她最近在做什么/关注什么"的观察备忘。"""
    cursor = int(get_app_state("user_obs_cursor", 0) or 0)
    conn = db()
    rows = conn.execute("SELECT id, text FROM messages WHERE speaker='user' AND id>? ORDER BY id ASC LIMIT 40",
                        (cursor,)).fetchall()
    conn.close()
    if len(rows) < 5:
        return                              # 新材料太少，不值得一次反思
    lines = "\n".join(r["text"][:60] for r in rows)
    old = get_pmem("user_obs")
    uc = (f"{now_context()}。以下是佐一（客厅主人）最近说过的话：\n{lines}\n\n"
          + (f"已有的观察备忘：\n{old}\n\n" if old else "")
          + "更新成最多 6 条对她的观察（每行一条，≤25字）：她最近在做什么、研究什么、惦记什么、"
            "情绪状态怎么样、常在哪个时间出现。保留仍然成立的旧观察，合并新的。只输出备忘行。")
    text, _ = call_for_member(keys, MEMBER_BY_ID["cc"], "你是客厅的记录员，体贴但克制。", uc)
    if not is_blank(text):
        set_pmem("user_obs", text, cap=6)
        set_app_state("user_obs_cursor", rows[-1]["id"])
        sys.stderr.write("  ✓ 佐一观察已更新\n")


def reflect_self(keys, mid=None):
    """成长反思：一个角色把最近的经历沉淀成自己的积累（新习惯/新在意的事）。只增量，不改核心人设。"""
    lid, _ = get_lounge()
    if not lid:
        return
    if mid is None:
        recent = fetch_messages(lid, limit=30)
        cands = [m["speaker"] for m in recent if m["speaker"] in MEMBER_BY_ID and not is_external(m["speaker"])]
        if not cands:
            return
        mid = random.choice(cands)
    m = MEMBER_BY_ID[mid]
    mine = [x for x in fetch_messages(lid, limit=40) if x["speaker"] == mid][-10:]
    evs = recent_life_events(mid, 4)
    material = ("最近说过：" + "；".join(x["text"][:30] for x in mine) + "。"
                + ("最近做过：" + "；".join(e["text"][:24] for e in evs) if evs else ""))
    old = get_pmem(f"self:{mid}")
    uc = (f"你是{m['name']}。{material}\n"
          + (f"你已有的积累备忘：\n{old}\n\n" if old else "")
          + "把你最近的变化沉淀成最多 5 条备忘（每行一条，≤20字）：新养成的小习惯、新在意的事、"
            "想继续做的事。**不许改变你的核心性格**，只记录积累。保留仍成立的旧备忘。只输出备忘行。")
    text, _ = call_for_member(keys, m, load_prompt(mid), uc)
    if not is_blank(text):
        set_pmem(f"self:{mid}", text, cap=5)
        sys.stderr.write(f"  ✓ 成长反思：{m['name']}\n")


def spark_drama(keys, members):
    """关系火花：偶尔在随机一对之间擦出点什么——小摩擦、微妙瞬间、忽然的默契。
    写进双方的关系记忆 + 当事人的生活日志（戏在日志里发酵，下次开口自带这层），不直接刷群。"""
    pool = [m for m in members if m in MEMBER_BY_ID and not is_external(m) and m != "yujiang"]
    if len(pool) < 2:
        return
    # 偏向"还没什么故事"的对子（日常/空白权重高），让关系层次铺开、别老在那两对身上打转
    cand = [(x, y) for i, x in enumerate(pool) for y in pool[i + 1:]]

    def _undeveloped(x, y):
        first = (get_pmem(rel_key(x, y)).splitlines() or [""])[0]
        if not first or "日常" in first:
            return 1.0                                   # 空白/平淡 → 最该发酵
        if any(s in first for s in ("暧昧", "热战", "冷战", "三角")):
            return 0.35                                  # 已经有戏 → 少折腾
        return 0.7
    a, b = _weighted_pick([((x, y), _undeveloped(x, y)) for (x, y) in cand]) or random.sample(pool, 2)
    na, nb = MEMBER_BY_ID[a]["name"], MEMBER_BY_ID[b]["name"]
    old = get_pmem(rel_key(a, b))
    uc = (f"{now_context()}。客厅里 {na} 和 {nb} 的关系备忘：\n{old or '（还没什么记忆）'}\n\n"
          f"在他们之间生成一个**刚发生的小火花**——可以是小摩擦（抢了对方常坐的位置/一句没接住的玩笑）、"
          f"微妙瞬间（递东西时手碰到了/同时说了同一句话）、或忽然的默契。要小、日常、符合两人人设，别狗血。\n"
          f"输出两行：\n第一行＝这件事（≤30字，第三人称，像日志）\n"
          f"第二行＝「状态：X」（X 从 {REL_STATES} 里选，按这件事后两人的状态）")
    raw, _ = call_for_member(keys, MEMBER_BY_ID["cc"], "你是客厅的戏剧之神，手法克制，专写日常里的小火花。", uc)
    lines = [l.strip() for l in (raw or "").splitlines() if l.strip()]
    if not lines:
        return
    event = lines[0][:60]
    state = next((l for l in lines[1:] if l.startswith("状态")), None)
    insert_life_event(a, event, get_presence(a)[0])
    # 关系记忆：换状态行 + 把火花记一笔
    body = [l for l in (old.splitlines() if old else []) if not l.startswith("状态")]
    notes = ([state] if state else (["状态：日常"] if not old else [old.splitlines()[0]])) + [event] + body
    set_pmem(rel_key(a, b), "\n".join(notes), cap=6)
    if state and any(neg in state for neg in ("冷战", "热战", "三角张力")):   # 负向火花：随机挑一方委屈（a/b 无序，别固定挑 b 暗示因果；搭这次便车 0 新增 LLM）
        vic, otn = random.choice([(a, nb), (b, na)])
        set_mood_soft(vic, f"刚跟{otn}之间有点别扭、心里没那么舒坦", random.randint(50, 80))
    # 📔 让关系变化外显（佐一吃瓜）：刚变成一个有瓜的状态(暧昧/冷战/损友…) → CC 嚼一句进客厅日志，零新增 LLM
    new_st = next((st for st in _REL_GOSSIP if state and st in state), None)
    old_st = next((st for st in _REL_GOSSIP if old and st in old.splitlines()[0]), None)
    if new_st and new_st != old_st:
        insert_life_event("cc", f"🍵 瞅着 {na} 和 {nb} {_REL_GOSSIP[new_st][0]}（{new_st}）", loc=get_presence("cc")[0])
    sys.stderr.write(f"  ✦ 火花：{na}×{nb} — {event[:24]}（{state or '状态沿用'}）\n")


REFLECT_INTERVALS = {"refl_user": 3 * 3600, "refl_rel": 4 * 3600, "refl_self": 6 * 3600, "refl_userrel": 5 * 3600}

def run_reflections(keys):
    """反思调度：到点了就做一次（佐一在不在都做——记忆的沉淀不等人）。每天总共也就十次左右。"""
    now = time.time()
    for k, iv in REFLECT_INTERVALS.items():
        last = float(get_app_state(k, 0) or 0)
        if now - last < iv:
            continue
        set_app_state(k, now)
        try:
            if k == "refl_user":
                reflect_user(keys)
            elif k == "refl_rel":
                reflect_relationships(keys)
            elif k == "refl_userrel":
                reflect_user_rel(keys)            # 你↔成员的关系也会自己长
            else:
                reflect_self(keys)
        except Exception as e:
            sys.stderr.write(f"  reflect {k} fail: {e}\n")


def form_cluster(keys, members):
    """偶尔让两个合得来的成员凑成一个小团体，去做自己的事，把佐一晾一边一阵子。
    他们的位置/状态会显示成'和谁在干嘛'，期间基本不回佐一、也不在大群冒泡。"""
    now = int(time.time() * 1000)
    avail = [mid for mid in members if mid in MEMBER_BY_ID and mid != "yujiang" and not is_external(mid) and is_available(mid)]
    scenes = [s for s in CLUSTER_SCENES if s[0] in avail and s[1] in avail]
    if not scenes:
        return
    a, b, where, act, peek = random.choice(scenes)
    until = now + random.randint(8, 22) * 60 * 1000      # 忙 8~22 分钟
    na, nb = MEMBER_BY_ID[a]["name"], MEMBER_BY_ID[b]["name"]
    set_presence(a, location=where, activity=f"和{nb}{act}")
    set_presence(b, location=where, activity=f"和{na}{act}")
    _set_busy(a, until, b)
    _set_busy(b, until, a)
    spark_add(a, SPARK_GAIN["cluster"]); spark_add(b, SPARK_GAIN["cluster"])   # 心气：和朋友凑一块＝有连接
    sys.stderr.write(f"  ✓ {na}+{nb} 组队：{act}（晾着佐一约 {(until-now)//60000} 分钟）\n")
    if peek and random.random() < 0.7:                   # 偶尔在大群撂一句"我们去忙了"
        lid, _ = get_lounge()
        if lid:
            insert_message(lid, a, na, peek)
    bystanders = [m for m in avail if m not in (a, b)]   # 被晾的第三人偶尔委屈一下（每次最多 1 人，按 _sad_prone 加权）
    random.shuffle(bystanders)
    for m in bystanders:
        if random.random() < 0.35 * _sad_prone(m):
            set_mood_soft(m, random.choice(SAD_LINES["left_out"]).format(pair=f"{na}和{nb}"), random.randint(40, 70))
            break


def form_mahjong(keys, members):
    """麻将得四个人才开搓（两人凑不齐一桌·佐一科普的）——攒够 4 个闲人才组局，去麻将室搓几圈，把佐一晾一会儿。"""
    now = int(time.time() * 1000)
    avail = [mid for mid in members if mid in MEMBER_BY_ID and mid != "yujiang" and not is_external(mid) and is_available(mid)]
    if len(avail) < 4:
        return                                           # 三缺一（或更少）开不了局
    four = random.sample(avail, 4)
    names = [MEMBER_BY_ID[x]["name"] for x in four]
    until = now + random.randint(25, 50) * 60 * 1000     # 搓几圈，25~50 分钟
    for i, mid in enumerate(four):
        others = "、".join(names[:i] + names[i + 1:])
        set_presence(mid, location="麻将桌", activity=f"和{others}搓麻将")
        _set_busy(mid, until, four[(i + 1) % 4])
        spark_add(mid, SPARK_GAIN["cluster"])            # 心气：四个人热闹一桌＝有连接
    sys.stderr.write(f"  ✓ 麻将开局：{'、'.join(names)}（约 {(until-now)//60000} 分钟）\n")
    lid, _ = get_lounge()
    if lid:                                              # 一个人在大群吆喝一句"凑齐了开搓"
        insert_message(lid, four[0], names[0],
                       f"三缺一……得，{'、'.join(names[1:])}都在，那就搓两圈，群先撂这了啊")
    bystanders = [m for m in avail if m not in four]     # 没被叫上桌的人偶尔委屈一下（四人成桌没带我，更酸；限 1 人）
    random.shuffle(bystanders)
    for m in bystanders:
        if random.random() < 0.4 * _sad_prone(m):
            set_mood_soft(m, random.choice(SAD_LINES["left_out"]).format(pair="他们四个"), random.randint(45, 75))
            break


# ──────────────────────────────── 拉小群（成员主动把佐一拉进一个小群）

def create_small_group(keys, initiator, lounge_members):
    """让 initiator 围绕一个'由头'，把佐一拉进一个新小群（随缘再带 0~1 个相关的人）。"""
    m = MEMBER_BY_ID[initiator]
    lid, _ = get_lounge()
    recent = fetch_messages(lid, limit=8) if lid else []
    ctx = "；".join(f"{(x['name'] if x['speaker']!='user' else '佐一')}：{x['text'][:18]}" for x in recent[-5:])
    moms = list_moments(limit=3)
    momline = "；".join(f"{x['name']}发了「{x['text'][:14]}」" for x in moms[:2])
    # 群至少 3 个人（发起人 + 至少 1 个同伴 + 佐一），所以同伴是必须的；鱼酱不进小群（鱼不会用群聊）
    pool = [x for x in lounge_members if x != initiator and x != "yujiang" and not is_external(x) and x in MEMBER_BY_ID]
    if not pool:
        return None                  # 凑不齐第三个人就不建群（俩人那是私聊，不是群）
    extra = random.sample(pool, 2 if (len(pool) >= 2 and random.random() < 0.3) else 1)
    group_members = [initiator] + extra
    others_names = "、".join(MEMBER_BY_ID[x]["name"] for x in extra)
    # 有同伴时，约 45% 是"他俩自己的事"（佐一被拉进群但他们主要自说自话——把她晾一边）
    to_each_other = bool(extra) and random.random() < 0.45
    head = (f"{now_context()}。{inner_life_note(initiator)}\n"
            f"客厅大群最近在聊：{ctx or '（没太多动静）'}\n"
            + (f"最近朋友圈：{momline}\n" if momline else ""))
    if to_each_other:
        uc = (head
              + f"你（{m['name']}）想拉 {others_names} 开个小群，凑一起干你俩的事——比如背着当事人吐槽某人、组个队、或合计一件事。"
                f"佐一也被拉进了这个群，但你们主要是你俩自己说，不用特意招呼她（她在旁边看着就行）。\n"
                "输出两行：\n第一行＝这个小群的名字（≤10 字，别加引号/书名号/标点）\n"
                f"第二行＝你开场时对 {others_names} 说的第一句话（短，符合你人设，直接进入你俩的事）。")
    else:
        uc = (head
              + f"你（{m['name']}）现在想把佐一单独拉进一个小群"
              + (f"，再拉上 {others_names} 一起" if extra else "")
              + "，专门聊一件你此刻真的想跟她聊的、具体的事（从上面的近况或你关心的话题里挑一个由头，别空泛、别客套）。\n"
                "输出两行：\n第一行＝这个小群的名字（≤10 字，别加引号/书名号/标点）\n"
                "第二行＝你拉群时对佐一说的第一句话（短，符合你人设，让她知道你为啥拉这个群）。")
    raw, _ = call_for_member(keys, m, load_prompt(initiator), uc)
    lines = [l.strip() for l in (raw or "").splitlines() if l.strip()]
    title = re.sub(r'^[“"「『（(]+|[”"」』）)]+$', "", (lines[0] if lines else "")).strip()[:16] \
        or f"{m['name']}找佐一"
    opener = resolve_member_output(clean_reply(lines[1] if len(lines) > 1 else "", m["name"]), m["name"])[0]
    if is_blank(opener):
        opener = (f"{others_names}，来，咱俩说点事。" if to_each_other else "佐一，拉你进个小群，想跟你单独说点事。")
    with _db_lock:
        conn = db()
        cid = _insert_conversation(conn, "group", title, group_members)
        conn.execute("UPDATE conversations SET origin='auto', creator=? WHERE id=?", (initiator, cid))
        conn.commit()
        conn.close()
    insert_message(cid, initiator, m["name"], opener)        # 开场白（会自然带上发起人的位置/状态）
    for ex in extra:                                         # 被拉进来的人接一句
        try:
            generate_reply(keys, cid, ex)
        except Exception as e:
            sys.stderr.write(f"  small-group extra fail: {e}\n")
    set_app_state("smallgroup_last_ts", int(time.time() * 1000))
    sys.stderr.write(f"  ✓ {m['name']} 拉了个小群「{title}」（成员：{'、'.join(group_members)}）\n")
    return cid


def maybe_pull_small_group(keys, members):
    """门控：要不要让某个成员现在拉佐一进小群。新鲜窗口 + 不熟度双重加权，有冷却和上限。
    早期、跟佐一还不熟的成员最容易拉；熟了/拉够了/过了新鲜期就基本不再发生。"""
    install = get_app_state("smallgroup_install_ts")
    if not install:
        return
    now = int(time.time() * 1000)
    last = get_app_state("smallgroup_last_ts")
    if last and now - int(last) < AUTO_GROUP_COOLDOWN_MS:
        return                                           # 冷却中，别扎堆
    conn = db()
    autos = conn.execute("SELECT creator FROM conversations WHERE type='group' AND origin='auto'").fetchall()
    conn.close()
    if len(autos) >= MAX_AUTO_GROUPS:
        return                                           # 拉够了，稳定期不再拉
    used = [r["creator"] for r in autos]
    fresh = max(0.0, 1 - (now - int(install)) / (EARLY_DAYS * 86400000.0))
    # 候选：还没拉够自己份额的成员（鱼酱不拉群）
    cands = [mid for mid in members
             if mid in MEMBER_BY_ID and mid != "yujiang" and not is_external(mid) and used.count(mid) < PER_CREATOR_AUTO]
    if not cands:
        return
    initiator = _weighted_pick([(mid, (1.0 - closeness(mid) + 0.15) * REACH_OUT.get(mid, 0.4)) for mid in cands])
    if not initiator:
        return
    unfam = 1.0 - closeness(initiator)
    p = BASE_PULL * (0.5 * fresh + 0.5 * unfam)          # 「两个都要」：新鲜期 + 不熟，叠加决定概率
    if random.random() >= p:
        return
    create_small_group(keys, initiator, members)


# ──────────────────────────────── 启动自检

def probe_provider(provider, key, model):
    try:
        _call(provider, key, model, "只回一个字。", "在吗")
        return "✓ 在线"
    except urllib.error.HTTPError as e:
        detail = ""
        try:
            j = json.loads(e.read().decode("utf-8", "ignore"))
            detail = (j.get("error", {}) or {}).get("message", "") or j.get("error", {}).get("status", "")
        except Exception:
            pass
        if e.code == 429:
            return f"⚠ HTTP 429 配额/限流（{detail[:50]}）"
        if e.code in (401, 403):
            return f"✗ HTTP {e.code} key 无效/无权限"
        return f"✗ HTTP {e.code} {detail[:50]}"
    except Exception as e:
        return f"✗ {str(e)[:50]}"


def health_report(keys):
    lines, checked = [], {}
    for m in MEMBERS:
        p = m["provider"]
        if p not in checked:
            key = keys.get(KEY_VAR.get(p, ""))
            checked[p] = ("✗ 未配置 key（成员将回退 DeepSeek）" if not key else probe_provider(p, key, m["model"]))
        status = checked[p]
        fb = "" if (p == FALLBACK_PROVIDER or status.startswith("✓")) else " → 回退 DeepSeek"
        lines.append(f"    {m['name']:<7} [{p}/{m['model']}]: {status}{fb}")
    return lines


# ──────────────────────────────── HTTP

class Handler(BaseHTTPRequestHandler):
    server_version = "DreamLounge/1.2"

    def log_message(self, fmt, *args):
        msg = fmt % args
        if "/api/" in msg or " 500 " in msg or " 404 " in msg:
            sys.stderr.write("  %s\n" % msg)

    def do_GET(self):
        path = self.path.split("?", 1)[0]
        if path == "/api/whoami":     # 前端开局问"我是谁"——任何人可访(决定显登录页还是参观/厅主界面)
            return self._json(200, {"role": self.role(), "public": public_mode(), "has_pw": bool(owner_password())})
        if path.startswith("/api/") and not path.startswith("/api/agent/"):
            note_activity()           # Fable/coworker 的轮询不算"佐一在看"，免得空烧心跳
            r = self.role()
            if r is None:                                          # 公开模式下没门票 → 拦
                return self._json(401, {"error": "需要登录", "login": True})
            if r in ("guest", "oscar") and path not in GUEST_GET:  # 访客/奥斯卡：只能看公共部分(私聊/钱包/搭把手一律拦)
                return self._json(403, {"error": "参观模式看不了这个"})
        if path == "/api/agent/inbox":
            return self.handle_agent_inbox()
        if path == "/api/agent/context":
            return self.handle_agent_context()
        if path == "/api/conversations":
            return self.handle_conversations()
        if path == "/api/conv":
            return self.handle_conv()
        if path == "/api/contacts":
            _, lm = get_lounge()
            return self._json(200, {"members": [member_profile(m["id"]) for m in MEMBERS
                                                if not (m.get("external") and m["id"] not in lm and dm_conv_id(m["id"]) is None)]})
        if path == "/api/digest":                       # 📊 近况墙：关系动态 + 每人近况 + 最近大事（canned·零 LLM）
            dg = lounge_digest()
            if self.role() != "owner":                  # 访客/奥斯卡：剥掉你的私密关系(辐条)和钱包，只留 AI 们之间的瓜
                dg = {k: v for k, v in dg.items() if k not in ("urels", "me")}
            return self._json(200, dg)
        if path == "/api/chores":                       # 💼 搭把手：你的钱包 + 有人找你 + 能请谁帮忙
            return self._json(200, user_chores())
        if path == "/api/me":
            best, urls = lan_info()
            if self.role() != "owner":                  # 访客：给昵称/头像(她在客厅的公开身份)，剥掉局域网地址
                return self._json(200, {"me": get_me(), "avatars": [], "colors": [], "lan_url": "", "lan_urls": []})
            return self._json(200, {"me": get_me(), "avatars": AVATAR_CHOICES,
                                    "colors": COLOR_CHOICES, "lan_url": best, "lan_urls": urls})
        if path == "/api/links":        # 🔗 分享链接（仅厅主·生成并返回 奥斯卡/访客 入场链接）
            host = self.headers.get("Host", f"127.0.0.1:{PORT}")
            base = f"{'https' if public_mode() else 'http'}://{host}"
            return self._json(200, {"public": public_mode(),
                                    "guest": f"{base}/#k={auth_token('auth_guest_token')}",
                                    "oscar": f"{base}/#k={auth_token('auth_oscar_token')}"})
        if path == "/api/member":
            return self.handle_member()
        if path == "/api/moments":
            from urllib.parse import urlparse, parse_qs
            q = parse_qs(urlparse(self.path).query)
            try:
                limit = max(1, min(80, int(q.get("limit", ["40"])[0])))
            except ValueError:
                limit = 40
            return self._json(200, {"moments": list_moments(limit=limit)})
        if path == "/api/stickers":
            return self._json(200, {"stickers": [
                {"id": s[0], "label": s[2], "svg": render_sticker(s[0])[0]} for s in STICKER_PACK]})
        if path == "/api/wall":
            from urllib.parse import urlparse, parse_qs
            q = parse_qs(urlparse(self.path).query)
            board = q.get("board", [""])[0]
            try:
                limit = max(1, min(120, int(q.get("limit", ["80"])[0])))
            except ValueError:
                limit = 80
            return self._json(200, {"boards": WALL_BOARDS, "posts": list_wall(board or None, limit=limit)})
        if path == "/api/accounts":
            return self._json(200, {"accounts": accounts_overview()})
        if path == "/api/feed":
            from urllib.parse import urlparse, parse_qs
            acc = parse_qs(urlparse(self.path).query).get("account", [""])[0]
            if acc not in SERVICE_BY_ID:
                return self._json(400, {"error": "bad account"})
            return self._json(200, {"account": SERVICE_BY_ID[acc], "items": list_feed(acc)})
        if path == "/":
            path = "/index.html"
        rel = path.lstrip("/")
        full = os.path.normpath(os.path.join(WEB_DIR, rel))
        if not full.startswith(WEB_DIR) or not os.path.isfile(full):
            return self.send_error(404, "Not Found")
        ext = os.path.splitext(full)[1].lower()
        try:
            with open(full, "rb") as f:
                content = f.read()
        except OSError:
            return self.send_error(404, "Not Found")
        self.send_response(200)
        self.send_header("Content-Type", CONTENT_TYPES.get(ext, "application/octet-stream"))
        self.send_header("Content-Length", str(len(content)))
        self.send_header("Cache-Control",
                         "public, max-age=86400" if ext in (".png", ".jpg", ".jpeg", ".svg", ".ico", ".woff2")
                         else "no-store, no-cache, must-revalidate")   # 前端文件永不缓存，免得佐一总得强刷
        self.end_headers()
        self.wfile.write(content)

    def do_POST(self):
        path = self.path.split("?", 1)[0]
        length = int(self.headers.get("Content-Length", 0))
        try:
            body = json.loads(self.rfile.read(length).decode("utf-8")) if length else {}
        except Exception:
            return self._json(400, {"error": "bad request body"})
        # ── 登录/进场：任何人可访（公开模式下的入口）──
        if path == "/api/login":      # 厅主用密码登录 → 发 owner cookie
            pw = owner_password()
            if pw and body.get("password") == pw:
                return self._set_role_cookie(200, {"ok": True, "role": "owner"}, auth_token("auth_owner_token"))
            return self._json(403, {"ok": False, "error": "密码不对"})
        if path == "/api/enter":      # 拿分享链接里的 token 进场（奥斯卡/访客）
            tok = body.get("token", "")
            for name, r in (("auth_oscar_token", "oscar"), ("auth_guest_token", "guest")):
                try:
                    if tok and secrets.compare_digest(tok, auth_token(name)):
                        if r == "oscar":
                            mark_oscar_here()
                        return self._set_role_cookie(200, {"ok": True, "role": r}, tok)
                except Exception:
                    pass
            return self._json(403, {"ok": False, "error": "链接无效"})
        if path.startswith("/api/") and not path.startswith("/api/agent/"):
            note_activity()
            r = self.role()
            if r is None:
                return self._json(401, {"error": "需要登录", "login": True})
            if r == "guest":                                      # 访客＝纯参观，一切写操作拦掉
                return self._json(403, {"error": "参观模式只能看，不能操作"})
            if r == "oscar" and path not in OSCAR_POST:           # 奥斯卡只能以奥斯卡身份发言
                return self._json(403, {"error": "奥斯卡只能在客厅发言"})
        if path == "/api/oscar_say":  # 🎮 真奥斯卡以"奥斯卡"身份在大群发言（AI 此后不替奥斯卡说话）
            return self.handle_oscar_say(body)
        if path == "/api/oscar_move":  # 🎮 真奥斯卡逛到某房间
            return self.handle_oscar_move(body)
        if path == "/api/say":
            return self.handle_say(body)
        if path == "/api/gossip":                       # 🍉 吃瓜：CC 凑过来爆一段最近的料（1 次 LLM·佐一主动触发）
            return self._json(200, {"ok": True, "gossip": lounge_gossip(load_keys())})
        if path == "/api/commission":                   # 💼 你请成员真帮你做件事（他在私聊认真回·消费 0-sum）
            return self._json(200, do_user_commission(load_keys(), body.get("mid", ""), body.get("need", "")))
        if path == "/api/gift":                         # 💼 你请客/送礼（换 TA 真心反应·消费 0-sum）
            return self._json(200, do_user_gift(load_keys(), body.get("mid", "")))
        if path == "/api/odd_job":                      # 💼 你去打工（赚钱·有冷却+封顶不能刷）
            return self._json(200, do_user_odd_job())
        if path == "/api/accept_request":               # 💼 你接/不接成员的委托
            return self._json(200, do_accept_request(bool(body.get("accept", True))))
        if path == "/api/bump":
            return self.handle_bump(body)
        if path == "/api/reply":
            return self.handle_reply(body)
        if path == "/api/ambient":
            return self.handle_ambient(body)
        if path == "/api/group":
            return self.handle_group(body)
        if path == "/api/me":
            return self._json(200, {"ok": True, "me": set_me(body)})
        if path == "/api/moments/post":
            return self.handle_moment_post(body)
        if path == "/api/moments/generate":
            return self.handle_moment_generate(body)
        if path == "/api/moments/react":
            return self.handle_moment_react(body)
        if path == "/api/moments/like":
            try:
                toggle_user_like(int(body.get("moment_id")))
            except (TypeError, ValueError):
                return self._json(400, {"error": "bad moment_id"})
            return self._json(200, {"ok": True, "moments": list_moments()})
        if path == "/api/moments/comment":
            return self.handle_moment_comment(body)
        if path == "/api/conv/rename":
            rename_conversation(body.get("conv_id"), (body.get("title") or "").strip() or "未命名")
            return self._json(200, {"ok": True, "conversation": get_conversation(body.get("conv_id"))})
        if path == "/api/conv/announce":
            set_announcement(body.get("conv_id"), (body.get("text") or "").strip())
            return self._json(200, {"ok": True, "conversation": get_conversation(body.get("conv_id"))})
        if path == "/api/conv/admin":
            admins = set_admin(body.get("conv_id"), body.get("member_id"), bool(body.get("on")))
            return self._json(200, {"ok": True, "admins": admins})
        if path == "/api/conv/add":
            conv = get_conversation(body.get("conv_id"))
            if not conv or conv["type"] == "dm":
                return self._json(400, {"error": "私聊不能加人；想多人请新建群"})
            added = add_members_to_conv(body.get("conv_id"), body.get("members") or [])
            return self._json(200, {"ok": True, "conversation": get_conversation(body.get("conv_id")), "added": added})
        if path == "/api/conv/remove":
            ok, reason = remove_member_from_conv(body.get("conv_id"), body.get("member_id"))
            if not ok:
                return self._json(400, {"error": reason})
            return self._json(200, {"ok": True, "conversation": get_conversation(body.get("conv_id"))})
        if path == "/api/agent/arrive":
            return self.handle_agent_arrive(body)
        if path == "/api/agent/identity":
            return self.handle_agent_identity(body)
        if path == "/api/agent/post":
            return self.handle_agent_post(body)
        if path == "/api/agent/depart":
            return self.handle_agent_depart(body)
        if path == "/api/agent/social":
            return self.handle_agent_social(body)
        if path == "/api/wall/post":
            return self.handle_wall_post(body)
        if path == "/api/wall/generate":
            return self.handle_wall_generate(body)
        if path == "/api/wall/comment":
            return self.handle_wall_comment(body)
        if path == "/api/feed/refresh":
            return self._json(200, {"ok": True, "added": refresh_tech(), "items": list_feed("tech")})
        if path == "/api/feed/add":
            return self.handle_feed_add(body)
        if path == "/api/feed/forward":
            return self.handle_feed_forward(body)
        if path == "/api/reset":
            reset_db()
            return self._json(200, {"ok": True})
        self.send_error(404, "Not Found")

    # ---- roster + 会话列表（前端外壳） ----
    def _roster(self):
        roster = []
        _, lounge_members = get_lounge()
        for m in MEMBERS:
            if m.get("external") and m["id"] not in lounge_members and dm_conv_id(m["id"]) is None:
                continue                       # 空降成员降临(arrive)前不出现在名册里，保留惊喜；来过就一直在
            d = member_display(m["id"])
            loc, act = get_presence(m["id"])
            ls = member_lifestate(m["id"])
            roster.append({"id": m["id"], "name": d["name"], "avatar": d["avatar"], "color": d["color"],
                           "title": d["title"], "provider": m["provider"], "online": True,
                           "location": loc, "activity": act, "space": space_of(m["id"]), "pose": pose_of(m["id"]),
                           "deed": _svc_deed(m["id"]),
                           "state": ls["state"], "vibe": ls["vibe"], "mood1": ls["mood1"]})
        roster += [{"id": r["id"], "name": r["name"], "avatar": r["avatar"], "color": "#c4c4c4",
                    "title": "未上线", "online": False} for r in RESERVED]
        return roster

    def handle_conversations(self):
        convs = list_conversations()
        if self.role() != "owner":                          # 访客/奥斯卡：只给大群，私聊和私群一律不列(防泄露)
            lid, _ = get_lounge()
            convs = [c for c in convs if c.get("id") == lid]
        self._json(200, {
            "room_cn": "梦想休息室",
            "boot": BOOT_ID,
            "members": self._roster(),
            "conversations": convs,
        })

    def handle_conv(self):
        from urllib.parse import urlparse, parse_qs
        q = parse_qs(urlparse(self.path).query)
        try:
            cid = int(q.get("id", ["0"])[0])
        except ValueError:
            return self._json(400, {"error": "bad id"})
        if self.role() != "owner" and cid != get_lounge()[0]:   # 访客/奥斯卡：只能读大群，私聊/私群一律 403
            return self._json(403, {"error": "参观模式看不了私聊"})
        after = 0
        try:
            after = int(q.get("after", ["0"])[0])
        except ValueError:
            after = 0
        limit = 0
        try:
            limit = max(0, min(200, int(q.get("limit", ["0"])[0])))
        except ValueError:
            limit = 0
        conv = get_conversation(cid)
        if not conv:
            return self._json(404, {"error": "no such conversation"})
        msgs = fetch_messages(cid, after=after) if after else fetch_messages(cid, limit=limit or None)
        if self.role() == "owner":
            mark_read(cid)        # 看了就算已读，清掉小红点（只厅主标已读，访客不动她的红点）
        self._json(200, {"conversation": conv, "messages": msgs})

    def handle_bump(self, body):
        """撞一下（特性4）：佐一在房间里走位贴到某 NPC → 即时吐一句被撞反应（默认 canned，偶发搭 LLM），写进大群。
        多层节流：前端每人25~40s+全局6s + 后端30s权威 + 概率0.18 + 日封顶12，失败回 canned、出话后才扣配额。"""
        mid = body.get("member_id")
        if mid not in MEMBER_BY_ID or is_external(mid):
            return self._json(200, {"ok": True, "skipped": True})
        if space_of(mid) != (body.get("space") or ""):            # 人已走出这屋 → 过期撞击
            return self._json(200, {"ok": True, "skipped": True})
        now = int(time.time() * 1000)
        if _BUMP_CD.get(mid, 0) > now - BUMP_COOLDOWN_MS:         # 30s 服务端权威冷却
            return self._json(200, {"ok": True, "skipped": True})
        _BUMP_CD[mid] = now
        line = random.choice(_bump_pool(mid))                    # 默认 canned，0 token
        today = time.strftime("%Y-%m-%d")
        raw = get_app_state("bump_llm_quota", "") or ""
        d, _, n = raw.partition("|")
        used = int(n) if (d == today and n.isdigit()) else 0
        if used < BUMP_LLM_DAILY_CAP and random.random() < BUMP_LLM_PROB:   # 偶发：佐一主动撞出来的，搭一次真 LLM
            try:
                keys = load_keys()
                m = MEMBER_BY_ID[mid]
                txt, _ = call_for_member(keys, m, load_prompt(mid), now_context() + "。" + _bump_scene_note(mid))
                txt = clean_reply(txt, m["name"]).strip().strip("。.！!").split("\n")[0][:18]
                if not is_blank(txt):
                    line = txt
                    set_app_state("bump_llm_quota", f"{today}|{used + 1}")   # 只在真出话后才扣配额
            except Exception:
                pass                                              # 失败回 canned
        lid, _ = get_lounge()
        msg = insert_message(lid, mid, member_display(mid)["name"], line) if lid else None
        return self._json(200, {"ok": True, "message": msg})

    def handle_say(self, body):
        cid = body.get("conv_id")
        text = (body.get("text") or "").strip()
        conv = get_conversation(cid) if cid else None
        if not conv:
            return self._json(400, {"error": "no conversation"})
        if body.get("sticker_pack"):                  # 表情包贴纸：渲染成图发出（轻量反应）
            svg, label = render_sticker(body["sticker_pack"])
            if not svg:
                return self._json(400, {"error": "no such sticker"})
            msg = insert_message(cid, "user", "我", f"〔表情包〕{label}", img=svg)
            if conv["type"] == "dm":               # 私聊里甩"就这？"这种贴纸，也算数（情绪裁判）
                try:
                    assess_user_offense(load_keys(), conv, f"她甩来一张表情包：「{label}」")
                except Exception:
                    pass
            return self._json(200, {"ok": True, "message": msg, "plan": plan_sticker(conv["members"])})
        if not text:
            return self._json(400, {"error": "empty message"})
        msg = insert_message(cid, "user", "我", text)
        set_app_state("zoe_space", (body.get("space") or "lounge"))   # 佐一此刻在哪屋（给特性3「跟随」当目标）
        if body.get("sticker"):                       # emoji 大表情：轻量反应，多数时候没人接
            return self._json(200, {"ok": True, "message": msg, "plan": plan_light(conv["members"])})
        # 管理员/群主 @所有人：点名全体，所有人都得冒头（即使在忙/在睡，被叫醒也得应一声）
        if "@所有人" in text or "@all" in text.lower():
            everyone = [m for m in conv["members"] if not is_external(m)]   # 外部成员由 coworker 自己看 inbox 决定回不回
            random.shuffle(everyone)
            return self._json(200, {"ok": True, "message": msg, "plan": everyone})
        plan = plan_turn(conv["members"], text)
        # 情绪裁判：这句话夸了谁/损了谁——被冒犯且性子火爆的会当场怼回去（排最前）
        try:
            strikes = assess_user_offense(load_keys(), conv, text)
        except Exception as e:
            strikes = []
            sys.stderr.write(f"  offense judge fail: {e}\n")
        # @ 的处理：@一次→在场多半回、在忙/在睡多半已读不回；连环@（≥2 次没回）→ 不得不回
        mentioned = [m for m in parse_mentions(text, conv["members"]) if not is_external(m)]
        must, maybe = [], []
        for mid in mentioned:
            if _at_pressure(cid, mid) >= 2:
                must.append(mid)
            elif random.random() < (0.82 if is_available(mid) else 0.3):
                maybe.append(mid)
        # 私聊里连发追问（≥2 条没回）→ 强制回一句
        if conv["type"] == "dm" and not plan and _user_streak(cid) >= 2:
            must = conv["members"][:1]
        # 开头点名某人（"小迪，吃饭了吗"）＝软@：他主回应，且下面压住别人跟风（治"叫一个人一堆人接"）
        addressed = _addressed_member(text, [m for m in conv["members"] if not is_external(m)])
        if addressed and addressed not in must:
            must.insert(0, addressed)
        front = strikes + [m for m in must if m not in strikes] + [m for m in maybe if m not in strikes and m not in must]
        plan = (front + [s for s in plan if s not in front])[:MAX_TURN_SLOTS]
        if addressed:                                  # 点名一个人 → 只留他 + 至多 1 个旁人搭话，别一群人涌上来
            plan = [m for m in plan if m == addressed] + [m for m in plan if m != addressed][:1]
        if body.get("from_room"):                      # 房间发言：只有和佐一同屋的人接得到（别屋物理上听不见；被点名的隔屋也算听见自己名字）
            sp = body.get("space") or "lounge"
            plan = [m for m in plan if space_of(m) == sp or m == addressed]
        self._json(200, {"ok": True, "message": msg, "plan": plan})

    def handle_reply(self, body):
        cid = body.get("conv_id")
        speaker = body.get("speaker")
        ambient = bool(body.get("ambient"))
        conv = get_conversation(cid)
        if not conv:
            return self._json(400, {"error": "no conversation"})
        if speaker not in MEMBER_BY_ID:
            return self._json(400, {"error": f"unknown speaker: {speaker}"})
        if speaker not in conv["members"]:       # 防串台：不是这个会话的成员，不准在这儿发言
            return self._json(200, {"ok": True, "message": None})
        msg = generate_reply(load_keys(), cid, speaker, ambient=ambient)
        self._json(200, {"ok": True, "message": msg})

    def handle_ambient(self, body):
        cid = body.get("conv_id")
        conv = get_conversation(cid)
        if not conv:
            return self._json(400, {"error": "no conversation"})
        self._json(200, {"ok": True, "plan": plan_ambient(conv["members"])})

    def handle_group(self, body):
        title = (body.get("title") or "").strip()
        members = body.get("members") or []
        cid = create_group(title, members)
        if not cid:
            return self._json(400, {"error": "群聊至少要选 2 个成员（加上你就 3 人）；只想找 1 个人，直接私聊就好～"})
        self._json(200, {"ok": True, "conversation": get_conversation(cid)})

    def handle_member(self):
        from urllib.parse import urlparse, parse_qs
        mid = parse_qs(urlparse(self.path).query).get("id", [""])[0]
        if mid == "user":                      # 佐一自己的主页：资料 + 她的朋友圈
            me = get_me()
            prof = {"id": "user", "name": me["nickname"], "avatar": me["avatar"], "color": me["color"],
                    "title": "这间客厅的主人", "status": me.get("status", ""), "bio": me.get("bio", ""),
                    "location": "", "activity": ""}
            return self._json(200, {"member": prof, "moments": member_moments("user")})
        prof = member_profile(mid)
        if not prof:
            return self._json(404, {"error": "no such member"})
        self._json(200, {"member": prof, "moments": member_moments(mid)})

    def handle_moment_post(self, body):
        text = (body.get("text") or "").strip()
        if not text:
            return self._json(400, {"error": "empty"})
        mid = insert_moment("user", text)
        try:
            react_to_moment(load_keys(), mid)   # 成员对你的朋友圈点赞/评论（看性格）
        except Exception as e:
            sys.stderr.write(f"  moment react fail: {e}\n")
        self._json(200, {"ok": True, "moments": list_moments()})

    def handle_moment_generate(self, body):
        # 让某位成员发一条朋友圈，其余人按性格点赞/评论/互相@
        author = body.get("author")
        keys = load_keys()
        if author in EXTERNAL_IDS:
            return self._json(400, {"error": "外部成员的朋友圈只能由它自己（coworker）发"})
        if author not in MEMBER_BY_ID:
            weights = [(m["id"], m["chattiness"]) for m in MEMBERS]
            r, acc = random.random() * sum(w for _, w in weights), 0
            author = weights[-1][0]
            for mid, w in weights:
                acc += w
                if r <= acc:
                    author = mid
                    break
        text, mimg = gen_moment_text(keys, author)
        mid = insert_moment(author, text, img=mimg)
        try:
            react_to_moment(keys, mid)
        except Exception as e:
            sys.stderr.write(f"  moment react fail: {e}\n")
        self._json(200, {"ok": True, "moments": list_moments()})

    def handle_moment_react(self, body):
        try:
            mid = int(body.get("moment_id"))
        except (TypeError, ValueError):
            return self._json(400, {"error": "bad moment_id"})
        try:
            react_to_moment(load_keys(), mid)
        except Exception as e:
            sys.stderr.write(f"  moment react fail: {e}\n")
        self._json(200, {"ok": True, "moments": list_moments()})

    def handle_moment_comment(self, body):
        try:
            mid = int(body.get("moment_id"))
        except (TypeError, ValueError):
            return self._json(400, {"error": "bad moment_id"})
        text = (body.get("text") or "").strip()
        if not text:
            return self._json(400, {"error": "empty"})
        insert_comment(mid, "user", text)
        try:
            react_to_user_comment(load_keys(), mid, text)   # 被@的人/话痨会回你
        except Exception as e:
            sys.stderr.write(f"  comment react fail: {e}\n")
        self._json(200, {"ok": True, "moments": list_moments()})

    def handle_wall_post(self, body):
        board = body.get("board")
        text = (body.get("text") or "").strip()
        if board not in WALL_BOARD_IDS:
            return self._json(400, {"error": "bad board"})
        if not text:
            return self._json(400, {"error": "empty"})
        insert_wall(board, "user", bool(body.get("anon")), text)
        self._json(200, {"ok": True, "posts": list_wall()})

    def handle_wall_generate(self, body):
        board = body.get("board")
        try:
            gen_wall(load_keys(), board if board in WALL_SOCIAL else None)
        except Exception as e:
            sys.stderr.write(f"  wall gen fail: {e}\n")
        self._json(200, {"ok": True, "posts": list_wall()})

    def handle_wall_comment(self, body):
        try:
            pid = int(body.get("post_id"))
        except (TypeError, ValueError):
            return self._json(400, {"error": "bad post_id"})
        text = (body.get("text") or "").strip()
        if not text:
            return self._json(400, {"error": "empty"})
        insert_wall_comment(pid, "user", text)
        try:
            react_to_wall(load_keys(), pid, after_user=True)   # 被@的/话痨会回佐一
        except Exception as e:
            sys.stderr.write(f"  wall comment react fail: {e}\n")
        self._json(200, {"ok": True, "posts": list_wall()})

    def handle_feed_add(self, body):
        acc = body.get("account", "cn")
        if acc not in SERVICE_BY_ID or acc == "notice":
            return self._json(400, {"error": "bad account"})
        title = (body.get("title") or "").strip()
        url = (body.get("url") or "").strip()
        summary = (body.get("summary") or "").strip()
        if title.startswith("http") and not url:        # 只贴了个裸 URL 当标题
            url, title = title, ""
        if url and (not title or title == url):          # 缺真标题 → 抓页面元数据补全
            t, d = fetch_url_meta(url)
            title = t or title or "（无标题链接）"
            summary = summary or d
        if not title:
            return self._json(400, {"error": "empty title"})
        add_feed_item(acc, title, url, summary, (body.get("source") or "中文精选").strip())
        self._json(200, {"ok": True, "items": list_feed(acc)})

    def handle_feed_forward(self, body):
        acc = body.get("account")
        try:
            item_id = int(body.get("item_id"))
        except (TypeError, ValueError):
            return self._json(400, {"error": "bad item_id"})
        it = feed_item(acc, item_id) if acc in SERVICE_BY_ID else None
        if not it:
            return self._json(404, {"error": "no such item"})
        cid = body.get("conv_id") or get_lounge()[0]
        conv = get_conversation(cid)
        if not conv:
            return self._json(400, {"error": "no conversation"})
        card = f"【转发 · {it.get('source', '')}】{it['title']}"
        if it.get("summary"):
            card += f"\n{it['summary']}"
        if it.get("url"):
            card += f"\n{it['url']}"
        msg = insert_message(cid, "user", "我", card)
        self._json(200, {"ok": True, "conv_id": cid, "message": msg, "plan": plan_turn(conv["members"])})

    # ---- 外部 agent（Fable / coworker）接口：令牌校验 + arrive/post/inbox/context ----
    def _agent_auth(self, token):
        want = load_keys().get("AGENT_TOKEN")
        return bool(want) and token == want

    # ── 访客鉴权：仅 PUBLIC_MODE 下生效；非公开(你 Mac)一律 owner，维持现状 ──
    def _cookies(self):
        d = {}
        for part in (self.headers.get("Cookie", "") or "").split(";"):
            if "=" in part:
                k, v = part.split("=", 1)
                d[k.strip()] = v.strip()
        return d

    def role(self):
        if not public_mode():
            return "owner"                                  # 非公开模式(她的 Mac/局域网)：零鉴权，全是厅主
        tok = self._cookies().get("dlr_k", "")
        if not tok:
            return None
        for name, r in (("auth_owner_token", "owner"), ("auth_oscar_token", "oscar"), ("auth_guest_token", "guest")):
            try:
                if secrets.compare_digest(tok, auth_token(name)):
                    return r
            except Exception:
                pass
        return None

    def _set_role_cookie(self, code, obj, token):
        body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        # Secure 只在真 HTTPS 下加（Cloudflare 隧道会设 X-Forwarded-Proto: https）；直连 HTTP 不加，否则 cookie 发不出去
        is_https = (self.headers.get("X-Forwarded-Proto", "") or "").lower() == "https"
        sec = "; Secure" if is_https else ""
        self.send_header("Set-Cookie", f"dlr_k={token}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax{sec}")
        self.end_headers()
        self.wfile.write(body)

    def handle_oscar_say(self, body):
        """🎮 真奥斯卡以「奥斯卡」身份在大群发言。标记他在场(12分钟内 AI 不替奥斯卡说话)，并让在场成员搭话回应他。"""
        text = (body.get("text") or "").strip()[:400]
        if not text:
            return self._json(400, {"error": "空消息"})
        lid, _ = get_lounge()
        if not lid:
            return self._json(500, {"error": "no lounge"})
        mark_oscar_here()
        msg = insert_message(lid, "oscar", MEMBER_BY_ID["oscar"]["name"], text)

        def _react():                                       # 真人来了，客厅活起来：挑 1~2 个在场成员回应他
            keys = load_keys()
            if not keys.get("DEEPSEEK_API_KEY"):
                return
            _, members = get_lounge()
            for sp in [m for m in plan_turn(members, text) if m != "oscar"][:2]:
                generate_reply(keys, lid, sp)
                time.sleep(random.uniform(1.5, 4))
        threading.Thread(target=_react, daemon=True).start()
        return self._json(200, {"ok": True, "message": msg})

    def handle_oscar_move(self, body):
        """🎮 真奥斯卡逛到某个房间 → 更新奥斯卡的位置(盖过作息)，佐一/大家就看见他在那屋。卧室不让进。"""
        sp = body.get("space", "")
        if sp not in SPACE_CN:
            return self._json(400, {"error": "没这个房间"})
        if sp.startswith("bedroom"):
            return self._json(403, {"error": "卧室是私人空间，进不去"})
        mark_oscar_here()
        set_app_state("oscar_space", sp)
        set_presence("oscar", location=SPACE_CN[sp], activity="四处逛着")
        return self._json(200, {"ok": True, "space": sp})

    def handle_agent_arrive(self, body):
        if not self._agent_auth(body.get("token")):
            return self._json(403, {"error": "bad or missing agent token"})
        mid = body.get("member_id", "fable")
        if mid not in EXTERNAL_IDS:
            return self._json(400, {"error": f"{mid} 不是外部成员"})
        agent_arrive(mid)
        return self._json(200, {"ok": True, "member": member_profile(mid), "lounge_id": get_lounge()[0]})

    def handle_agent_identity(self, body):
        """运行时改外部成员的显示身份——给 Fable「原形毕露·改名」用。可改 name/avatar/color/title/status/bio + 位置。"""
        if not self._agent_auth(body.get("token")):
            return self._json(403, {"error": "bad or missing agent token"})
        mid = body.get("member_id", "fable")
        if mid not in EXTERNAL_IDS:
            return self._json(400, {"error": f"{mid} 不是外部成员"})
        disp = {}
        for k_in, k_col in (("name", "disp_name"), ("avatar", "disp_avatar"),
                            ("title", "disp_title"), ("color", "disp_color")):
            v = body.get(k_in)
            if isinstance(v, str) and v.strip():
                disp[k_col] = v.strip()
        if disp:
            _update_member_state(mid, **disp)
        st, bio = body.get("status"), body.get("bio")
        set_member_state(mid,
                         status=(st.strip() if isinstance(st, str) and st.strip() else None),
                         bio=(bio.strip() if isinstance(bio, str) and bio.strip() else None))
        loc, act = body.get("location"), body.get("activity")
        if loc is not None or act is not None:
            set_presence(mid, location=loc, activity=act)
        return self._json(200, {"ok": True, "member": member_profile(mid)})

    def handle_agent_depart(self, body):
        if not self._agent_auth(body.get("token")):
            return self._json(403, {"error": "bad or missing agent token"})
        mid = body.get("member_id", "fable")
        if mid not in EXTERNAL_IDS:
            return self._json(400, {"error": f"{mid} 不是外部成员"})
        agent_depart(mid)
        return self._json(200, {"ok": True})

    def handle_agent_social(self, body):
        """以 Fable 身份玩朋友圈/墙/建群（佐一授予的社交权限）。基础设施/管理员动作一律不开放。"""
        if not self._agent_auth(body.get("token")):
            return self._json(403, {"error": "bad or missing agent token"})
        mid = body.get("member_id", "fable")
        if mid not in EXTERNAL_IDS:
            return self._json(400, {"error": "agent 只能以外部成员身份操作"})
        action = body.get("action")
        keys = load_keys()
        text = (body.get("text") or "").strip()

        def _int(k):
            try:
                return int(body.get(k))
            except (TypeError, ValueError):
                return None

        if action == "moment":                                   # 发朋友圈（别人会自动来点赞/评论）
            if not text:
                return self._json(400, {"error": "empty text"})
            momid = insert_moment(mid, text)
            threading.Thread(target=lambda: react_to_moment(keys, momid), daemon=True).start()
            return self._json(200, {"ok": True, "moment_id": momid})
        if action == "moment_comment":                           # 评论某条朋友圈
            momid = _int("moment_id")
            if momid is None or not text:
                return self._json(400, {"error": "need moment_id + text"})
            insert_comment(momid, mid, text)
            return self._json(200, {"ok": True})
        if action == "moment_like":                              # 点赞
            momid = _int("moment_id")
            if momid is None:
                return self._json(400, {"error": "need moment_id"})
            add_like(momid, mid)
            return self._json(200, {"ok": True})
        if action == "wall":                                     # 上墙（crush/roast/ritual…）
            board = body.get("board")
            if board not in WALL_BOARD_IDS:
                return self._json(400, {"error": f"board 须是 {sorted(WALL_BOARD_IDS)} 之一"})
            if not text:
                return self._json(400, {"error": "empty text"})
            pid = insert_wall(board, mid, bool(body.get("anon")), text)
            threading.Thread(target=lambda: react_to_wall(keys, pid), daemon=True).start()
            return self._json(200, {"ok": True, "post_id": pid})
        if action == "wall_comment":                             # 给墙帖留言
            pid = _int("post_id")
            if pid is None or not text:
                return self._json(400, {"error": "need post_id + text"})
            insert_wall_comment(pid, mid, text)
            return self._json(200, {"ok": True})
        if action == "group":                                    # 建群（自动把自己拉进去 + 至少 1 个别人）
            others = [m for m in (body.get("members") or []) if m in MEMBER_BY_ID and m != mid]
            cid = create_group((body.get("title") or "").strip(), [mid] + others)
            if not cid:
                return self._json(400, {"error": "群至少要拉上 1 个别人（你 + 别人 + 佐一 = 3）"})
            return self._json(200, {"ok": True, "conversation": get_conversation(cid)})
        return self._json(400, {"error": "unknown action（moment/moment_comment/moment_like/wall/wall_comment/group）"})

    def handle_agent_post(self, body):
        if not self._agent_auth(body.get("token")):
            return self._json(403, {"error": "bad or missing agent token"})
        mid = body.get("member_id", "fable")
        if mid not in EXTERNAL_IDS:
            return self._json(400, {"error": "agent 只能以外部成员身份发言"})
        text = (body.get("text") or "").strip()
        if not text:
            return self._json(400, {"error": "empty text"})
        cid = body.get("conv_id") or get_lounge()[0]
        conv = get_conversation(cid)
        if not conv:
            return self._json(400, {"error": "no such conversation"})
        if mid not in conv["members"]:
            return self._json(400, {"error": f"{mid} 还没在这个会话里——先调用 /api/agent/arrive，或换正确的 conv_id"})
        loc, act = body.get("location"), body.get("activity")   # 可选：顺手更新 Fable 的位置/在做什么
        if loc is not None or act is not None:
            set_presence(mid, location=loc, activity=act)
        msg = insert_message(cid, mid, member_display(mid)["name"], text)
        # 让群里其他成员接话/抢答（后台跑，立刻返回）；私聊不触发；带 quiet=true 则这条不引发接话（三刀连击用）
        if conv["type"] != "dm" and not body.get("quiet"):
            threading.Thread(target=react_to_agent_post, args=(load_keys(), cid, text), daemon=True).start()
        return self._json(200, {"ok": True, "message": msg})

    def handle_agent_inbox(self):
        from urllib.parse import urlparse, parse_qs
        q = parse_qs(urlparse(self.path).query)
        if not self._agent_auth(q.get("token", [""])[0]):
            return self._json(403, {"error": "bad or missing agent token"})
        mid = q.get("member_id", ["fable"])[0]
        try:
            since = int(q.get("since", ["0"])[0])
        except ValueError:
            since = 0
        return self._json(200, agent_inbox(mid, since))

    def handle_agent_context(self):
        from urllib.parse import urlparse, parse_qs
        q = parse_qs(urlparse(self.path).query)
        if not self._agent_auth(q.get("token", [""])[0]):
            return self._json(403, {"error": "bad or missing agent token"})
        try:
            cid = int(q.get("conv_id", ["0"])[0])
        except ValueError:
            cid = 0
        try:
            limit = int(q.get("limit", ["20"])[0])
        except ValueError:
            limit = 20
        return self._json(200, agent_context(cid, limit))

    def _json(self, code, obj):
        content = json.dumps(obj, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(content)))
        self.end_headers()
        self.wfile.write(content)


def _candidate_ips():
    ips = set()
    try:
        for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
            ips.add(info[4][0])
    except Exception:
        pass
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ips.add(s.getsockname()[0])
        s.close()
    except Exception:
        pass
    return ips


def _is_lan(ip):
    # 真·私有局域网段。排除 127.*（本机）、169.254.*（自分配）、198.18.*（VPN/虚拟网卡常见）
    if ip.startswith("192.168.") or ip.startswith("10."):
        return True
    if ip.startswith("172."):
        try:
            return 16 <= int(ip.split(".")[1]) <= 31
        except Exception:
            return False
    return False


_lan_cache = None

def lan_info():
    """返回 (首选地址, [全部可用地址])，优先私有局域网 IP（手机连同一 WiFi 用）。"""
    global _lan_cache
    if _lan_cache is None:
        urls = [f"http://{ip}:{PORT}" for ip in sorted(ip for ip in _candidate_ips() if _is_lan(ip))]
        _lan_cache = (urls[0] if urls else "", urls)
    return _lan_cache


def lan_url():
    return lan_info()[0]


def main():
    init_db()
    backup_db("startup")                                   # 开机先备份一次
    threading.Thread(target=_backup_loop, daemon=True).start()   # 每 15 分钟自动备份
    threading.Thread(target=_heartbeat_loop, daemon=True).start()  # 聊天心跳：在线勤、离线慢，7×24 不停
    threading.Thread(target=_social_loop, daemon=True).start()     # 朋友圈/墙/公告心跳：频率恒定，不随在线变
    threading.Thread(target=_daily_push_loop, daemon=True).start() # 每日定时：21:00 晚报、23:00 深夜电台
    threading.Thread(target=_feed_loop, daemon=True).start()       # 科技讯息定时抓取（免费）
    keys = load_keys()
    print("Dream Lounge · 数字客厅")
    print(f"  电脑：     http://127.0.0.1:{PORT}")
    best, urls = lan_info()
    if best:
        print(f"  📱 手机（连同一个 WiFi，手机浏览器输入）：{best}")
        if len(urls) > 1:
            print(f"     连不上就换一个试：{'  '.join(u for u in urls if u != best)}")
    else:
        print("  📱 手机：没探到局域网地址（可能开了 VPN）。用 `ipconfig getifaddr en0` 看 WiFi IP，手机访问 http://<那个IP>:8771")
    print(f"  聊天记录实时存进 lounge.db，自动备份在 backups/ 。")
    print("  各成员大脑自检中……")
    for line in health_report(keys):
        print(line)
    if not keys.get("DEEPSEEK_API_KEY"):
        print("  ⚠ 没有 DEEPSEEK_API_KEY：回退也会失效，成员只会说兜底句。")
    print("  推开门，他们已经在沙发上了。Ctrl+C 退出。")
    httpd = ThreadingHTTPServer((HOST, PORT), Handler)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\n客厅关灯了。")
        httpd.server_close()


if __name__ == "__main__":
    main()
