dsh-companion-persona 0.2.0 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ Addresses the v0.2.0 audit. Three functional defects, all verified by test.
6
+
7
+ ### N1 — model judging never actually ran (high)
8
+
9
+ `judgeProvider` / `judgeModel` defaulted to empty strings, and those empty strings
10
+ were passed straight to `ctx.llm.stream()`, which throws `NO_ADAPTER` for an
11
+ unregistered provider. Every turn fell through to the rule fallback, so the
12
+ advertised 91% was really 68% — silently.
13
+
14
+ The judge now **inherits the current agent's model** (`agent.options.provider` /
15
+ `agent.options.model`), so switching models switches the judge automatically.
16
+ Explicit config still wins when you want to pin a channel. Documented in the
17
+ config table, where it was previously missing entirely.
18
+
19
+ ### N2 — lost update on concurrent turns (high)
20
+
21
+ Judging became async, so `turn/end` handlers could overlap: two turns both read
22
+ the shared `state` snapshot, and the later write clobbered the earlier one.
23
+ Reproduced with 30ms/5ms delays — only one turn's effect survived. State updates
24
+ now run through a serial promise chain (same pattern as `persistChain`).
25
+ 0.1.x had no such race because it was fully synchronous.
26
+
27
+ ### Bond channel was unreachable, so good relationships never warmed (high)
28
+
29
+ `BOND_THRESHOLD = 0.30` filtered out **every real warm event**:
30
+ "谢谢你陪我" 0.226, "你真好" 0.170, "我今天升职了" 0.202, repair 0.202. Only
31
+ "我很喜欢你" (0.399) ever passed. A user who was consistently kind saw zero
32
+ change after 200 turns; the persona could only move in the negative direction.
33
+
34
+ Admission is now by **event kind** (`support` / `intimacy` / `repair` /
35
+ `achievement`) with a low 0.05 floor that only filters noise. Magnitude decides
36
+ *how much* to move, not *whether* to move.
37
+
38
+ Measured: 200 warm turns → `warmth +0.212`, `guardedness −0.071`.
39
+
40
+ ### Fallback path quality
41
+
42
+ The degraded path scored 68% and made directional errors: "我其实很害怕"
43
+ (vulnerability — a high-value intimacy signal) was classified as `conflict`
44
+ (negative valence), so a user opening up made the persona more guarded.
45
+
46
+ Fallback now orders: explicit conflict phrasing → intimate topics → vulnerability
47
+ expressions → positive hints → negative hints → repair → routine. 69% → **77%**,
48
+ and no intimacy case maps to a negative kind.
49
+
50
+ ### Tests
51
+
52
+ `src/e2e.js` gained the two assertions whose absence let these through:
53
+
54
+ - **positive channel**: 200 warm turns must raise `warmth` by > 0.02
55
+ - **fallback accuracy**: a deliberately-broken classifier must still score ≥ 60%
56
+
57
+ The old "accuracy" check only counted rule-covered items and skipped everything
58
+ routed to the LLM — which is exactly how a permanently-failing judge went
59
+ unnoticed.
60
+
61
+ ### Minor
62
+
63
+ - cache is now a real LRU (was FIFO while calling itself LRU)
64
+ - dropped `再见` / `拜拜` / `在吗` from the silent-word table — a farewell or a
65
+ "are you there?" opener can be significant; they should not be zero-cost routine
66
+ - removed dead `cwdCmp` code in the exclusion check
67
+
68
+ ## 0.2.1
69
+
70
+ - Fix: `@deepseek-ai/dsh-home-paths` was imported statically but never declared
71
+ as a dependency, so a fresh `npm install` crashed on load with
72
+ `Cannot find package`. It is now an **optional peer dependency** resolved at
73
+ plugin start; if absent, the state path falls back to `$DSH_HOME` / `~/.dsh`.
74
+ (It has no stable release on npm — only `rc`/`alpha` tags — so pinning it as a
75
+ regular dependency would break installs.)
76
+
3
77
  ## 0.2.0
4
78
 
5
79
  Signal layer rewritten. The old rule-only extractor scored **45%** on a 22-item
package/README.md CHANGED
@@ -240,9 +240,22 @@ agent.ctx.systemPrompt.section({
240
240
  | `maxChars` | `800` | 注入段长度上限 |
241
241
  | `order` | `10` | section 排序 |
242
242
  | `audit` | `true` | 是否写入 `ledger.jsonl` |
243
+ | `judgeProvider` | **空(自动)** | 事件判定用的 provider。**留空 = 自动跟随当前会话的模型** |
244
+ | `judgeModel` | **空(自动)** | 事件判定用的 model。同上 |
245
+
246
+ ### 关于 `judgeProvider` / `judgeModel`
247
+
248
+ **留空是推荐配置**:插件会读取当前 agent 的 `options.provider` / `options.model`,
249
+ 也就是你此刻正在用的那个模型。你换模型,判定通道自动跟着换。
250
+
251
+ 只有在想让判定**固定走某个通道**(比如前台用强模型、判定用便宜快模型)时才需要显式填写。
252
+
253
+ > 0.2.0 的这两个默认值是空字符串,而且代码直接把空串传给了 `ctx.llm.stream()`,
254
+ > 导致每轮抛 `NO_ADAPTER`、判定永远走降级路径 —— 用户以为在用模型判定,
255
+ > 实际一直跑在规则上。0.2.2 起改为自动继承。
243
256
 
244
257
  `statePath` 未设置时的解析顺序:
245
- `$DSH_HOME` → `$XDG_DATA_HOME` → `~/.local/share`。
258
+ 宿主 `dsh-home-paths` 的 `defaultDshHome()` → `$DSH_HOME` → `~/.dsh`。
246
259
 
247
260
  ## 标定参考
248
261
 
package/index.js CHANGED
@@ -13,7 +13,6 @@
13
13
  import Schema from '@deepseek-ai/schemastery';
14
14
  import { join } from 'node:path';
15
15
  import { homedir } from 'node:os';
16
- import { defaultDshHome } from '@deepseek-ai/dsh-home-paths';
17
16
  import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
18
17
 
19
18
  import {
@@ -64,7 +63,10 @@ function resolveStatePath(config, ctx) {
64
63
  // 自己拼路径会在 Windows 上落到错误的位置,也会和 DSH 其它数据脱节。
65
64
  // 该包是纯函数集合,不是注入服务 —— 直接 import 即可。
66
65
  try {
67
- const h = defaultDshHome();
66
+ // 动态解析:该包在 DSH 运行时必然存在;作为独立 npm 包安装时可能缺席,
67
+ // 那时退回环境变量也不会出错。用同步 require 走 Node 的 CJS 桥。
68
+ const mod = globalThis.__personaHomePaths ?? null;
69
+ const h = mod?.defaultDshHome?.();
68
70
  if (typeof h === 'string' && h.length > 0) {
69
71
  return join(h, STATE_DIR_NAME, 'state.json');
70
72
  }
@@ -120,6 +122,13 @@ async function loadState(file) {
120
122
  }
121
123
 
122
124
  export async function apply(ctx, config) {
125
+ // 尝试加载宿主的 home 解析包(可选依赖)。
126
+ // 失败完全无损:resolveStatePath 会退回 DSH_HOME / ~/.dsh。
127
+ if (!globalThis.__personaHomePaths) {
128
+ try {
129
+ globalThis.__personaHomePaths = await import('@deepseek-ai/dsh-home-paths');
130
+ } catch { /* 独立安装场景,忽略 */ }
131
+ }
123
132
  const statePath = resolveStatePath(config, ctx);
124
133
  const logger = ctx.logger?.('companion-persona');
125
134
  let state = await loadState(statePath);
@@ -129,20 +138,71 @@ export async function apply(ctx, config) {
129
138
  }
130
139
  let persistChain = Promise.resolve();
131
140
 
141
+ /**
142
+ * 状态更新的串行队列。
143
+ *
144
+ * 为什么必须串行:判定是异步的(要调模型)。如果两轮判定重叠,
145
+ * 后完成的那轮会因为读的是一份过期的 state 快照而覆盖先完成的写入
146
+ * (lost update)。实测:两轮并发后 load 只剩后一轮的结果。
147
+ *
148
+ * 0.1.x 是同步处理所以没这个问题 —— 这是引入 LLM 判定时带入的回归。
149
+ */
150
+ let stateChain = Promise.resolve();
151
+ const withStateLock = (fn) => {
152
+ const run = stateChain.then(fn, fn);
153
+ // 吞掉异常,避免一次失败就让整条链断掉
154
+ stateChain = run.catch(() => {});
155
+ return run;
156
+ };
157
+
132
158
  /**
133
159
  * 事件判定器。
134
160
  *
135
161
  * 走宿主 llm 服务而不是自建 HTTP 客户端:provider/模型/回退链全部由 DSH 管理,
136
162
  * 限流时宿主会自己降级,插件不需要重复实现那套。
137
163
  */
164
+ /**
165
+ * 各 agent 的模型路由。
166
+ *
167
+ * 为什么不写死配置项:默认空串会让 ctx.llm.stream({provider:'', model:''})
168
+ * 每轮抛 NO_ADAPTER,判定永远走降级 —— 用户以为开了 LLM 判定,实际一直是规则。
169
+ * 实测过这个"静默失效",比报错更糟。
170
+ *
171
+ * 正确来源是 agent.options:那就是这个 agent 当前真正在用的 provider/model,
172
+ * 用户换模型时自动跟随,不需要改插件配置。
173
+ */
174
+ const agentRoutes = new WeakMap();
175
+ const routeByAgentId = new Map();
176
+
177
+ /** 解析本次判定该用哪个路由:显式配置 > 当前 agent > 任意已知 agent > 让宿主默认 */
178
+ function pickRoute(agentHint) {
179
+ // 1) 显式配置优先(用户想固定判定通道时用)
180
+ if (config.judgeProvider && config.judgeModel) {
181
+ return { provider: config.judgeProvider, model: config.judgeModel };
182
+ }
183
+ // 2) 当前 session 所属 agent 的路由
184
+ const sid = typeof agentHint === 'string' ? agentHint : agentHint?.sessionId;
185
+ if (sid && routeByAgentId.has(sid)) {
186
+ const r = routeByAgentId.get(sid);
187
+ if (r?.provider && r?.model) return r;
188
+ }
189
+ // 3) 任意已知 agent(单用户部署下通常只有一个)
190
+ for (const r of routeByAgentId.values()) {
191
+ if (r?.provider && r?.model) return r;
192
+ }
193
+ return null;
194
+ }
195
+
138
196
  const judge = createJudge({
139
- classify: async (text) => {
140
- // ctx.llm.stream() 是宿主唯一的一等调用入口(带 provider 路由与回退)。
141
- // GenerateOptions 必须显式给 provider/model —— 这里用插件自己配置的通道,
142
- // 与主对话模型解耦:即使前台换了模型,判定通道也保持稳定。
197
+ classify: async (text, agentHint) => {
198
+ const route = pickRoute(agentHint);
199
+ if (!route) {
200
+ throw new Error('no model route available for judging');
201
+ }
202
+ // ctx.llm.stream() 是宿主唯一的一等调用入口,带 provider 路由与回退。
143
203
  const chunks = ctx.llm.stream({
144
- provider: config.judgeProvider,
145
- model: config.judgeModel,
204
+ provider: route.provider,
205
+ model: route.model,
146
206
  system: CLASSIFIER_PROMPT,
147
207
  messages: [{ role: 'user', content: [{ type: 'text', text }] }],
148
208
  maxTokens: 16,
@@ -170,6 +230,16 @@ export async function apply(ctx, config) {
170
230
  const registerSection = (agent) => {
171
231
  if (!agent?.ctx || sectioned.has(agent)) return;
172
232
  sectioned.add(agent);
233
+ // 记录该 agent 的模型路由(判定通道跟随它)
234
+ try {
235
+ const opt = agent.options ?? {};
236
+ const route = { provider: opt.provider, model: opt.model, reasoningEffort: opt.reasoningEffort };
237
+ if (route.provider && route.model) {
238
+ agentRoutes.set(agent, route);
239
+ const id = agent.session?.id;
240
+ if (typeof id === 'string') routeByAgentId.set(id, route);
241
+ }
242
+ } catch { /* options 不可读时忽略,走 fallback 路由 */ }
173
243
  try {
174
244
  const agentCtx = agent.ctx;
175
245
  const disposer = agentCtx.systemPrompt.section({
@@ -231,6 +301,12 @@ export async function apply(ctx, config) {
231
301
 
232
302
  const text = userTexts[userTexts.length - 1];
233
303
 
304
+ // 找到这个 session 对应的 agent,让判定走它当前在用的模型
305
+ let agentHint = null;
306
+ try {
307
+ agentHint = typeof session.id === 'string' ? session.id : null;
308
+ } catch { /* 忽略 */ }
309
+
234
310
  // 排除判定:某些会话不参与人格形成(工作区 / 单会话 / preset / subagent)
235
311
  let header = {};
236
312
  try { header = session.header ?? {}; } catch { header = {}; }
@@ -265,8 +341,9 @@ export async function apply(ctx, config) {
265
341
 
266
342
  // 判定是异步的(可能调模型)。turn/end 监听器不 await,
267
343
  // 所以人格变化在下一轮生效 —— 这符合直觉,也不阻塞对话。
268
- void (async () => {
269
- const verdict = await judge.judge(text);
344
+ // 用 withStateLock 串行化:await 前后跨共享 state 必须串行,否则丢更新。
345
+ void withStateLock(async () => {
346
+ const verdict = await judge.judge(text, agentHint);
270
347
  const kind = verdict.kind;
271
348
  if (!kind) return;
272
349
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-companion-persona",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Long-term companion persona for DSH: trait dynamics, drift and catastrophe mutation, injected as a scoped system-prompt section.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -48,5 +48,13 @@
48
48
  "bugs": {
49
49
  "url": "https://github.com/ldviqd/dsh-companion-persona/issues"
50
50
  },
51
- "homepage": "https://github.com/ldviqd/dsh-companion-persona#readme"
51
+ "homepage": "https://github.com/ldviqd/dsh-companion-persona#readme",
52
+ "peerDependencies": {
53
+ "@deepseek-ai/dsh-home-paths": "*"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "@deepseek-ai/dsh-home-paths": {
57
+ "optional": true
58
+ }
59
+ }
52
60
  }
package/src/e2e.js CHANGED
@@ -168,11 +168,73 @@ console.log('=== 4. 日常对话不磨坏人格 ===');
168
168
  }
169
169
 
170
170
  // ---------------------------------------------------------------------------
171
- // 5. 真实冲突文本:变化应该发生,但不该 15 轮就崩
171
+ // 5. 正向通道:持续暖对话必须让人格变暖
172
+ //
173
+ // 这条断言是审计报告 N3 指出的缺口 —— 0.2.0 的全部 18 项断言里没有一项
174
+ // 检查"好关系会升温",所以键合通道完全失效(BOND_THRESHOLD=0.30 拦住
175
+ // 所有真实暖事件)却没被发现。功能没生效,测试必须能抓到。
172
176
  // ---------------------------------------------------------------------------
173
177
 
174
178
  console.log();
175
- console.log('=== 5. 真实冲突的演化节奏 ===');
179
+ console.log('=== 5. 正向通道(暖对话应变暖)===');
180
+ {
181
+ const warm = ['谢谢你陪我', '你真好', '对不起,我刚才说重了', '我今天升职了'];
182
+ let s = initialState();
183
+ for (let i = 0; i < 200; i++) {
184
+ const text = warm[i % warm.length];
185
+ const kind = i % 4 === 2 ? 'repair' : (i % 4 === 3 ? 'achievement' : 'support');
186
+ s = step(s, toEpisode(kind, text)).state;
187
+ if (i % 10 === 9) s = restPersona(s, 2);
188
+ }
189
+ const dWarmth = s.traits.warmth - s.baseline.warmth;
190
+ const dGuard = s.traits.guardedness - s.baseline.guardedness;
191
+ console.log(` 200 轮纯暖对话: warmth Δ=${dWarmth.toFixed(3)} guardedness Δ=${dGuard.toFixed(3)}`);
192
+ check('暖对话让人格变暖 Δ > 0.02', dWarmth > 0.02, dWarmth.toFixed(3));
193
+ check('戒备同步下降', dGuard < 0, dGuard.toFixed(3));
194
+ check('变暖幅度不过冲 Δ < 0.4', dWarmth < 0.4, dWarmth.toFixed(3));
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // 6. 降级路径准确率(不依赖 LLM)
199
+ //
200
+ // 审计报告 N1:0.2.0 默认配置下 judgeProvider='' 导致每轮抛 NO_ADAPTER,
201
+ // 实际一直跑在降级路径上,而 e2e 的"准确率"只统计规则覆盖项,
202
+ // 把降级路径排除在统计之外 —— 这是 N1 没被自测发现的原因。
203
+ //
204
+ // 这里显式断言降级路径的准确率下限。
205
+ // ---------------------------------------------------------------------------
206
+
207
+ console.log();
208
+ console.log('=== 6. 降级路径准确率 ===');
209
+ {
210
+ // 用一个必定失败的 classify 模拟模型不可用
211
+ const broken = createJudge({
212
+ classify: async () => { throw new Error('model unavailable'); },
213
+ });
214
+ let correct = 0;
215
+ const errors = [];
216
+ for (const [text, want] of GOLD) {
217
+ const r = await broken.judge(text);
218
+ if (r.kind === want) correct++;
219
+ else errors.push(`${text} → ${r.kind} (期望 ${want})`);
220
+ }
221
+ const acc = correct / GOLD.length;
222
+ console.log(` 降级路径准确率: ${correct}/${GOLD.length} = ${(acc * 100).toFixed(0)}%`);
223
+ if (errors.length > 0 && errors.length <= 8) {
224
+ for (const e of errors) console.log(` · ${e}`);
225
+ }
226
+ check('降级准确率 >= 60%', acc >= 0.60, `${(acc * 100).toFixed(0)}%`);
227
+ check('降级不把亲密判成负性',
228
+ !errors.some((e) => e.includes('我其实很害怕') || e.includes('我妈住院')),
229
+ '');
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // 7. 真实冲突文本:变化应该发生,但不该 15 轮就崩
234
+ // ---------------------------------------------------------------------------
235
+
236
+ console.log();
237
+ console.log('=== 7. 真实冲突的演化节奏 ===');
176
238
  {
177
239
  const conflicts = ['你根本不懂我在说什么', '算了,不想说了', '你总是这样', '我很失望'];
178
240
  let s = initialState();
@@ -190,11 +252,11 @@ console.log('=== 5. 真实冲突的演化节奏 ===');
190
252
  }
191
253
 
192
254
  // ---------------------------------------------------------------------------
193
- // 6. rest() 必须真的被调用(旧代码从未调用)
255
+ // 8. rest() 必须真的被调用(旧代码从未调用)
194
256
  // ---------------------------------------------------------------------------
195
257
 
196
258
  console.log();
197
- console.log('=== 6. 闲置恢复(rest 生效)===');
259
+ console.log('=== 8. 闲置恢复(rest 生效)===');
198
260
  {
199
261
  let s = initialState();
200
262
  for (let i = 0; i < 60; i++) {
@@ -218,11 +280,11 @@ console.log('=== 6. 闲置恢复(rest 生效)===');
218
280
  }
219
281
 
220
282
  // ---------------------------------------------------------------------------
221
- // 7. 面板字段一致性(旧代码 changed 恒 undefined)
283
+ // 9. 面板字段一致性(旧代码 changed 恒 undefined)
222
284
  // ---------------------------------------------------------------------------
223
285
 
224
286
  console.log();
225
- console.log('=== 7. 面板数据 ===');
287
+ console.log('=== 9. 面板数据 ===');
226
288
  {
227
289
  const s = initialState();
228
290
  s.traits = { warmth: 0.3, volatility: 0.35, guardedness: 0.6, playfulness: 0.55, dependence: 0.4 };
@@ -234,11 +296,11 @@ console.log('=== 7. 面板数据 ===');
234
296
  }
235
297
 
236
298
  // ---------------------------------------------------------------------------
237
- // 8. 排除规则(含 Windows 路径)
299
+ // 10. 排除规则(含 Windows 路径)
238
300
  // ---------------------------------------------------------------------------
239
301
 
240
302
  console.log();
241
- console.log('=== 8. 排除规则 ===');
303
+ console.log('=== 10. 排除规则 ===');
242
304
  {
243
305
  const ex = { workspaces: ['/proj/a', 'D:\\work\\proj'], sessions: [], presets: [] };
244
306
  const cases = [
@@ -259,11 +321,11 @@ console.log('=== 8. 排除规则 ===');
259
321
  }
260
322
 
261
323
  // ---------------------------------------------------------------------------
262
- // 9. 安全指令不能被截断
324
+ // 11. 安全指令不能被截断
263
325
  // ---------------------------------------------------------------------------
264
326
 
265
327
  console.log();
266
- console.log('=== 9. 渲染安全 ===');
328
+ console.log('=== 11. 渲染安全 ===');
267
329
  {
268
330
  const s = initialState();
269
331
  s.identity = { name: '鲸鲸', userName: '' };
package/src/judge.js CHANGED
@@ -55,9 +55,11 @@ const CONFIDENT_RULES = [
55
55
  */
56
56
  const SILENT_WORDS = new Set([
57
57
  '嗯', '嗯嗯', '哦', '噢', '好的', '好', '行', '可以', '收到', '明白了', '懂了',
58
- '晚安', '早安', '早', '午安', '拜拜', '再见', '明天见', '先这样',
58
+ // '再见'/'拜拜' 刻意不收:可能是告别事件(长期不联系),不该零成本静默
59
+ '晚安', '早安', '早', '午安', '明天见', '先这样',
59
60
  '哈哈', '哈哈哈', '笑死', '确实', '是啊', '对', '是的', '没错',
60
- 'ok', 'okay', '收到啦', '好嘞', '嗯呢', '在', '在吗',
61
+ // '在吗' 是询问不是寒暄,交给 LLM 判(可能是一次重要对话的开场)
62
+ 'ok', 'okay', '收到啦', '好嘞', '嗯呢', '在',
61
63
  '哦', '噢', '喔', '诶', '嘿', '唔', '额', '呃',
62
64
  ]);
63
65
 
@@ -150,7 +152,10 @@ export const CLASSIFIER_PROMPT = `你是情感事件分类器。判断用户这
150
152
  只输出标签本身,不要解释,不要加标点。`;
151
153
 
152
154
  /**
153
- * LLM 判定器。带 LRU 缓存与降级。
155
+ * LLM 判定器。带缓存与降级。
156
+ *
157
+ * 缓存策略是**真 LRU**:命中时把键移到队尾,淘汰时删队首。
158
+ * (0.2.0 的注释写成 LRU 但实现是 FIFO —— 命中不刷新位置,审计报告指出过。)
154
159
  *
155
160
  * @param {object} deps
156
161
  * @param {(text:string) => Promise<string|null>} deps.classify 调用模型的函数,返回原始文本
@@ -192,7 +197,11 @@ export function createJudge({ classify, cacheSize = 512, logger } = {}) {
192
197
  const key = cacheKey(text);
193
198
  if (cache.has(key)) {
194
199
  stats.cacheHits++;
195
- return { kind: cache.get(key), source: 'cache', reason: 'cached' };
200
+ // 真 LRU:命中后移到队尾,让热点条目不容易被淘汰
201
+ const hit = cache.get(key);
202
+ cache.delete(key);
203
+ cache.set(key, hit);
204
+ return { kind: hit, source: 'cache', reason: 'cached' };
196
205
  }
197
206
 
198
207
  // LLM
@@ -240,21 +249,35 @@ const NEGATIVE_HINTS = /(难过|累|烦|压力|焦虑|担心|害怕|失望|生
240
249
  function conservativeFallback(text) {
241
250
  const s = String(text ?? '');
242
251
 
243
- // 亲密话题优先于情绪判断:家人/身体/私事即使语气平淡也是 intimacy
252
+ // 1) 明确的冲突指向优先("你"字从句),先于情绪词判断
253
+ if (/(你根本|你总是|你从来|你不懂|你又不|凭什么)/.test(s)) return 'conflict';
254
+
255
+ // 2) 亲密话题:家人/身体/私事。
256
+ // 亲密**先于**负面情绪判断 —— "我妈住院了我很难过"是示弱不是冲突。
244
257
  if (/(妈|爸|父母|家人|奶奶|爷爷|外婆|外公|弟|妹|哥|姐|住院|生病|手术|身体|去世)/.test(s)) {
245
258
  return 'intimacy';
246
259
  }
247
260
 
248
- if (POSITIVE_HINTS.test(s)) return 'achievement';
261
+ // 3) 示弱表达:向对方暴露脆弱本身就是靠近,不是冲突。
262
+ // 这是审计报告指出的方向性错误:"我其实很害怕" 被判成 conflict(负效价),
263
+ // 等于用户越示弱、AI 越戒备。
264
+ // 正则要点:副词可叠加("我其实**很**害怕"是四段),所以用 [^。,]{0,4} 松散匹配,
265
+ // 而不是硬编码"我+副词+情绪词"三段式 —— 后者会漏掉最常见的说法。
266
+ if (/(我[^。,!?]{0,5}(害怕|担心|难过|委屈|孤单|想哭|撑不住|不敢|后悔|焦虑))/.test(s)
267
+ || /(说实话|老实说|说真的)/.test(s)) {
268
+ return 'intimacy';
269
+ }
249
270
 
250
- // 明确的冲突指向("你"字从句 + 负性)
251
- if (/(你根本|你总是|你从来|你不懂|你又不|凭什么)/.test(s)) return 'conflict';
271
+ if (POSITIVE_HINTS.test(s)) return 'achievement';
252
272
 
253
273
  if (NEGATIVE_HINTS.test(s)) {
254
274
  if (/(别管|不想说|不想聊|让我静一静)/.test(s)) return 'distance';
255
275
  return 'conflict';
256
276
  }
257
277
 
278
+ // 4) 修复语
279
+ if (/(我们聊聊|好吗|对不起|抱歉)/.test(s)) return 'repair';
280
+
258
281
  // 无明确情感信号 → routine(中性,不消耗人格)
259
282
  return 'routine';
260
283
  }
@@ -28,8 +28,26 @@ export const DRIFT_THRESHOLD = 0.55;
28
28
  /** 漂变速率 */
29
29
  export const DRIFT_RATE = 0.03; // 实测标定:75 轮极端冲突后 warmth 0.65→0.16,不触底
30
30
 
31
- /** 键合门槛:暖事件的量级门控 */
32
- export const BOND_THRESHOLD = 0.30;
31
+ /**
32
+ * 键合的最小量级门槛。
33
+ *
34
+ * 实测教训(0.2.0 审计):原来的 0.30 把**所有真实暖事件**都拦下了 ——
35
+ * "谢谢你陪我" 0.226、"你真好" 0.170、"我今天升职了" 0.202,
36
+ * 只有 "我很喜欢你" 0.399 能过。结果是持续对 AI 好的用户 200 轮后人格零变化,
37
+ * 正向通道形同虚设,人格只会往变坏的方向走。
38
+ *
39
+ * 改为:**按事件类型准入**(见 BOND_KINDS),量级只做地板过滤。
40
+ * 0.05 的作用是排除"意外被标成暖事件"的噪声,不是筛选强度。
41
+ */
42
+ export const BOND_THRESHOLD = 0.05;
43
+
44
+ /**
45
+ * 可以走键合通道的事件类型。
46
+ *
47
+ * 用白名单而不是靠量级门槛:语义上确定是"关系向好的事"就应该计入,
48
+ * 不管这次说得多轻。量级决定**改变多少**,不该决定**要不要改变**。
49
+ */
50
+ export const BOND_KINDS = new Set(['support', 'intimacy', 'repair', 'achievement']);
33
51
  /** 键合速率(刻意慢于漂变) */
34
52
  export const BOND_RATE = 0.025;
35
53
 
@@ -275,8 +293,12 @@ export function step(state, episode) {
275
293
  }
276
294
  }
277
295
 
278
- // 5) 键合:暖事件独立通道,不受 load 门控
279
- if (episode.valence > 0 && episode.magnitude >= BOND_THRESHOLD) {
296
+ // 5) 键合:暖事件独立通道,不受 load 门控。
297
+ // 准入看**事件类型**,不看量级 —— 见 BOND_KINDS 的注释。
298
+ const bondEligible = BOND_KINDS.has(episode.kind)
299
+ && episode.valence > 0
300
+ && episode.magnitude >= BOND_THRESHOLD;
301
+ if (bondEligible) {
280
302
  const size = BOND_RATE * episode.magnitude;
281
303
  for (const [axis, weight] of [['warmth', 1.0], ['guardedness', -0.6], ['dependence', 0.4]]) {
282
304
  const delta = damped(next.traits, next.resistance, axis, size * weight);
@@ -605,14 +627,13 @@ export function evaluateExclusion(exclusions, meta = {}) {
605
627
  const ws = norm(wsRaw);
606
628
  if (!ws) continue;
607
629
  // 大小写不敏感:Windows 路径不区分大小写
608
- const cwdCmp = ws.length === 2 && ws[1] === ':' ? cwd.toLowerCase() : cwd;
609
- const wsCmp = ws.length === 2 && ws[1] === ':' ? ws.toLowerCase() : ws;
630
+ // 统一小写比较:Windows 路径大小写不敏感,POSIX 大小写敏感但
631
+ // 把工作区路径也小写化影响极小,换来跨平台一致的排除行为。
610
632
  const lc = cwd.toLowerCase();
611
- const lw = wsCmp.toLowerCase();
633
+ const lw = ws.toLowerCase();
612
634
  if (lc === lw || lc.startsWith(`${lw}/`)) {
613
635
  return { excluded: true, reason: `工作区 ${wsRaw} 被排除` };
614
636
  }
615
- void cwdCmp;
616
637
  }
617
638
  }
618
639