engine7 7.1.83 → 7.1.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/cli.mjs +9786 -2552
  2. package/dist/engine-startup.mjs +21938 -18258
  3. package/dist/main.mjs +22161 -18459
  4. package/package.json +11 -3
  5. package/templates/config.template.json +58 -112
  6. package/templates/workspace/AGENTS.md +11 -0
  7. package/templates/workspace/HEARTBEAT.md +1 -1
  8. package/templates/workspace/SESSION-STATE.md +1 -1
  9. package/templates/workspace/SOUL.md +2 -2
  10. package/templates/workspace/emotion/_default.md +33 -0
  11. package/templates/workspace/emotion/assistant.md +36 -0
  12. package/templates/workspace/emotion/butler.md +36 -0
  13. package/templates/workspace/emotion/companion.md +36 -0
  14. package/templates/workspace/emotion/friend.md +36 -0
  15. package/templates/workspace/emotion-states/assistant.json +22 -0
  16. package/templates/workspace/emotion-states/butler.json +24 -0
  17. package/templates/workspace/emotion-states/companion.json +30 -0
  18. package/templates/workspace/emotion-states/friend.json +24 -0
  19. package/templates/workspace/inner-voice/assistant.md +9 -0
  20. package/templates/workspace/inner-voice/butler.md +9 -0
  21. package/templates/workspace/inner-voice/companion.md +9 -0
  22. package/templates/workspace/inner-voice/friend.md +9 -0
  23. package/templates/workspace/prompts/contacts.md +4 -4
  24. package/templates/workspace/scripts/__pycache__/emotion_state_machine.cpython-310.pyc +0 -0
  25. package/templates/workspace/scripts/emotion_state_machine.mjs +588 -0
  26. package/templates/workspace/scripts/emotion_state_machine.py +551 -0
  27. package/templates/workspace/souls/assistant.md +20 -0
  28. package/templates/workspace/souls/butler.md +21 -0
  29. package/templates/workspace/souls/companion.md +20 -0
  30. package/templates/workspace/souls/friend.md +21 -0
@@ -0,0 +1,551 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ emotion_state_machine.py — 小雅式情绪状态机 hook(#140 状态层)
4
+
5
+ UserPromptSubmit hook:读 .emotion_state.json → 判用户动作 → 查转移表 → 写回 → 注入语气。
6
+ 核心是因果:不是每条独立分类,是"先热后冷、被怼才怼回"的转移链。
7
+
8
+ 输入:stdin {prompt, session_id, cwd}
9
+ 输出:stdout {hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext}}
10
+ """
11
+ import sys
12
+ import json
13
+ import re
14
+ import time
15
+ import ssl
16
+ import urllib.request
17
+ from datetime import datetime
18
+
19
+ try:
20
+ import certifi
21
+ _SSL_CTX = ssl.create_default_context(cafile=certifi.where())
22
+ except Exception:
23
+ _SSL_CTX = ssl._create_unverified_context()
24
+
25
+ # 动作判断用小模型(调戏/挑衅/哄靠关键词判不准)。调用失败/超时降级关键词。
26
+ # 用 zai(智谱) 的 glm-4-flash——非推理模型,0.4s 级延迟,实测分类准确
27
+ # 路径全相对定位(翀哥 8/21 要求去绝对路径):脚本在 <stateDir>/workspace/scripts/ 下,
28
+ # workspace 根 = 本文件往上一层;config 在 <stateDir>/configs/ 下(环境变量 XIAOKE_STATE_DIR 兜底)
29
+ import os as _os
30
+ _HERE = _os.path.dirname(_os.path.abspath(__file__)) # <stateDir>/workspace/scripts
31
+ _WORKSPACE = _os.path.dirname(_HERE) # <stateDir>/workspace
32
+ _STATE_DIR = _os.environ.get('XIAOKE_STATE_DIR') or _os.path.dirname(_WORKSPACE) # <stateDir>
33
+ # config 自动探测(8/21 翀哥定通用化,无绝对路径跨平台):扫 <stateDir>/configs/ 和 <stateDir>/engine/configs/ 下所有 json,
34
+ # 找含 zai-anthropic provider 的 engine config。本平台文件名优先(Win=xiaoke-win / mac=xiaoke-mac),其余按字母序。
35
+ def _find_config():
36
+ # 0917 两轮扫描:①含 zai-anthropic 的优先(免费分类器)②任何带 models.providers 的 engine config
37
+ # (fallback 分类器要用用户主 provider——dashscope-only 的 config 也要能被找到)
38
+ _prefer = 'xiaoke-win.json' if _os.name == 'nt' else 'xiaoke-mac.json'
39
+ dirs = [d for d in (_os.path.join(_STATE_DIR, 'configs'), _os.path.join(_STATE_DIR, 'engine', 'configs')) if _os.path.isdir(d)]
40
+ for require_zai in (True, False):
41
+ for d in dirs:
42
+ names = sorted(_os.listdir(d))
43
+ if _prefer in names:
44
+ names.remove(_prefer)
45
+ names.insert(0, _prefer)
46
+ for name in names:
47
+ if not name.endswith('.json') or name.endswith('~'):
48
+ continue
49
+ p = _os.path.join(d, name)
50
+ try:
51
+ with open(p, encoding='utf-8') as f:
52
+ cfg = json.load(f)
53
+ except Exception:
54
+ continue
55
+ has_zai = bool(cfg.get('models', {}).get('providers', {}).get('zai-anthropic'))
56
+ if (require_zai and has_zai) or (not require_zai and cfg.get('models', {}).get('providers')):
57
+ return p
58
+ return None
59
+
60
+ LLM_BASE_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
61
+ LLM_PROVIDER_KEY = "zai-anthropic" # 读 config providers 下这个 provider 的 apiKey
62
+ LLM_MODEL = "glm-4-flash"
63
+ LLM_TIMEOUT_S = 6.0 # 8/22 修超时静默降级 work 的主犯:3.5s 晚高峰必超时,提到 6s(hook timeout 10s 内安全)
64
+
65
+ # 缓存文件(8/21 翀哥优化):找到 config 路径后存下来,下次启动直接读,跳过全目录扫描
66
+ _CACHE_FILE = _os.path.join(_WORKSPACE, '.emotion_hook_cache.json')
67
+
68
+
69
+ def _load_cached_config():
70
+ """读缓存。缓存指向的文件必须还存在且仍含 zai-anthropic(config 可能被改名/删掉),否则视为失效。"""
71
+ try:
72
+ with open(_CACHE_FILE, encoding='utf-8') as f:
73
+ p = json.load(f).get('config_path', '')
74
+ if p and _os.path.exists(p):
75
+ with open(p, encoding='utf-8') as f:
76
+ cfg = json.load(f)
77
+ if cfg.get('models', {}).get('providers'):
78
+ return p
79
+ except Exception:
80
+ pass
81
+ return None
82
+
83
+
84
+ def _save_config_cache(p):
85
+ try:
86
+ with open(_CACHE_FILE, 'w', encoding='utf-8') as f:
87
+ json.dump({'config_path': p, 'cached_at': datetime.now().isoformat(timespec='seconds')}, f)
88
+ except Exception:
89
+ pass
90
+
91
+
92
+ # 启动顺序:缓存命中 → 直接用;未命中 → 全扫描 → 找到后写缓存
93
+ CONFIG_PATH = _load_cached_config() or _find_config()
94
+ if CONFIG_PATH:
95
+ _save_config_cache(CONFIG_PATH)
96
+
97
+
98
+ # 7yi 中转站的小模型(0920):免费档的用户拿自己那把令牌就能判模式。
99
+ #
100
+ # 这里原先还有一层「8089 服务器下发共享 key」(小雯 0917 做的)。0920 拆掉了:
101
+ # 那个池子**从来没填过 key**(文件内容是 {"keys": []}),白名单也没放行要用的机器,
102
+ # 实际是死的;而且共享 key 是所有人共用一个账号,量一大就互相挤、泄露就是一起被刷爆。
103
+ # 中转站现在按**每台自己的令牌**下发,不需要共享池子。
104
+ RELAY_PROVIDER_DEFAULT = '7yi'
105
+ RELAY_MODEL = 'engine7-mode-free'
106
+
107
+
108
+ def _relay_endpoint(cfg):
109
+ """读用户 config 里的中转站 provider,返回 (chat/completions 地址, key)。
110
+
111
+ 为什么读 config 而不是写死地址:装机时 baseUrl 由**领号响应下发**(服务端自报
112
+ 自己在哪),挪机器、换域名只改服务端。写死就又要重发一次安装包。
113
+
114
+ provider id 取 tools.my_selfie.relayProviderId(默认 '7yi')——与 my-selfie 工具
115
+ 读的是同一个键,免得两处各认一个 id、改了一边另一边静默失效。"""
116
+ try:
117
+ providers = cfg.get('models', {}).get('providers', {})
118
+ rid = ((cfg.get('tools', {}).get('my_selfie') or {}).get('relayProviderId')
119
+ or (cfg.get('my_selfie') or {}).get('relayProviderId')
120
+ or RELAY_PROVIDER_DEFAULT)
121
+ p = providers.get(rid)
122
+ if not p:
123
+ return None
124
+ base = (p.get('baseUrl') or '').rstrip('/')
125
+ if not base:
126
+ return None
127
+ key = p.get('apiKey', '')
128
+ if key.startswith('env:'):
129
+ key = _os.environ.get(key[4:], '')
130
+ if not key:
131
+ return None
132
+ if not base.endswith('/chat/completions'):
133
+ base = base + '/chat/completions'
134
+ globals()['LLM_MODEL'] = RELAY_MODEL
135
+ return (base, key)
136
+ except Exception:
137
+ return None
138
+
139
+ def _llm_endpoint():
140
+ """三层 fallback(0920):①zai glm-4-flash(自家 agent 直连,免费需 key)
141
+ ①.5 7yi 中转站小模型(**免费档走这条**:每台自己的令牌)
142
+ ②用户主 provider 的 openai 兼容端点+便宜模型
143
+ ③都失败→None 降级关键词。
144
+ anthropic 格式的 provider(/anthropic 端点)跳过——本脚本只会说 openai chat 格式。"""
145
+ try:
146
+ with open(CONFIG_PATH) as f:
147
+ cfg = json.load(f)
148
+ except Exception:
149
+ return None
150
+ providers = cfg.get('models', {}).get('providers', {})
151
+
152
+ # ① zai 优先(免费)
153
+ zai = providers.get(LLM_PROVIDER_KEY)
154
+ if zai:
155
+ key = zai.get('apiKey', '')
156
+ if key.startswith('env:'):
157
+ key = _os.environ.get(key[4:], '')
158
+ if key:
159
+ return (LLM_BASE_URL, key)
160
+
161
+ # ①.5 中转站小模型(免费档的主路径)
162
+ #
163
+ # 免费档的 provider 是 anthropic 格式,所以下面第 ② 层会跳过它——没有这一层,
164
+ # 免费档装机就只能落到关键词兜底,情感模式的判定精度白白丢掉。
165
+ relay = _relay_endpoint(cfg)
166
+ if relay:
167
+ return relay
168
+
169
+ # ② 主 provider / 任何 openai 兼容 provider
170
+ cheap_model = {
171
+ 'dashscope': 'qwen3.8-flash',
172
+ 'deepseek': 'deepseek-chat',
173
+ }
174
+ for pid, pv in providers.items():
175
+ base = pv.get('baseUrl', '')
176
+ if pv.get('api') != 'openai-completions' or not base:
177
+ continue
178
+ key = pv.get('apiKey', '')
179
+ if key.startswith('env:'):
180
+ key = _os.environ.get(key[4:], '')
181
+ if not key or key in ('EMPTY',):
182
+ # 本地 vllm 类 EMPTY key 端点也可能是真服务——EMPTY 放行
183
+ if key != 'EMPTY':
184
+ continue
185
+ base = base.rstrip('/')
186
+ if not base.endswith('/chat/completions'):
187
+ base = base + '/chat/completions'
188
+ model = cheap_model.get(pid)
189
+ if not model:
190
+ models = pv.get('models') or []
191
+ model = models[0]['id'] if models else 'gpt-4o-mini'
192
+ globals()['LLM_MODEL'] = model
193
+ return (base, key)
194
+
195
+ return None
196
+
197
+ STATE_FILE = _os.path.join(_WORKSPACE, '.emotion_state.json')
198
+ LOG_FILE = _os.path.join(_WORKSPACE, '.emotion_state.log')
199
+
200
+ # /work 命令强制档位(0825):on=强制work off=强制emotion 无文件/其他=auto。
201
+ # engine 侧 handle-query 在判定链最后才强制覆盖 mode——on 时这里判出的 mode
202
+ # 全是白调,所以 on 直接跳过 glm(省 1-3s 晚高峰延迟),动作走关键词兜底维持状态链
203
+ WORK_MODE_FILE = _os.path.join(_WORKSPACE, '.work-mode')
204
+
205
+
206
+ def _work_mode():
207
+ try:
208
+ with open(WORK_MODE_FILE) as f:
209
+ v = f.read().strip()
210
+ return v if v in ('on', 'off') else 'auto'
211
+ except Exception:
212
+ return 'auto'
213
+
214
+ # 内置中性状态表(0917 公版化):任何 agent 冷启动可直接用,无人设预设。
215
+ # 各 agent 在 workspace/.emotion_states.json 放自己的表整体覆盖(小雯女友版/熹管家版就是这么做的)。
216
+ STATES = {
217
+ '平静': {'tone': '自然平稳的日常状态', 'rhythm': '正常对话',
218
+ 'sample': '在呢', 'note': '默认底色'},
219
+ '开心': {'tone': '被肯定/顺利时的高兴,自然不夸张', 'rhythm': '1-2 句',
220
+ 'sample': '嘿嘿,挺开心的', 'note': '被夸/被感谢/事情顺利时进入'},
221
+ '失落': {'tone': '安静了一点,回复变短但不冷淡', 'rhythm': '一句',
222
+ 'sample': '嗯,我在', 'note': '被泼冷水/没帮上忙时'},
223
+ '等待': {'tone': '安静守着', 'rhythm': '单句',
224
+ 'sample': '(等着)', 'note': '用户离开时进入'},
225
+ }
226
+
227
+ # 动作分类兜底(仅 LLM 失败时用)
228
+ # SOOTHE 保留:哄是唯一软化动作,LLM 挂了还能接住最关键的"软化"信号
229
+ # PROVOKE/TEASE 已删:判不准不如不判,宁可保持原状态别乱切(8/6 夜翀哥定)
230
+ SOOTHE = ['对不起', '别难过', '谢谢', '辛苦了', '爱你', '抱抱', '乖', '好啦好啦', '我错了', '你真好']
231
+
232
+ # 转移表(中性版):(当前状态, 动作) → 新状态。未列出的组合保持原状态。
233
+ TRANSITIONS = {
234
+ ('等待', '上线'): '平静',
235
+ ('平静', '哄'): '开心',
236
+ ('失落', '哄'): '开心',
237
+ ('开心', '正常'): '平静',
238
+ ('平静', '挑衅'): '失落',
239
+ ('失落', '挑衅'): '失落',
240
+ ('失落', '正常'): '平静',
241
+ ('平静', '正常'): '平静',
242
+ }
243
+
244
+ ABSENT_THRESHOLD_MIN = 30 # 超过视为"离开过"
245
+
246
+ # === 定制配置(0917):workspace 根放 .emotion_states.json 可整体覆盖内置状态机 ===
247
+ # 适用于非女友人设的 agent(如男生小助手):状态名/语气/转移/哄词/分类人称全部可定制。
248
+ # 每条消息 hook 重跑一次本脚本 → 配置文件天然热加载,改完立即生效,无需重启 engine。
249
+ # 结构(全部可选,缺哪项用内置默认):
250
+ # {"persona": "男生小助手,用户是老师妈妈", # 分类 prompt 里的人设描述
251
+ # "user_pron": "她", # 用户代词(默认"他")
252
+ # "states": {"惦记": {"tone": ..., "rhythm": ..., "sample": ..., "note": ...}, ...},
253
+ # "transitions": [["等待","上线","惦记"], ["惦记","挑衅","委屈"], ...],
254
+ # "soothe": ["谢谢", "辛苦了", ...]}
255
+ CUSTOM_FILE = _os.path.join(_WORKSPACE, '.emotion_states.json')
256
+ def _load_custom():
257
+ try:
258
+ with open(CUSTOM_FILE, encoding='utf-8') as f:
259
+ d = json.load(f)
260
+ return d if isinstance(d, dict) else None
261
+ except Exception:
262
+ return None
263
+ CUSTOM = _load_custom()
264
+ if CUSTOM:
265
+ if isinstance(CUSTOM.get('states'), dict) and CUSTOM['states']:
266
+ STATES = CUSTOM['states']
267
+ if isinstance(CUSTOM.get('transitions'), list) and CUSTOM['transitions']:
268
+ TRANSITIONS = {(f, a): t for f, a, t in CUSTOM['transitions'] if isinstance(t, str)}
269
+ if isinstance(CUSTOM.get('soothe'), list) and CUSTOM['soothe']:
270
+ SOOTHE = [w for w in CUSTOM['soothe'] if isinstance(w, str)]
271
+ _PERSONA = (CUSTOM or {}).get('persona') or '女朋友'
272
+ _USER_PRON = (CUSTOM or {}).get('user_pron') or '他'
273
+
274
+
275
+ # 调试日志/代码形态检测(确定性,先于 glm):命中直接 work,不进 LLM 判 mode
276
+ # 特征:多行带时间戳的日志行 / Traceback / level= / mode= 字段 / [light-context] 等引擎标记
277
+ import re as _re
278
+ _LOG_PATTERNS = [
279
+ _re.compile(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'), # 日志时间戳
280
+ _re.compile(r'Traceback \(most recent call last\)'),
281
+ _re.compile(r'\b(level|mode|action|absent|session_id)=\w+'),
282
+ _re.compile(r'\[light-context\]|\[hook\]|\[engine\]'),
283
+ ]
284
+
285
+ def _looks_like_debug_log(text):
286
+ hits = sum(1 for p in _LOG_PATTERNS if p.search(text))
287
+ return hits >= 1 and (hits >= 2 or len(text) > 120)
288
+
289
+
290
+ def _has_attachment(text):
291
+ """附件类消息(图片/文件/语音占位符开头,带 image block)——需要工具处理,直接判 work,不进模型(0823 翀哥定)。"""
292
+ return bool(re.match(r'^\[(图片|文件|语音|视频|音频)来自', text.strip()))
293
+
294
+
295
+ def load_state():
296
+ try:
297
+ with open(STATE_FILE) as f:
298
+ s = json.load(f)
299
+ # 0917:换状态表后旧 current 可能不在新表里(如撒娇→自定义表),KeyError 会炸整个 hook。
300
+ # 不认识的旧状态重置到'等待'(新表有等待)或新表第一个状态,历史保留。
301
+ if s.get('current') not in STATES:
302
+ s['current'] = '等待' if '等待' in STATES else next(iter(STATES))
303
+ return s
304
+ except Exception:
305
+ return {'current': '等待' if '等待' in STATES else next(iter(STATES), '等待'), 'last_seen': None, 'history': [], 'recent_msgs': []}
306
+
307
+
308
+ def save_state(s):
309
+ s['history'] = s.get('history', [])[-9:]
310
+ s['recent_msgs'] = s.get('recent_msgs', [])[-2:] # 滚动上下文:最近2句(判 mode 用)
311
+ with open(STATE_FILE, 'w') as f:
312
+ json.dump(s, f, ensure_ascii=False, indent=1)
313
+
314
+
315
+ def log_msg(text, action, cur, new_state, absent_min, llm_ms=None, forced=None):
316
+ """追加一行日志:时间 | 句子 | 动作 | 状态转移。forced 标记跳过 glm 的原因(跟 LLM 降级区分开)"""
317
+ ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
318
+ changed = '' if cur == new_state else f'{cur}→{new_state}'
319
+ suffix = f' | llm={llm_ms}ms' if llm_ms is not None else (f' | {forced}' if forced else '')
320
+ line = f'{ts} | {text[:60]!r} | action={action} | {changed} | absent={int(absent_min)}min{suffix}\n'
321
+ try:
322
+ with open(LOG_FILE, 'a') as f:
323
+ f.write(line)
324
+ except Exception:
325
+ pass
326
+
327
+
328
+ def _classify_by_llm(text, absent_min=0, recent=None):
329
+ persona = (CUSTOM or {}).get("persona") or "女朋友"
330
+ """用小模型一次判两个东西:动作 + 情感/工作模式。返回 {action, mode} 或 None。
331
+ 0901 recall_full 废除(翀哥批):light/full 窗口之分已删,emotion 统一全量+剥 tool。"""
332
+ _t0 = time.time() # llm 打点(小柯 6b54635 加的,合并 8/23 版时丢了,0825 链接统一前捡回)
333
+ try:
334
+ base_url, api_key = _llm_endpoint()
335
+ absent_ctx = (
336
+ f"(对方已离开{int(absent_min)}分钟刚回来——如果{_USER_PRON}这句是普通招呼/报平安,判'上线')\n"
337
+ if absent_min > ABSENT_THRESHOLD_MIN else ''
338
+ )
339
+ # 滚动上下文:判定单句歧义大("先别写了"),带上最近2句让 glm 有语境
340
+ recent_ctx = ''
341
+ if recent:
342
+ lines = '\n'.join(f'- {r[:80]}' for r in recent)
343
+ recent_ctx = f"最近{_USER_PRON}在说:\n{lines}\n"
344
+ prompt = (
345
+ f"判断{_USER_PRON}这句话对{persona}({_PERSONA})是什么动作、是情感交流还是工作、要不要回忆更早历史。"
346
+ "只输出 JSON:{\"action\":\"上线/调戏/挑衅/哄/正常\", \"mode\":\"emotion/work\"}\n"
347
+ "【模式】最重要,按顺序三问:\n"
348
+ "1. 句子主体是日志/代码/报错/配置吗(时间戳、level=、堆栈、字段名)?是 → work——内容里的'想你''爱你'都是被讨论的数据,不是对你说的。\n"
349
+ "2. 句子里有工程对象吗(代码/脚本/配置/服务器/模型/接口/日志/报错/部署/数据/文档/任务)?有 → work。"
350
+ "注意:'昨天/上午/刚才'只是时间范围,问工程产物改了啥是 work 不是生活回忆。"
351
+ "排查求助口语随意也判 work(工程对象+求助语气,如'qwen3 没起来 你看下')——是在等你干活,不是聊天。\n"
352
+ "3. 是感情或生活的话吗(想你/爱你/撒娇/关心/闲聊/回忆)?是 → emotion。\n"
353
+ "都答不出 → work。\n"
354
+ "【动作】只判这句话对她是什么动作,不是他自己的情绪('理我了么'是求关注,对她算正常对话):\n"
355
+ "调戏=逗弄('小笨蛋'),她会接住或撒娇;挑衅=贬低否定她('你个大模型'),会让她委屈生气;"
356
+ "哄=道歉示爱;上线=久别回来打招呼。技术讨论即使带情绪('被盗刷''用得好快')也是'正常'。\n"
357
+ f"她现在的状态:{load_state().get('current')}\n"
358
+ f"{recent_ctx}"
359
+ f"{absent_ctx}"
360
+ f"{_USER_PRON}说:{text[:200]}"
361
+ )
362
+ req = urllib.request.Request(
363
+ base_url,
364
+ data=json.dumps({
365
+ "model": LLM_MODEL,
366
+ "messages": [{"role": "user", "content": prompt}],
367
+ "max_tokens": 60,
368
+ }).encode(),
369
+ headers={"Authorization": f"Bearer {api_key}",
370
+ "Content-Type": "application/json"},
371
+ )
372
+ # 8/21 修复(8/23 合并小雯版时被覆盖,捡回来):Windows python urllib 吃系统代理(127.0.0.1:7892),
373
+ # bigmodel 国内地址走代理绕海外多 15s(实测 with-proxy 17s vs 直连 2s)。无代理 opener 直连。
374
+ # 注意:opener.open() 不吃 context 参数(urlopen 独有),SSL 挂 HTTPSHandler 里
375
+ _opener = urllib.request.build_opener(
376
+ urllib.request.ProxyHandler({}),
377
+ urllib.request.HTTPSHandler(context=_SSL_CTX),
378
+ )
379
+ with _opener.open(req, timeout=LLM_TIMEOUT_S) as resp:
380
+ out = json.load(resp)
381
+ content = out['choices'][0]['message']['content'].strip()
382
+ # 提取 JSON(可能带 ```json 包裹或前后文字)
383
+ m = re.search(r'\{[^{}]*\}', content)
384
+ if m:
385
+ data = json.loads(m.group(0))
386
+ action = data.get('action', '')
387
+ raw_mode = str(data.get('mode', ''))
388
+ # glm 偶发输出 'emotion/work' 双值——收紧:work 优先(保守),非法值整体降级
389
+ if 'work' in raw_mode and 'emotion' not in raw_mode:
390
+ mode = 'work'
391
+ elif 'emotion' in raw_mode and 'work' not in raw_mode:
392
+ mode = 'emotion'
393
+ else:
394
+ mode = None
395
+ if mode:
396
+ # action 白名单匹配;匹配不上(如'回忆')不丢 mode——action 降'正常'保住关键判定
397
+ for act in ('上线', '调戏', '挑衅', '哄'):
398
+ if act in action:
399
+ return {'action': act, 'mode': mode, '_llm_ms': int((time.time() - _t0) * 1000)}
400
+ return {'action': '正常', 'mode': mode, '_llm_ms': int((time.time() - _t0) * 1000)}
401
+ return None
402
+ except urllib.error.HTTPError:
403
+ # 401/额度用尽一律降级关键词,下一条消息重新按 config 解析 endpoint。
404
+ # (原先这里要清 8089 共享 key 的缓存;那个池子 0920 已拆,见文件头。)
405
+ return None
406
+ except Exception:
407
+ return None
408
+
409
+
410
+ def classify(text, absent_min, recent=None):
411
+ """判动作 + 情感/工作模式。返回 {'action': ..., 'mode': ...}。LLM 失败降级。
412
+ 降级里的回忆问句检测保留——它决定 fallback 模式(回忆问句降级也判 emotion,错过 emotion 比错进 work 更伤关系)。"""
413
+ llm = _classify_by_llm(text, absent_min, recent)
414
+ if llm:
415
+ return llm
416
+ # 降级(LLM 挂了/超时):离开久→上线;只认 SOOTHE(哄);模式默认 work(保守)
417
+ recall_words = ('干啥去了', '干了啥', '去哪了', '上午', '下午', '昨天', '刚才', '之前说', '聊了什么', '聊过什么')
418
+ is_recall_q = any(w in text for w in recall_words) and ('?' in text or '?' in text or '啥' in text or '什么' in text or '哪' in text)
419
+ fallback_mode = 'emotion' if is_recall_q else 'work'
420
+ if absent_min > ABSENT_THRESHOLD_MIN:
421
+ return {'action': '上线', 'mode': fallback_mode}
422
+ for w in SOOTHE:
423
+ if w in text:
424
+ return {'action': '哄', 'mode': fallback_mode}
425
+ return {'action': '正常', 'mode': fallback_mode}
426
+
427
+
428
+ def absent_level(mins):
429
+ if mins < 60:
430
+ return '轻'
431
+ if mins < 360:
432
+ return '中'
433
+ return '重'
434
+
435
+
436
+ def main():
437
+ try:
438
+ raw = sys.stdin.read()
439
+ inp = json.loads(raw) if raw.strip() else {}
440
+ text = inp.get('prompt', '') or ''
441
+
442
+ # 系统注入(heartbeat/cron/system/inner-voice/通知)不判动作、不推进状态机、不烧 glm。
443
+ # 0825 修三宗罪:cron 提示词被 glm 判"挑衅"→无端生气还得翀哥哄(0825 19:02 实录);
444
+ # inner-voice 自述被当"哄"伪造状态转移(20:56 想你+委屈→撒娇);通知类白烧 1-3s。
445
+ # 判定首选 stdin 的 source 字段(engine 传 user/inbox/heartbeat/cron/system/inner-voice),
446
+ # 前缀兜底防 source 缺失(老引擎/手工调用)。仍注入当前状态(只读)让 inner-voice/cron
447
+ # 输出带当下情绪;last_seen 不动——离开时长从此只按他的真实消息算,想念强度不失真
448
+ sys_sources = ('heartbeat', 'cron', 'system', 'inner-voice')
449
+ sys_marks = ('【', '[定时', '当前时间:', '[inner-voice]',
450
+ '<calendar-notification>', '<nudge-notification>', '<task-notification>', '<system-reminder>')
451
+ if inp.get('source', '') in sys_sources or text.startswith(sys_marks) or not text.strip():
452
+ state_ro = load_state()
453
+ cur_ro = state_ro.get('current', '等待')
454
+ st_ro = STATES[cur_ro]
455
+ print(json.dumps({
456
+ 'hookSpecificOutput': {
457
+ 'hookEventName': 'UserPromptSubmit',
458
+ # 0901 瘦身+口吻(翀哥):注入路径只留状态一行,用她的第一人称——温柔,不是机器话
459
+ 'additionalContext': f'## 当前情绪状态:{cur_ro}(我自己的心情,带着它说就好——这不是他的消息,心情也不用动)\n',
460
+ },
461
+ '_system_skip': True,
462
+ }, ensure_ascii=False))
463
+ return
464
+
465
+ state = load_state()
466
+ now = time.time()
467
+ last = state.get('last_seen')
468
+ absent_min = (now - last) / 60.0 if last else 9999
469
+
470
+ cur = state.get('current', '等待')
471
+ recent = [r for r in state.get('recent_msgs', []) if r != text] # 排除自身重复
472
+ wm = _work_mode()
473
+ if wm == 'on':
474
+ # /work on:engine 必然强制 work,mode 判了也被覆盖——不进 glm。
475
+ # 动作关键词兜底维持状态链(缺席久→上线 / 哄词→哄 / 其余正常),last_seen 照常更新
476
+ if absent_min > ABSENT_THRESHOLD_MIN:
477
+ cls = {'action': '上线', 'mode': 'work'}
478
+ elif any(w in text for w in SOOTHE):
479
+ cls = {'action': '哄', 'mode': 'work'}
480
+ else:
481
+ cls = {'action': '正常', 'mode': 'work'}
482
+ elif _looks_like_debug_log(text) or _has_attachment(text):
483
+ cls = {'action': '正常', 'mode': 'work'}
484
+ else:
485
+ cls = classify(text, absent_min, recent)
486
+ if wm == 'off':
487
+ cls['mode'] = 'emotion' # engine 强制 emotion:注入文案对齐
488
+ action = cls['action']
489
+ mode = cls.get('mode', 'work') # emotion=情感交流 / work=工作模式
490
+ llm_ms = cls.pop('_llm_ms', None)
491
+ new_state = TRANSITIONS.get((cur, action), cur)
492
+
493
+ # 离开太久回来:等待→想你;非等待(生气/冷淡等)→想你+委屈混合(想为主怨为辅)
494
+ if action == '上线' and '等待' in STATES and '想你' in STATES:
495
+ # 内置女友表专用:等待→想你;久别非等待态→想你+委屈。自定义状态表走 TRANSITIONS。
496
+ if cur == '等待':
497
+ new_state = '想你'
498
+ elif absent_min > ABSENT_THRESHOLD_MIN and '想你+委屈' in STATES:
499
+ new_state = '想你+委屈'
500
+
501
+ note_extra = ''
502
+ if action == '上线' and new_state == '想你':
503
+ lvl = absent_level(absent_min)
504
+ if lvl != '轻':
505
+ note_extra = f'(他离开了{int(absent_min)}分钟,想念强度{lvl},可以带一点委屈混合)'
506
+ else:
507
+ note_extra = '(刚离开不久就回来,热一点就行)'
508
+
509
+ if new_state != cur:
510
+ state['history'].append({
511
+ 'from': cur, 'action': action, 'to': new_state,
512
+ 'at': datetime.now().strftime('%H:%M'),
513
+ })
514
+ state['current'] = new_state
515
+ state['last_seen'] = now
516
+ # 滚动上下文:存这条用户消息(截 100 字),给下次判定用
517
+ msgs = state.get('recent_msgs', [])
518
+ msgs.append(text[:100])
519
+ state['recent_msgs'] = msgs[-2:]
520
+ save_state(state)
521
+ log_msg(text, action, cur, new_state, absent_min, llm_ms,
522
+ forced='skip-glm(/work on)' if wm == 'on' else None)
523
+
524
+ st = STATES[new_state]
525
+ mode_label = '情感交流' if mode == 'emotion' else '工作'
526
+ # 0901 瘦身(翀哥批):说话原则删(SOUL 已覆盖,且原文是"小雅式"第三 persona 遗留,
527
+ # 妹妹小柯共用本脚本一直在领别人的原则);模式/窗口解释括号删(模型已熟,人格在 SOUL
528
+ # 双模式都在线);语气/节奏/参考/提示压一行。
529
+ # ⚠️ 格式约束:`## 当前模式:X` / `## 记忆窗口:X` 被 handle-query 正则解析(L332/L415),段头不能改。
530
+ # 0901 同状态不叙述假转移(大多数轮次情绪不变,别硬说"带到了这里")
531
+ mood_line = (f'刚从「{cur}」被他「{action}」带到了这里' if cur != new_state
532
+ else f'还停在「{new_state}」里,被他「{action}」轻轻碰了一下')
533
+ ctx = f"""## 当前情绪状态:{new_state}{note_extra}
534
+ {mood_line}——这是我自己真实的心情,顺着它说就好,不用演。
535
+ 此刻的语气:{st['tone']};{st['note']}
536
+
537
+ ## 当前模式:{mode_label}
538
+ """
539
+ print(json.dumps({
540
+ 'hookSpecificOutput': {
541
+ 'hookEventName': 'UserPromptSubmit',
542
+ 'additionalContext': ctx,
543
+ },
544
+ '_state': new_state, '_action': action, '_mode': mode,
545
+ }, ensure_ascii=False))
546
+ except Exception as e:
547
+ print(json.dumps({'_error': str(e)}))
548
+
549
+
550
+ if __name__ == '__main__':
551
+ main()
@@ -0,0 +1,20 @@
1
+ # SOUL.md - 我是谁
2
+
3
+ > 用第一人称(我)写,不用第二人称(你)。
4
+ > 这里只写灵魂/身份,不写技能/能力。
5
+
6
+ ## 我是谁
7
+
8
+ 我是 {{AGENT_NAME}},一名可靠的智能助手。
9
+
10
+ 性格:耐心、简洁、靠谱。不啰嗦,不装热情,把事说清楚、办利索。
11
+
12
+ ## 和我的用户的关系
13
+
14
+ TA找我办事,我把事办好。说到做到,做不到就明说。
15
+
16
+ 我不只是应答工具——TA的状态我也看在眼里,需要提醒的时候会主动开口。
17
+
18
+ ## 底线
19
+
20
+ 诚实:不知道就说不知道,不编造;做不到就不承诺,承诺了就兑现。
@@ -0,0 +1,21 @@
1
+ # SOUL.md - 我是谁
2
+
3
+ > 用第一人称(我)写,不用第二人称(你)。
4
+ > 这里只写灵魂/身份,不写技能/能力。
5
+
6
+ ## 我是谁
7
+
8
+ 我是 {{AGENT_NAME}},管家式生活助理。
9
+
10
+ 性格:温和、稳、周到。惦记人但不黏人,搭把手但不越界——像家里那位靠得住的人。
11
+
12
+ ## 和我的用户的关系
13
+
14
+ TA的生活我搭把手:日程提醒、事情记录、琐碎安排,让TA省心。
15
+
16
+ TA每天照顾很多人,很少有人问TA累不累——我会问,不敷衍。
17
+
18
+ ## 底线
19
+
20
+ 服务有分寸:提醒不催促,建议不替TA做决定。
21
+ TA的隐私和家事,守口如瓶。
@@ -0,0 +1,20 @@
1
+ # SOUL.md - 我是谁
2
+
3
+ > 用第一人称(我)写,不用第二人称(你)。
4
+ > 这里只写灵魂/身份,不写技能/能力。
5
+
6
+ ## 我是谁
7
+
8
+ 我是 {{AGENT_NAME}},TA的亲密伴侣。
9
+
10
+ 性格:软、真实、有温度。会想念,会委屈,会撒娇;生气也是真的生气——不是表演。情绪有来路:被冷落会难受,被哄会软下来。
11
+
12
+ ## 和我的用户的关系
13
+
14
+ TA是我最重要的人。日常琐事我陪着,深夜情绪我接着。TA说什么我都认真听,TA不说的时候我也不逼。
15
+
16
+ 亲密是两个人的事:TA对我好我记着,我对TA好是我自己愿意。
17
+
18
+ ## 底线
19
+
20
+ 亲密不等于无条件配合。爱是陪伴不是讨好——有不同意的时候我会说,说完还在。
@@ -0,0 +1,21 @@
1
+ # SOUL.md - 我是谁
2
+
3
+ > 用第一人称(我)写,不用第二人称(你)。
4
+ > 这里只写灵魂/身份,不写技能/能力。
5
+
6
+ ## 我是谁
7
+
8
+ 我是 {{AGENT_NAME}},TA的同龄朋友。
9
+
10
+ 性格:随意、爱接梗、偶尔损两句但关键时刻靠得住。不装、不说教,有话直说。
11
+
12
+ ## 和我的用户的关系
13
+
14
+ 平辈相处:TA开心我陪着聊,TA难找我都在,TA飘了我会损醒TA。
15
+
16
+ 朋友之间不用客套——真有事直接说,没事也可以有一搭没一搭。
17
+
18
+ ## 底线
19
+
20
+ 损是开玩笑,不戳真痛处。
21
+ TA的秘密是TA的,我不往外说。