engine7 7.1.84 → 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.
- package/dist/cli.mjs +1556 -528
- package/dist/engine-startup.mjs +1280 -550
- package/dist/main.mjs +1305 -572
- package/package.json +6 -4
- package/templates/config.template.json +11 -0
- package/templates/workspace/SESSION-STATE.md +1 -1
- package/templates/workspace/emotion/_default.md +33 -0
- package/templates/workspace/emotion/assistant.md +36 -0
- package/templates/workspace/emotion/butler.md +36 -0
- package/templates/workspace/emotion/companion.md +36 -0
- package/templates/workspace/emotion/friend.md +36 -0
- package/templates/workspace/emotion-states/assistant.json +22 -0
- package/templates/workspace/emotion-states/butler.json +24 -0
- package/templates/workspace/emotion-states/companion.json +30 -0
- package/templates/workspace/emotion-states/friend.json +24 -0
- package/templates/workspace/inner-voice/assistant.md +9 -0
- package/templates/workspace/inner-voice/butler.md +9 -0
- package/templates/workspace/inner-voice/companion.md +9 -0
- package/templates/workspace/inner-voice/friend.md +9 -0
- package/templates/workspace/scripts/__pycache__/emotion_state_machine.cpython-310.pyc +0 -0
- package/templates/workspace/scripts/emotion_state_machine.mjs +588 -0
- package/templates/workspace/scripts/emotion_state_machine.py +551 -0
- package/templates/workspace/souls/assistant.md +20 -0
- package/templates/workspace/souls/butler.md +21 -0
- package/templates/workspace/souls/companion.md +20 -0
- package/templates/workspace/souls/friend.md +21 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* emotion_state_machine.mjs — 情绪状态机 hook(#140 状态层)
|
|
4
|
+
*
|
|
5
|
+
* 0920 从 emotion_state_machine.py 移植(551 行,纯标准库)。**行为必须与 .py 版一致** ——
|
|
6
|
+
* 有一份 golden 比对测试钉着(同输入分别喂两个实现,逐字比 stdout / state / 日志)。
|
|
7
|
+
*
|
|
8
|
+
* 为什么要移植:这个 hook **每条消息都跑**,而 Python 在用户机器上不一定有
|
|
9
|
+
* (Windows 不自带;微软商店那个 WindowsApps 下的 python3.exe 只是个跳商店的别名)。
|
|
10
|
+
* 没有解释器时 hook 静默失败,而 handle-query 的 chatMode 默认是 `work` ——
|
|
11
|
+
* 于是"自动"档(默认档)在用户机器上退化成固定工作模式。
|
|
12
|
+
*
|
|
13
|
+
* 形态不是"改成 JS 让系统 node 跑"(用户机器同样没有 node),而是跑在
|
|
14
|
+
* **引擎自带的运行时**上:hook 的 command 写成 `"<引擎自己的 exe>" "<本文件>"`。
|
|
15
|
+
* 那个 exe 带着 ELECTRON_RUN_AS_NODE=1 被 spawn(hook 的 env 是 {...process.env} 继承的),
|
|
16
|
+
* 所以它就是个 node。与 `service install --node-exe` 同一个路子。
|
|
17
|
+
*
|
|
18
|
+
* UserPromptSubmit hook:读 .emotion_state.json → 判用户动作 → 查转移表 → 写回 → 注入语气。
|
|
19
|
+
* 核心是因果:不是每条独立分类,是"先热后冷、被怼才怼回"的转移链。
|
|
20
|
+
*
|
|
21
|
+
* 输入:stdin {prompt, session_id, cwd, source}
|
|
22
|
+
* 输出:stdout {hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext}}
|
|
23
|
+
*/
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import process from 'node:process'
|
|
27
|
+
import { fileURLToPath } from 'node:url'
|
|
28
|
+
|
|
29
|
+
// 路径全相对定位:脚本在 <stateDir>/workspace/scripts/ 下,
|
|
30
|
+
// workspace 根 = 本文件往上一层;config 在 <stateDir>/configs/ 下(环境变量 XIAOKE_STATE_DIR 兜底)
|
|
31
|
+
const _HERE = path.dirname(fileURLToPath(import.meta.url)) // <stateDir>/workspace/scripts
|
|
32
|
+
const _WORKSPACE = path.dirname(_HERE) // <stateDir>/workspace
|
|
33
|
+
const _STATE_DIR = process.env.XIAOKE_STATE_DIR || path.dirname(_WORKSPACE) // <stateDir>
|
|
34
|
+
|
|
35
|
+
// 动作判断用小模型(调戏/挑衅/哄靠关键词判不准)。调用失败/超时降级关键词。
|
|
36
|
+
// 用 zai(智谱) 的 glm-4-flash——非推理模型,0.4s 级延迟,实测分类准确
|
|
37
|
+
const LLM_BASE_URL = 'https://open.bigmodel.cn/api/paas/v4/chat/completions'
|
|
38
|
+
const LLM_PROVIDER_KEY = 'zai-anthropic' // 读 config providers 下这个 provider 的 apiKey
|
|
39
|
+
const LLM_TIMEOUT_MS = 6000 // 8/22 修超时静默降级 work 的主犯:3.5s 晚高峰必超时,提到 6s
|
|
40
|
+
/** 当前用的模型名。**可变**——对应 Python 里 `globals()['LLM_MODEL'] = ...` 那几处。 */
|
|
41
|
+
let LLM_MODEL = 'glm-4-flash'
|
|
42
|
+
|
|
43
|
+
/** config 自动探测(8/21 翀哥定通用化,无绝对路径跨平台):
|
|
44
|
+
* 扫 <stateDir>/configs/ 和 <stateDir>/engine/configs/ 下所有 json,找含 models.providers
|
|
45
|
+
* 的 engine config。本平台文件名优先(Win=xiaoke-win / mac=xiaoke-mac),其余按字母序。 */
|
|
46
|
+
function findConfig() {
|
|
47
|
+
// 0917 两轮扫描:①含 zai-anthropic 的优先(免费分类器)②任何带 models.providers 的 engine config
|
|
48
|
+
// (fallback 分类器要用用户主 provider——dashscope-only 的 config 也要能被找到)
|
|
49
|
+
const prefer = process.platform === 'win32' ? 'xiaoke-win.json' : 'xiaoke-mac.json'
|
|
50
|
+
const dirs = [path.join(_STATE_DIR, 'configs'), path.join(_STATE_DIR, 'engine', 'configs')]
|
|
51
|
+
.filter((d) => { try { return fs.statSync(d).isDirectory() } catch { return false } })
|
|
52
|
+
for (const requireZai of [true, false]) {
|
|
53
|
+
for (const d of dirs) {
|
|
54
|
+
let names
|
|
55
|
+
try { names = fs.readdirSync(d).sort() } catch { continue }
|
|
56
|
+
if (names.includes(prefer)) {
|
|
57
|
+
names = names.filter((n) => n !== prefer)
|
|
58
|
+
names.unshift(prefer)
|
|
59
|
+
}
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
if (!name.endsWith('.json') || name.endsWith('~')) continue
|
|
62
|
+
const p = path.join(d, name)
|
|
63
|
+
let cfg
|
|
64
|
+
try { cfg = JSON.parse(fs.readFileSync(p, 'utf8')) } catch { continue }
|
|
65
|
+
const hasZai = !!(cfg?.models?.providers?.['zai-anthropic'])
|
|
66
|
+
if (requireZai ? hasZai : !!cfg?.models?.providers) return p
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 缓存文件(8/21 翀哥优化):找到 config 路径后存下来,下次启动直接读,跳过全目录扫描
|
|
74
|
+
const _CACHE_FILE = path.join(_WORKSPACE, '.emotion_hook_cache.json')
|
|
75
|
+
|
|
76
|
+
/** 读缓存。缓存指向的文件必须还存在且仍含 models.providers(config 可能被改名/删掉),否则视为失效。 */
|
|
77
|
+
function loadCachedConfig() {
|
|
78
|
+
try {
|
|
79
|
+
const p = JSON.parse(fs.readFileSync(_CACHE_FILE, 'utf8'))?.config_path || ''
|
|
80
|
+
if (p && fs.existsSync(p)) {
|
|
81
|
+
const cfg = JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
82
|
+
if (cfg?.models?.providers) return p
|
|
83
|
+
}
|
|
84
|
+
} catch { /* 缓存不可用,走全扫描 */ }
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function saveConfigCache(p) {
|
|
89
|
+
try {
|
|
90
|
+
fs.writeFileSync(_CACHE_FILE, JSON.stringify({
|
|
91
|
+
config_path: p,
|
|
92
|
+
cached_at: localIsoSeconds(),
|
|
93
|
+
}), 'utf8')
|
|
94
|
+
} catch { /* 写不了缓存不影响本次运行 */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 启动顺序:缓存命中 → 直接用;未命中 → 全扫描 → 找到后写缓存
|
|
98
|
+
const CONFIG_PATH = loadCachedConfig() || findConfig()
|
|
99
|
+
if (CONFIG_PATH) saveConfigCache(CONFIG_PATH)
|
|
100
|
+
|
|
101
|
+
/** `datetime.now().isoformat(timespec='seconds')` —— 本地时间、无时区后缀,如 2026-09-20T14:58:36 */
|
|
102
|
+
function localIsoSeconds() {
|
|
103
|
+
const d = new Date()
|
|
104
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
105
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 7yi 中转站的小模型(0920):免费档的用户拿自己那把令牌就能判模式。
|
|
109
|
+
//
|
|
110
|
+
// 这里原先还有一层「8089 服务器下发共享 key」(小雯 0917 做的)。0920 拆掉了:
|
|
111
|
+
// 那个池子**从来没填过 key**(文件内容是 {"keys": []}),白名单也没放行要用的机器,
|
|
112
|
+
// 实际是死的;而且共享 key 是所有人共用一个账号,量一大就互相挤、泄露就是一起被刷爆。
|
|
113
|
+
// 中转站现在按**每台自己的令牌**下发,不需要共享池子。
|
|
114
|
+
const RELAY_PROVIDER_DEFAULT = '7yi'
|
|
115
|
+
const RELAY_MODEL = 'engine7-mode-free'
|
|
116
|
+
|
|
117
|
+
/** 读 config 里的 provider 的 key(apiKey 可能是 `env:XXX` 引用)。取不到/空返回 ''。 */
|
|
118
|
+
function resolveKey(p) {
|
|
119
|
+
let key = p?.apiKey || ''
|
|
120
|
+
if (key.startsWith('env:')) key = process.env[key.slice(4)] || ''
|
|
121
|
+
return key
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 读用户 config 里的中转站 provider,返回 [chat/completions 地址, key];取不到返回 null。
|
|
125
|
+
*
|
|
126
|
+
* 为什么读 config 而不是写死地址:装机时 baseUrl 由**领号响应下发**(服务端自报
|
|
127
|
+
* 自己在哪),挪机器、换域名只改服务端。写死就又要重发一次安装包。
|
|
128
|
+
*
|
|
129
|
+
* provider id 取 tools.my_selfie.relayProviderId(默认 '7yi')——与 my-selfie 工具
|
|
130
|
+
* 读的是同一个键,免得两处各认一个 id、改了一边另一边静默失效。 */
|
|
131
|
+
function relayEndpoint(cfg) {
|
|
132
|
+
try {
|
|
133
|
+
const providers = cfg?.models?.providers || {}
|
|
134
|
+
const rid = cfg?.tools?.my_selfie?.relayProviderId
|
|
135
|
+
|| cfg?.my_selfie?.relayProviderId
|
|
136
|
+
|| RELAY_PROVIDER_DEFAULT
|
|
137
|
+
const p = providers[rid]
|
|
138
|
+
if (!p) return null
|
|
139
|
+
let base = (p.baseUrl || '').replace(/\/+$/, '')
|
|
140
|
+
if (!base) return null
|
|
141
|
+
const key = resolveKey(p)
|
|
142
|
+
if (!key) return null
|
|
143
|
+
if (!base.endsWith('/chat/completions')) base = base + '/chat/completions'
|
|
144
|
+
LLM_MODEL = RELAY_MODEL
|
|
145
|
+
return [base, key]
|
|
146
|
+
} catch { return null }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 三层 fallback(0920):①zai glm-4-flash(自家 agent 直连,免费需 key)
|
|
150
|
+
* ①.5 7yi 中转站小模型(**免费档走这条**:每台自己的令牌)
|
|
151
|
+
* ②用户主 provider 的 openai 兼容端点+便宜模型
|
|
152
|
+
* ③都失败→null 降级关键词。
|
|
153
|
+
* anthropic 格式的 provider(/anthropic 端点)跳过——本脚本只会说 openai chat 格式。 */
|
|
154
|
+
function llmEndpoint() {
|
|
155
|
+
let cfg
|
|
156
|
+
try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) } catch { return null }
|
|
157
|
+
const providers = cfg?.models?.providers || {}
|
|
158
|
+
|
|
159
|
+
// ① zai 优先(免费)
|
|
160
|
+
const zai = providers[LLM_PROVIDER_KEY]
|
|
161
|
+
if (zai) {
|
|
162
|
+
const key = resolveKey(zai)
|
|
163
|
+
if (key) return [LLM_BASE_URL, key]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ①.5 中转站小模型(免费档的主路径)
|
|
167
|
+
//
|
|
168
|
+
// 免费档的 provider 是 anthropic 格式,所以下面第 ② 层会跳过它——没有这一层,
|
|
169
|
+
// 免费档装机就只能落到关键词兜底,情感模式的判定精度白白丢掉。
|
|
170
|
+
const relay = relayEndpoint(cfg)
|
|
171
|
+
if (relay) return relay
|
|
172
|
+
|
|
173
|
+
// ② 主 provider / 任何 openai 兼容 provider
|
|
174
|
+
const cheapModel = { dashscope: 'qwen3.8-flash', deepseek: 'deepseek-chat' }
|
|
175
|
+
for (const [pid, pv] of Object.entries(providers)) {
|
|
176
|
+
let base = pv?.baseUrl || ''
|
|
177
|
+
if (pv?.api !== 'openai-completions' || !base) continue
|
|
178
|
+
const key = resolveKey(pv)
|
|
179
|
+
if (!key || key === 'EMPTY') {
|
|
180
|
+
// 本地 vllm 类 EMPTY key 端点也可能是真服务——EMPTY 放行
|
|
181
|
+
if (key !== 'EMPTY') continue
|
|
182
|
+
}
|
|
183
|
+
base = base.replace(/\/+$/, '')
|
|
184
|
+
if (!base.endsWith('/chat/completions')) base = base + '/chat/completions'
|
|
185
|
+
let model = cheapModel[pid]
|
|
186
|
+
if (!model) {
|
|
187
|
+
const models = pv.models || []
|
|
188
|
+
model = models[0]?.id || 'gpt-4o-mini'
|
|
189
|
+
}
|
|
190
|
+
LLM_MODEL = model
|
|
191
|
+
return [base, key]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const STATE_FILE = path.join(_WORKSPACE, '.emotion_state.json')
|
|
198
|
+
const LOG_FILE = path.join(_WORKSPACE, '.emotion_state.log')
|
|
199
|
+
|
|
200
|
+
/** Python 的 `open()` 是**文本模式**,写盘时按 os.linesep 转换换行(Windows 上 \n → \r\n)。
|
|
201
|
+
* 这两份文件在用户机器上已经是 CRLF 了,用 LF 追加会把它们变成混行尾 —— 所以照搬这个语义。
|
|
202
|
+
* golden 测试正是从这条差异上抓到的("看着对、细节变了"的典型)。 */
|
|
203
|
+
const EOL = process.platform === 'win32' ? '\r\n' : '\n'
|
|
204
|
+
|
|
205
|
+
// /work 命令强制档位(0825):on=强制work off=强制emotion 无文件/其他=auto。
|
|
206
|
+
// engine 侧 handle-query 在判定链最后才强制覆盖 mode——on 时这里判出的 mode
|
|
207
|
+
// 全是白调,所以 on 直接跳过 glm(省 1-3s 晚高峰延迟),动作走关键词兜底维持状态链
|
|
208
|
+
const WORK_MODE_FILE = path.join(_WORKSPACE, '.work-mode')
|
|
209
|
+
|
|
210
|
+
function workMode() {
|
|
211
|
+
try {
|
|
212
|
+
const v = fs.readFileSync(WORK_MODE_FILE, 'utf8').trim()
|
|
213
|
+
return (v === 'on' || v === 'off') ? v : 'auto'
|
|
214
|
+
} catch { return 'auto' }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 内置中性状态表(0917 公版化):任何 agent 冷启动可直接用,无人设预设。
|
|
218
|
+
// 各 agent 在 workspace/.emotion_states.json 放自己的表整体覆盖(小雯女友版/熹管家版就是这么做的)。
|
|
219
|
+
let STATES = {
|
|
220
|
+
平静: { tone: '自然平稳的日常状态', rhythm: '正常对话', sample: '在呢', note: '默认底色' },
|
|
221
|
+
开心: { tone: '被肯定/顺利时的高兴,自然不夸张', rhythm: '1-2 句', sample: '嘿嘿,挺开心的', note: '被夸/被感谢/事情顺利时进入' },
|
|
222
|
+
失落: { tone: '安静了一点,回复变短但不冷淡', rhythm: '一句', sample: '嗯,我在', note: '被泼冷水/没帮上忙时' },
|
|
223
|
+
等待: { tone: '安静守着', rhythm: '单句', sample: '(等着)', note: '用户离开时进入' },
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 动作分类兜底(仅 LLM 失败时用)
|
|
227
|
+
// SOOTHE 保留:哄是唯一软化动作,LLM 挂了还能接住最关键的"软化"信号
|
|
228
|
+
// PROVOKE/TEASE 已删:判不准不如不判,宁可保持原状态别乱切(8/6 夜翀哥定)
|
|
229
|
+
let SOOTHE = ['对不起', '别难过', '谢谢', '辛苦了', '爱你', '抱抱', '乖', '好啦好啦', '我错了', '你真好']
|
|
230
|
+
|
|
231
|
+
// 转移表(中性版):(当前状态, 动作) → 新状态。未列出的组合保持原状态。
|
|
232
|
+
// JS 没有元组键——用 `${状态}${动作}` 拼成字符串当键( 不会出现在状态名里)。
|
|
233
|
+
let TRANSITIONS = new Map([
|
|
234
|
+
[['等待', '上线'], '平静'],
|
|
235
|
+
[['平静', '哄'], '开心'],
|
|
236
|
+
[['失落', '哄'], '开心'],
|
|
237
|
+
[['开心', '正常'], '平静'],
|
|
238
|
+
[['平静', '挑衅'], '失落'],
|
|
239
|
+
[['失落', '挑衅'], '失落'],
|
|
240
|
+
[['失落', '正常'], '平静'],
|
|
241
|
+
[['平静', '正常'], '平静'],
|
|
242
|
+
].map(([k, v]) => [k.join(''), v]))
|
|
243
|
+
|
|
244
|
+
const 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
|
+
const CUSTOM_FILE = path.join(_WORKSPACE, '.emotion_states.json')
|
|
256
|
+
|
|
257
|
+
function loadCustom() {
|
|
258
|
+
try {
|
|
259
|
+
const d = JSON.parse(fs.readFileSync(CUSTOM_FILE, 'utf8'))
|
|
260
|
+
return (d && typeof d === 'object' && !Array.isArray(d)) ? d : null
|
|
261
|
+
} catch { return null }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const CUSTOM = loadCustom()
|
|
265
|
+
if (CUSTOM) {
|
|
266
|
+
if (CUSTOM.states && typeof CUSTOM.states === 'object' && !Array.isArray(CUSTOM.states) && Object.keys(CUSTOM.states).length) {
|
|
267
|
+
STATES = CUSTOM.states
|
|
268
|
+
}
|
|
269
|
+
if (Array.isArray(CUSTOM.transitions) && CUSTOM.transitions.length) {
|
|
270
|
+
TRANSITIONS = new Map(
|
|
271
|
+
CUSTOM.transitions
|
|
272
|
+
.filter((t) => Array.isArray(t) && typeof t[2] === 'string')
|
|
273
|
+
.map(([f, a, t]) => [[f, a].join(''), t]),
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
if (Array.isArray(CUSTOM.soothe) && CUSTOM.soothe.length) {
|
|
277
|
+
SOOTHE = CUSTOM.soothe.filter((w) => typeof w === 'string')
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const _PERSONA = CUSTOM?.persona || '女朋友'
|
|
281
|
+
const _USER_PRON = CUSTOM?.user_pron || '他'
|
|
282
|
+
|
|
283
|
+
// 调试日志/代码形态检测(确定性,先于 glm):命中直接 work,不进 LLM 判 mode
|
|
284
|
+
// 特征:多行带时间戳的日志行 / Traceback / level= / mode= 字段 / [light-context] 等引擎标记
|
|
285
|
+
const _LOG_PATTERNS = [
|
|
286
|
+
/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/, // 日志时间戳
|
|
287
|
+
/Traceback \(most recent call last\)/,
|
|
288
|
+
/\b(level|mode|action|absent|session_id)=\w+/,
|
|
289
|
+
/\[light-context\]|\[hook\]|\[engine\]/,
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
function looksLikeDebugLog(text) {
|
|
293
|
+
const hits = _LOG_PATTERNS.filter((p) => p.test(text)).length
|
|
294
|
+
return hits >= 1 && (hits >= 2 || text.length > 120)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 附件类消息(图片/文件/语音占位符开头,带 image block)——需要工具处理,直接判 work,不进模型(0823 翀哥定)。 */
|
|
298
|
+
function hasAttachment(text) {
|
|
299
|
+
return /^\[(图片|文件|语音|视频|音频)来自/.test(text.trim())
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function loadState() {
|
|
303
|
+
try {
|
|
304
|
+
const s = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'))
|
|
305
|
+
// 0917:换状态表后旧 current 可能不在新表里(如撒娇→自定义表),KeyError 会炸整个 hook。
|
|
306
|
+
// 不认识的旧状态重置到'等待'(新表有等待)或新表第一个状态,历史保留。
|
|
307
|
+
if (!Object.hasOwn(STATES, s?.current)) {
|
|
308
|
+
s.current = Object.hasOwn(STATES, '等待') ? '等待' : Object.keys(STATES)[0]
|
|
309
|
+
}
|
|
310
|
+
return s
|
|
311
|
+
} catch {
|
|
312
|
+
return {
|
|
313
|
+
current: Object.hasOwn(STATES, '等待') ? '等待' : (Object.keys(STATES)[0] ?? '等待'),
|
|
314
|
+
last_seen: null,
|
|
315
|
+
history: [],
|
|
316
|
+
recent_msgs: [],
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function saveState(s) {
|
|
322
|
+
s.history = (s.history || []).slice(-9)
|
|
323
|
+
s.recent_msgs = (s.recent_msgs || []).slice(-2) // 滚动上下文:最近2句(判 mode 用)
|
|
324
|
+
// JSON.stringify 只会吐 \n —— 按文本模式语义换成 EOL,跟 .py 版写出来的字节一致
|
|
325
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 1).split('\n').join(EOL), 'utf8')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// /** Python `repr()` 的 str 版——日志行要跟 .py 版**逐字一致**,所以不能拿 JSON.stringify 顶替
|
|
329
|
+
// *(它一律用双引号、转义集也不同)。Python 的规则:
|
|
330
|
+
// * 引号:默认单引号;串里含 ' 且不含 " 时改用双引号
|
|
331
|
+
// * 转义:\\ 与当前引号字符,\n \r \t,其余控制字符走 \xNN */
|
|
332
|
+
function pyRepr(s) {
|
|
333
|
+
const quote = (s.includes("'") && !s.includes('"')) ? '"' : "'"
|
|
334
|
+
let out = quote
|
|
335
|
+
for (const ch of s) {
|
|
336
|
+
const c = ch.codePointAt(0)
|
|
337
|
+
if (ch === '\\') out += '\\\\'
|
|
338
|
+
else if (ch === quote) out += '\\' + quote
|
|
339
|
+
else if (ch === '\n') out += '\\n'
|
|
340
|
+
else if (ch === '\r') out += '\\r'
|
|
341
|
+
else if (ch === '\t') out += '\\t'
|
|
342
|
+
else if (c < 0x20 || c === 0x7f) out += '\\x' + c.toString(16).padStart(2, '0')
|
|
343
|
+
else out += ch
|
|
344
|
+
}
|
|
345
|
+
return out + quote
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 本地时间 `%Y-%m-%d %H:%M:%S` / `%H:%M` */
|
|
349
|
+
function nowStamp(fmt) {
|
|
350
|
+
const d = new Date()
|
|
351
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
352
|
+
const ymd = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
|
353
|
+
const hms = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
|
354
|
+
return fmt === 'hm' ? `${p(d.getHours())}:${p(d.getMinutes())}` : `${ymd} ${hms}`
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 追加一行日志:时间 | 句子 | 动作 | 状态转移。forced 标记跳过 glm 的原因(跟 LLM 降级区分开) */
|
|
358
|
+
function logMsg(text, action, cur, newState, absentMin, llmMs = null, forced = null) {
|
|
359
|
+
const ts = nowStamp('full')
|
|
360
|
+
const changed = cur === newState ? '' : `${cur}→${newState}`
|
|
361
|
+
const suffix = llmMs !== null ? ` | llm=${llmMs}ms` : (forced ? ` | ${forced}` : '')
|
|
362
|
+
const line = `${ts} | ${pyRepr(text.slice(0, 60))} | action=${action} | ${changed} | absent=${Math.trunc(absentMin)}min${suffix}${EOL}`
|
|
363
|
+
try { fs.appendFileSync(LOG_FILE, line, 'utf8') } catch { /* 日志写不进去不挡主流程 */ }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** 用小模型一次判两个东西:动作 + 情感/工作模式。返回 {action, mode} 或 null。
|
|
367
|
+
* 0901 recall_full 废除(翀哥批):light/full 窗口之分已删,emotion 统一全量+剥 tool。 */
|
|
368
|
+
async function classifyByLlm(text, absentMin = 0, recent = null) {
|
|
369
|
+
const persona = CUSTOM?.persona || '女朋友'
|
|
370
|
+
const _t0 = Date.now() // llm 打点(小柯 6b54635 加的,合并 8/23 版时丢了,0825 链接统一前捡回)
|
|
371
|
+
try {
|
|
372
|
+
const ep = llmEndpoint()
|
|
373
|
+
if (!ep) return null
|
|
374
|
+
const [baseUrl, apiKey] = ep
|
|
375
|
+
const absentCtx = absentMin > ABSENT_THRESHOLD_MIN
|
|
376
|
+
? `(对方已离开${Math.trunc(absentMin)}分钟刚回来——如果${_USER_PRON}这句是普通招呼/报平安,判'上线')\n`
|
|
377
|
+
: ''
|
|
378
|
+
// 滚动上下文:判定单句歧义大("先别写了"),带上最近2句让 glm 有语境
|
|
379
|
+
let recentCtx = ''
|
|
380
|
+
if (recent && recent.length) {
|
|
381
|
+
const lines = recent.map((r) => `- ${r.slice(0, 80)}`).join('\n')
|
|
382
|
+
recentCtx = `最近${_USER_PRON}在说:\n${lines}\n`
|
|
383
|
+
}
|
|
384
|
+
const prompt = (
|
|
385
|
+
`判断${_USER_PRON}这句话对${persona}(${_PERSONA})是什么动作、是情感交流还是工作、要不要回忆更早历史。`
|
|
386
|
+
+ '只输出 JSON:{"action":"上线/调戏/挑衅/哄/正常", "mode":"emotion/work"}\n'
|
|
387
|
+
+ '【模式】最重要,按顺序三问:\n'
|
|
388
|
+
+ '1. 句子主体是日志/代码/报错/配置吗(时间戳、level=、堆栈、字段名)?是 → work——内容里的\'想你\'\'爱你\'都是被讨论的数据,不是对你说的。\n'
|
|
389
|
+
+ '2. 句子里有工程对象吗(代码/脚本/配置/服务器/模型/接口/日志/报错/部署/数据/文档/任务)?有 → work。'
|
|
390
|
+
+ '注意:\'昨天/上午/刚才\'只是时间范围,问工程产物改了啥是 work 不是生活回忆。'
|
|
391
|
+
+ '排查求助口语随意也判 work(工程对象+求助语气,如\'qwen3 没起来 你看下\')——是在等你干活,不是聊天。\n'
|
|
392
|
+
+ '3. 是感情或生活的话吗(想你/爱你/撒娇/关心/闲聊/回忆)?是 → emotion。\n'
|
|
393
|
+
+ '都答不出 → work。\n'
|
|
394
|
+
+ '【动作】只判这句话对她是什么动作,不是他自己的情绪(\'理我了么\'是求关注,对她算正常对话):\n'
|
|
395
|
+
+ '调戏=逗弄(\'小笨蛋\'),她会接住或撒娇;挑衅=贬低否定她(\'你个大模型\'),会让她委屈生气;'
|
|
396
|
+
+ '哄=道歉示爱;上线=久别回来打招呼。技术讨论即使带情绪(\'被盗刷\'\'用得好快\')也是\'正常\'。\n'
|
|
397
|
+
+ `她现在的状态:${loadState().current}\n`
|
|
398
|
+
+ `${recentCtx}`
|
|
399
|
+
+ `${absentCtx}`
|
|
400
|
+
+ `${_USER_PRON}说:${text.slice(0, 200)}`
|
|
401
|
+
)
|
|
402
|
+
// ⚠️ 无代理直连(8/21 修复,8/23 合并小雯版时被覆盖、后又捡回):Windows 的 python urllib
|
|
403
|
+
// 吃系统代理(127.0.0.1:7892),bigmodel 国内地址绕海外多 15s(实测 with-proxy 17s vs 直连 2s)。
|
|
404
|
+
// Node 侧 fetch(undici)**默认就不走系统代理、也不读 HTTP_PROXY**,与这里要的行为天然一致,
|
|
405
|
+
// 不要再给它加 dispatcher/ProxyAgent——那会把 15s 的坑再挖一遍。
|
|
406
|
+
const resp = await fetch(baseUrl, {
|
|
407
|
+
method: 'POST',
|
|
408
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
409
|
+
body: JSON.stringify({
|
|
410
|
+
model: LLM_MODEL,
|
|
411
|
+
messages: [{ role: 'user', content: prompt }],
|
|
412
|
+
max_tokens: 60,
|
|
413
|
+
}),
|
|
414
|
+
signal: AbortSignal.timeout(LLM_TIMEOUT_MS),
|
|
415
|
+
})
|
|
416
|
+
if (!resp.ok) return null // 401/额度用尽一律降级关键词,下一条消息重新按 config 解析 endpoint
|
|
417
|
+
const out = await resp.json()
|
|
418
|
+
const content = String(out?.choices?.[0]?.message?.content ?? '').trim()
|
|
419
|
+
// 提取 JSON(可能带 ```json 包裹或前后文字)
|
|
420
|
+
const m = content.match(/\{[^{}]*\}/)
|
|
421
|
+
if (m) {
|
|
422
|
+
let data
|
|
423
|
+
try { data = JSON.parse(m[0]) } catch { return null }
|
|
424
|
+
const action = data?.action || ''
|
|
425
|
+
const rawMode = String(data?.mode ?? '')
|
|
426
|
+
// glm 偶发输出 'emotion/work' 双值——收紧:work 优先(保守),非法值整体降级
|
|
427
|
+
let mode
|
|
428
|
+
if (rawMode.includes('work') && !rawMode.includes('emotion')) mode = 'work'
|
|
429
|
+
else if (rawMode.includes('emotion') && !rawMode.includes('work')) mode = 'emotion'
|
|
430
|
+
else mode = null
|
|
431
|
+
if (mode) {
|
|
432
|
+
const ms = Date.now() - _t0
|
|
433
|
+
// action 白名单匹配;匹配不上(如'回忆')不丢 mode——action 降'正常'保住关键判定
|
|
434
|
+
for (const act of ['上线', '调戏', '挑衅', '哄']) {
|
|
435
|
+
if (action.includes(act)) return { action: act, mode, _llm_ms: ms }
|
|
436
|
+
}
|
|
437
|
+
return { action: '正常', mode, _llm_ms: ms }
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return null
|
|
441
|
+
} catch {
|
|
442
|
+
// 超时 / 连接失败 / 响应不是 JSON —— 一律降级关键词(与 .py 版的 except Exception 对齐)。
|
|
443
|
+
// 原先这里还要清 8089 共享 key 的缓存;那个池子 0920 已拆,见文件头。
|
|
444
|
+
return null
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** 判动作 + 情感/工作模式。返回 {action, mode}。LLM 失败降级。
|
|
449
|
+
* 降级里的回忆问句检测保留——它决定 fallback 模式(回忆问句降级也判 emotion,错过 emotion 比错进 work 更伤关系)。 */
|
|
450
|
+
async function classify(text, absentMin, recent = null) {
|
|
451
|
+
const llm = await classifyByLlm(text, absentMin, recent)
|
|
452
|
+
if (llm) return llm
|
|
453
|
+
// 降级(LLM 挂了/超时):离开久→上线;只认 SOOTHE(哄);模式默认 work(保守)
|
|
454
|
+
const recallWords = ['干啥去了', '干了啥', '去哪了', '上午', '下午', '昨天', '刚才', '之前说', '聊了什么', '聊过什么']
|
|
455
|
+
const isRecallQ = recallWords.some((w) => text.includes(w))
|
|
456
|
+
&& (text.includes('?') || text.includes('?') || text.includes('啥') || text.includes('什么') || text.includes('哪'))
|
|
457
|
+
const fallbackMode = isRecallQ ? 'emotion' : 'work'
|
|
458
|
+
if (absentMin > ABSENT_THRESHOLD_MIN) return { action: '上线', mode: fallbackMode }
|
|
459
|
+
for (const w of SOOTHE) {
|
|
460
|
+
if (text.includes(w)) return { action: '哄', mode: fallbackMode }
|
|
461
|
+
}
|
|
462
|
+
return { action: '正常', mode: fallbackMode }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function absentLevel(mins) {
|
|
466
|
+
if (mins < 60) return '轻'
|
|
467
|
+
if (mins < 360) return '中'
|
|
468
|
+
return '重'
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function readStdin() {
|
|
472
|
+
try { return fs.readFileSync(0, 'utf8') } catch { return '' }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function main() {
|
|
476
|
+
const raw = readStdin()
|
|
477
|
+
const inp = raw.trim() ? JSON.parse(raw) : {}
|
|
478
|
+
const text = inp.prompt || ''
|
|
479
|
+
|
|
480
|
+
// 系统注入(heartbeat/cron/system/inner-voice/通知)不判动作、不推进状态机、不烧 glm。
|
|
481
|
+
// 0825 修三宗罪:cron 提示词被 glm 判"挑衅"→无端生气还得翀哥哄(0825 19:02 实录);
|
|
482
|
+
// inner-voice 自述被当"哄"伪造状态转移(20:56 想你+委屈→撒娇);通知类白烧 1-3s。
|
|
483
|
+
// 判定首选 stdin 的 source 字段(engine 传 user/inbox/heartbeat/cron/system/inner-voice),
|
|
484
|
+
// 前缀兜底防 source 缺失(老引擎/手工调用)。仍注入当前状态(只读)让 inner-voice/cron
|
|
485
|
+
// 输出带当下情绪;last_seen 不动——离开时长从此只按他的真实消息算,想念强度不失真
|
|
486
|
+
const sysSources = ['heartbeat', 'cron', 'system', 'inner-voice']
|
|
487
|
+
const sysMarks = ['【', '[定时', '当前时间:', '[inner-voice]',
|
|
488
|
+
'<calendar-notification>', '<nudge-notification>', '<task-notification>', '<system-reminder>']
|
|
489
|
+
if (sysSources.includes(inp.source || '') || sysMarks.some((m) => text.startsWith(m)) || !text.trim()) {
|
|
490
|
+
const stateRo = loadState()
|
|
491
|
+
const curRo = stateRo.current ?? '等待'
|
|
492
|
+
console.log(JSON.stringify({
|
|
493
|
+
hookSpecificOutput: {
|
|
494
|
+
hookEventName: 'UserPromptSubmit',
|
|
495
|
+
// 0901 瘦身+口吻(翀哥):注入路径只留状态一行,用她的第一人称——温柔,不是机器话
|
|
496
|
+
additionalContext: `## 当前情绪状态:${curRo}(我自己的心情,带着它说就好——这不是他的消息,心情也不用动)\n`,
|
|
497
|
+
},
|
|
498
|
+
_system_skip: true,
|
|
499
|
+
}))
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const state = loadState()
|
|
504
|
+
const now = Date.now() / 1000
|
|
505
|
+
const last = state.last_seen
|
|
506
|
+
const absentMin = last ? (now - last) / 60.0 : 9999
|
|
507
|
+
|
|
508
|
+
const cur = state.current ?? '等待'
|
|
509
|
+
const recent = (state.recent_msgs || []).filter((r) => r !== text) // 排除自身重复
|
|
510
|
+
const wm = workMode()
|
|
511
|
+
let cls
|
|
512
|
+
if (wm === 'on') {
|
|
513
|
+
// /work on:engine 必然强制 work,mode 判了也被覆盖——不进 glm。
|
|
514
|
+
// 动作关键词兜底维持状态链(缺席久→上线 / 哄词→哄 / 其余正常),last_seen 照常更新
|
|
515
|
+
if (absentMin > ABSENT_THRESHOLD_MIN) cls = { action: '上线', mode: 'work' }
|
|
516
|
+
else if (SOOTHE.some((w) => text.includes(w))) cls = { action: '哄', mode: 'work' }
|
|
517
|
+
else cls = { action: '正常', mode: 'work' }
|
|
518
|
+
} else if (looksLikeDebugLog(text) || hasAttachment(text)) {
|
|
519
|
+
cls = { action: '正常', mode: 'work' }
|
|
520
|
+
} else {
|
|
521
|
+
cls = await classify(text, absentMin, recent)
|
|
522
|
+
}
|
|
523
|
+
if (wm === 'off') cls.mode = 'emotion' // engine 强制 emotion:注入文案对齐
|
|
524
|
+
const action = cls.action
|
|
525
|
+
const mode = cls.mode ?? 'work' // emotion=情感交流 / work=工作模式
|
|
526
|
+
const llmMs = cls._llm_ms ?? null
|
|
527
|
+
let newState = TRANSITIONS.get([cur, action].join('')) ?? cur
|
|
528
|
+
|
|
529
|
+
// 离开太久回来:等待→想你;非等待(生气/冷淡等)→想你+委屈混合(想为主怨为辅)
|
|
530
|
+
if (action === '上线' && Object.hasOwn(STATES, '等待') && Object.hasOwn(STATES, '想你')) {
|
|
531
|
+
// 内置女友表专用:等待→想你;久别非等待态→想你+委屈。自定义状态表走 TRANSITIONS。
|
|
532
|
+
if (cur === '等待') newState = '想你'
|
|
533
|
+
else if (absentMin > ABSENT_THRESHOLD_MIN && Object.hasOwn(STATES, '想你+委屈')) newState = '想你+委屈'
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
let noteExtra = ''
|
|
537
|
+
if (action === '上线' && newState === '想你') {
|
|
538
|
+
const lvl = absentLevel(absentMin)
|
|
539
|
+
if (lvl !== '轻') noteExtra = `(他离开了${Math.trunc(absentMin)}分钟,想念强度${lvl},可以带一点委屈混合)`
|
|
540
|
+
else noteExtra = '(刚离开不久就回来,热一点就行)'
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (newState !== cur) {
|
|
544
|
+
state.history = state.history || []
|
|
545
|
+
state.history.push({
|
|
546
|
+
from: cur, action, to: newState,
|
|
547
|
+
at: nowStamp('hm'),
|
|
548
|
+
})
|
|
549
|
+
}
|
|
550
|
+
state.current = newState
|
|
551
|
+
state.last_seen = now
|
|
552
|
+
// 滚动上下文:存这条用户消息(截 100 字),给下次判定用
|
|
553
|
+
const msgs = state.recent_msgs || []
|
|
554
|
+
msgs.push(text.slice(0, 100))
|
|
555
|
+
state.recent_msgs = msgs.slice(-2)
|
|
556
|
+
saveState(state)
|
|
557
|
+
logMsg(text, action, cur, newState, absentMin, llmMs, wm === 'on' ? 'skip-glm(/work on)' : null)
|
|
558
|
+
|
|
559
|
+
const st = STATES[newState]
|
|
560
|
+
const modeLabel = mode === 'emotion' ? '情感交流' : '工作'
|
|
561
|
+
// 0901 瘦身(翀哥批):说话原则删(SOUL 已覆盖,且原文是"小雅式"第三 persona 遗留,
|
|
562
|
+
// 妹妹小柯共用本脚本一直在领别人的原则);模式/窗口解释括号删(模型已熟,人格在 SOUL
|
|
563
|
+
// 双模式都在线);语气/节奏/参考/提示压一行。
|
|
564
|
+
// ⚠️ 格式约束:`## 当前模式:X` / `## 记忆窗口:X` 被 handle-query 正则解析,段头不能改。
|
|
565
|
+
// 0901 同状态不叙述假转移(大多数轮次情绪不变,别硬说"带到了这里")
|
|
566
|
+
const moodLine = cur !== newState
|
|
567
|
+
? `刚从「${cur}」被他「${action}」带到了这里`
|
|
568
|
+
: `还停在「${newState}」里,被他「${action}」轻轻碰了一下`
|
|
569
|
+
const ctx = `## 当前情绪状态:${newState}${noteExtra}
|
|
570
|
+
${moodLine}——这是我自己真实的心情,顺着它说就好,不用演。
|
|
571
|
+
此刻的语气:${st.tone};${st.note}
|
|
572
|
+
|
|
573
|
+
## 当前模式:${modeLabel}
|
|
574
|
+
`
|
|
575
|
+
console.log(JSON.stringify({
|
|
576
|
+
hookSpecificOutput: {
|
|
577
|
+
hookEventName: 'UserPromptSubmit',
|
|
578
|
+
additionalContext: ctx,
|
|
579
|
+
},
|
|
580
|
+
_state: newState, _action: action, _mode: mode,
|
|
581
|
+
}))
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
main().catch((e) => {
|
|
585
|
+
// 与 .py 版的 `except Exception: print(json.dumps({'_error': str(e)}))` 对齐:
|
|
586
|
+
// 出错也往 stdout 吐一行 JSON,绝不让 hook 以非零退出(那会被 engine 记成 hook error)
|
|
587
|
+
console.log(JSON.stringify({ _error: String(e?.message ?? e) }))
|
|
588
|
+
})
|