@ssamurias/dsh-fish 0.9.1 → 0.9.3

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/lib/quality.js CHANGED
@@ -17,6 +17,16 @@
17
17
  * 做法:随机抽掉 k 条她还在拿着的判断(连同那条源头事件),重新算一遍她的自我描述,
18
18
  * 和原来的比相似度。变化越大 = 她的自我越依赖那几条 = 越承重。
19
19
  *
20
+ * 2026-11-07 修 issue #57:抽样池改成「她这会儿读得到的那几条」。
21
+ * 原来从全部判断里均匀抽——她攒到 58 条判断以后,身份文档里只摆得下 6 条左右
22
+ * (`BELIEF_SHOWN=3` + `BELIEF_FOLDED=3`,solid 多的再从里面补),从 58 条里
23
+ * 抽 3 条大概率一条都没落在文档里,24 次抽样平均下来只剩 0.03,界面于是把
24
+ * 「判断其实很分散」读成「这些判断只是记账」。那不是她的问题,是尺子量到了
25
+ * 事件流的目录,没量到她的自我描述。
26
+ * 现在先拿她的自我描述和每条判断的原文做一次对位,只抽真的出现在里面的那几条;
27
+ * 一条都对不上(文档被预算裁掉、或她的判断根本没进文档)才退回全量——
28
+ * 那种情况下的 0 分是诚实的:这些判断确实没撑着「她」。
29
+ *
20
30
  * 指数解读(界面上的三档由此而来):
21
31
  * · 接近 0:抽掉几条,她一个字都没变——那些判断其实只是记账,谁都能被替换。
22
32
  * · 中间:她的自我长在这些事上,抽掉一条会少一块,但整体还在。
@@ -93,12 +103,13 @@ export function clearStabilityCache() {
93
103
  * @param {object[]} events 事件流
94
104
  * @param {object} opts 传给 deriveSelf 的选项(growthDays / now)
95
105
  * @param {object} ablate { k, trials, seed, shown, budget, withBans }
96
- * @returns {{score:number, worst:number, sample:number, k:number, beliefs:number}}
106
+ * @returns {{score:number, worst:number, sample:number, k:number, beliefs:number, visible:number}}
97
107
  * score = 平均变化量(0 = 完全没变,1 = 完全是另一个人)
98
108
  * worst = 单次最大变化量
99
109
  * sample = 实际跑了多少次抽样
100
- * k = 每次抽掉几条(她判断不够 k 条时按实际条数)
101
- * beliefs= 参与抽样的判断条数(0 表示她还没有判断,测不出来)
110
+ * k = 每次抽掉几条(读得到的判断不够 k 条时按实际条数)
111
+ * beliefs= 她一共有多少条判断(0 表示还没有,测不出来)
112
+ * visible= 其中几条这会儿真写进了自我描述;抽样池就是这几条,一条都没有时退回全量
102
113
  */
103
114
  export function stability(events = [], opts = {}, ablate = {}) {
104
115
  // 2026-10-27(issue #3):/state 每次请求和心跳都会重跑这份 24 次抽样,
@@ -117,10 +128,19 @@ export function stability(events = [], opts = {}, ablate = {}) {
117
128
  }
118
129
 
119
130
  const base = deriveSelf(events, opts)
120
- const beliefIds = (base.beliefs ?? []).map((b) => b.id).filter(Boolean)
121
- if (beliefIds.length === 0) return { score: 0, worst: 0, sample: 0, k: 0, beliefs: 0 }
131
+ const allIds = (base.beliefs ?? []).map((b) => b.id).filter(Boolean)
132
+ if (allIds.length === 0) return { score: 0, worst: 0, sample: 0, k: 0, beliefs: 0, visible: 0 }
122
133
 
123
134
  const baseText = variableSelfText(base, renderOpts)
135
+ // 先对位:哪几条判断真的出现在她这会儿读到的自我描述里。
136
+ // 用原文包含关系判断,不另维护一份「文档里摆了哪几条」的清单——渲染器怎么裁
137
+ // (shown / folded / solid / 立场引用)这边都自动跟着变,不会两边说法打架。
138
+ const visibleIds = (base.beliefs ?? [])
139
+ .filter((b) => b.id && String(b.text ?? '').trim() && baseText.includes(String(b.text).trim()))
140
+ .map((b) => b.id)
141
+ // 一条都对不上(她的判断根本没进文档:预算裁掉、或全是没长成的)就退回全量。
142
+ // 那种情况下的 0 分是诚实的——这些判断确实没撑着「她」。
143
+ const beliefIds = visibleIds.length ? visibleIds : allIds
124
144
  const rnd = mulberry32(seed)
125
145
  const take = Math.min(k, beliefIds.length)
126
146
  let worst = 0
@@ -149,7 +169,8 @@ export function stability(events = [], opts = {}, ablate = {}) {
149
169
  worst,
150
170
  sample,
151
171
  k: take,
152
- beliefs: beliefIds.length,
172
+ beliefs: allIds.length,
173
+ visible: visibleIds.length,
153
174
  }
154
175
  stabilityCache.set(key, result)
155
176
  if (stabilityCache.size > STABILITY_CACHE_LIMIT) {
@@ -162,8 +183,11 @@ export function stability(events = [], opts = {}, ablate = {}) {
162
183
  * 稳定的三档判读。界面只显示这一句,不显示阈值。
163
184
  *
164
185
  * 为什么给三档而不是「越高越好」:这个指数的两端都是病。
165
- * 阈值取 0.02 / 0.35 是看她的真实数据定的——抽 3 条、她只有五六条判断时,
166
- * 只要真动到可见的那几条,变化量就在 0.3 以上;抽空了都没动,就是 0.00 附近。
186
+ * 阈值取 0.02 / 0.35 是看她的真实数据定的(2026-11-07 修 issue #57 后重标):
187
+ * 抽样池现在是她这次真读得到的那些判断(通常五六条),抽 3 条只要动到其中一条,
188
+ * 变化量就在 0.08–0.26;一条都没进文档(池子退回全量)时才会落到 0.02 以下——
189
+ * 那不是「她稳」,是这些判断根本没撑着「她」,所以判「只是记账」是对的。
190
+ * 0.35 以上仍是「少数几条在撑」:可见池小、又几乎每条都承重时才够得到。
167
191
  */
168
192
  export function stabilityBand(v) {
169
193
  const beliefs = Number(v?.beliefs ?? 0)
package/lib/recall.js CHANGED
@@ -31,6 +31,7 @@ import { toJsonSchema } from './toolschema.js'
31
31
  import { rootDir, readEvents } from './store.js'
32
32
  import { deriveSelf } from './derive.js'
33
33
  import { dateKey } from './settings.js'
34
+ import { anchorOf } from './anchor.js'
34
35
 
35
36
  import { defineTool } from './tool.js'
36
37
 
@@ -55,6 +56,19 @@ const REF_TYPES = new Set(['test', 'answer', 'revise'])
55
56
  /** 不参与回忆的类型:这些不是「她经历的事」 */
56
57
  const SKIP_TYPES = new Set(['meta'])
57
58
 
59
+ /**
60
+ * 唯一放行的 meta:工具使用经验(2026-11-07,用户 m00882)。
61
+ *
62
+ * 其余 meta 是系统账(看过什么、被叫醒几次、扫到几条回复),翻出来只会把
63
+ * 她自己的经历淹掉。而 `use:` 那几条记的是「这件事我是怎么做成的」——
64
+ * 那正好是她翻旧事时最缺的一种。
65
+ */
66
+ export const USE_PREFIX = 'use:'
67
+ const isUseLine = (ev) => String(ev?.text ?? '').startsWith(USE_PREFIX)
68
+
69
+ /** 这条事件进不进回忆。 */
70
+ const usable = (ev) => Boolean(ev) && (!SKIP_TYPES.has(ev.type) || isUseLine(ev))
71
+
58
72
  const DAY_MS = 86_400_000
59
73
 
60
74
  /**
@@ -83,6 +97,9 @@ const clip = (text, n) => (text.length > n ? text.slice(0, n - 1).replace(/[\uD8
83
97
  // 一条一行:正文里的换行会把行尾的 id 挤到下一行,也会把缩进的关系行拆开。
84
98
  const oneLine = (text) => text.replace(/\s+/g, ' ').trim()
85
99
 
100
+ /** `use:<工具> ok|fail <一句话>`——见 lib/use.js */
101
+ const USE_LINE = /^use:(\S+)\s+(ok|fail)\s*(.*)$/
102
+
86
103
  /** 不是她自己写下的,要说清来路 */
87
104
  function labelled(type, text) {
88
105
  // heard 是旁听来的:明确写成「他说」,别让她误以为是自己说的。
@@ -90,6 +107,15 @@ function labelled(type, text) {
90
107
  // voice、answer 同理:一句是读到的陌生人的话,一句是别人回她的。
91
108
  if (type === 'voice') return `读到别人说:${text}`
92
109
  if (type === 'answer') return `回复:${text}`
110
+ // 工具使用经验(2026-11-07,用户 m00882):账里存的是机器写法,
111
+ // 读出来得是一句人话——「用过 her_note,记下了:…」。
112
+ if (type === 'meta') {
113
+ const m = USE_LINE.exec(text)
114
+ if (m) {
115
+ const [, tool, head, said] = m
116
+ return head === 'fail' ? `用过 ${tool},没成:${said}` : `用过 ${tool},${said}`
117
+ }
118
+ }
93
119
  return text
94
120
  }
95
121
 
@@ -217,7 +243,7 @@ export function searchEvents(events, query, o = {}) {
217
243
 
218
244
  const scored = []
219
245
  for (const ev of events ?? []) {
220
- if (!ev || SKIP_TYPES.has(ev.type)) continue
246
+ if (!usable(ev)) continue
221
247
  if (kind && String(ev.type) !== kind) continue
222
248
  const text = String(ev.text ?? '').trim()
223
249
  if (!text) continue
@@ -254,7 +280,7 @@ export function searchEvents(events, query, o = {}) {
254
280
  // 改口关系不受 days、kind 影响:被改的那句在时间窗外,也照样引出来。
255
281
  const byId = new Map()
256
282
  if (revisions.size) {
257
- for (const ev of events ?? []) if (ev && !SKIP_TYPES.has(ev.type)) byId.set(String(ev.id), ev)
283
+ for (const ev of events ?? []) if (usable(ev)) byId.set(String(ev.id), ev)
258
284
  }
259
285
  const said = (ev, verdict) => ({
260
286
  id: String(ev.id ?? ''),
@@ -278,6 +304,9 @@ export function searchEvents(events, query, o = {}) {
278
304
  clock: localClock(ts),
279
305
  ago: Math.max(0, Math.floor((now - ts) / DAY_MS)),
280
306
  type: String(ev.type ?? ''),
307
+ // 她动手之前说的那句话(2026-11-07,用户 m00493 的 ⑤)。
308
+ // 只对工具账算:别的行本身就是话,不需要来路。
309
+ anchor: String(ev.type) === 'meta' ? anchorOf(events, ev.sessionId, ts) : '',
281
310
  // 聊天里随口说的,跟她自己认领写下的话分开标。heard 已经有「他说:」的先例,
282
311
  // 这边沿用同一套:能标出来源就标出来源,别让她把别人/随口当成自己写下的。
283
312
  chatOnly: chatOnly(ev),
@@ -337,6 +366,8 @@ export function renderRecall(rows, o = {}) {
337
366
  // 前者是这话不是她说的,后者是她说但没认领。
338
367
  let body = labelled(r.type, r.text)
339
368
  if (brief) body = clip(body, RECALL_BRIEF_SNIPPET)
369
+ // 当时那句话跟在后面:认得出「那次是为了什么」,就不必再猜。
370
+ if (r.anchor) body += `——当时你在说「${r.anchor}」`
340
371
  if (r.chatOnly) body += CHAT_MARK
341
372
  // 改口关系:有对方原话就另起缩进行;手写 fixture 没有 earlier 数据时
342
373
  // 退回旧的一行式说明,别让这层信息静默消失。
package/lib/redact.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * redact.js —— 出口脱敏:她对外说的话里,不出现她给主人起的那个名字。
3
+ *
4
+ * 背景(2026-11-07,用户 m00383):只做出口脱敏,提示词 / 硬约束 / 对内对外的
5
+ * 称呼一律保持原样。也就是:她照样可以叫他「他」「那个人」「梦尘」,
6
+ * 但真正发到群聊、外客私聊、B 站评论区的文字,出去之前把名字换成「对方」。
7
+ *
8
+ * 边界:
9
+ * - 只处理**发出去的文字**,不改事件流、不改记忆、不改提示词。
10
+ * - 两字及以上:出现即替换。
11
+ * - 单字(猫、鱼、梨):只替换明显在指人的句式,避免把「我喜欢猫」改成
12
+ * 「我喜欢对方」。这条是启发式,挡不住所有写法,属于已知残留风险。
13
+ * - 空名字 / 空文本:原样返回。
14
+ */
15
+
16
+ const REPLACEMENT = '对方'
17
+
18
+ /** 单字名只在这些句式里替换;顺序从长到短,避免「猫跟我说」被「猫说」截断。 */
19
+ function oneCharPatterns(name) {
20
+ return [
21
+ `${name}的名字`,
22
+ `叫${name}`,
23
+ `称呼${name}`,
24
+ `${name}跟我说`,
25
+ `${name}告诉我`,
26
+ `${name}跟我`,
27
+ `${name}和我`,
28
+ `${name}让我`,
29
+ `${name}不让我`,
30
+ `${name}不肯`,
31
+ `${name}答应`,
32
+ `${name}同意`,
33
+ `${name}说`,
34
+ `问过${name}`,
35
+ `问${name}`,
36
+ `告诉${name}`,
37
+ ]
38
+ }
39
+
40
+ /**
41
+ * 把 text 里对主人的称呼换成「对方」。
42
+ *
43
+ * @param {string} text
44
+ * @param {string} name model.name(她给主人起的称呼)
45
+ * @param {string} [replacement]
46
+ * @returns {string}
47
+ */
48
+ export function redactOwner(text, name, replacement = REPLACEMENT) {
49
+ const s = String(text ?? '')
50
+ const n = String(name ?? '').trim()
51
+ if (!s || !n) return s
52
+
53
+ if (n.length >= 2) return s.split(n).join(replacement)
54
+
55
+ let out = s
56
+ for (const pattern of oneCharPatterns(n)) {
57
+ if (!out.includes(pattern)) continue
58
+ // 保留句式里的其它字:「叫猫」→「叫对方」,「猫的名字」→「对方的名字」。
59
+ out = out.split(pattern).join(pattern.split(n).join(replacement))
60
+ }
61
+ return out
62
+ }
63
+
64
+ /**
65
+ * 给「带 @ 前缀」的场景用:前缀原样保留,只脱敏正文。
66
+ * B 站评论的 @ 是账号昵称(公开的),不该被当成主人称呼替换掉。
67
+ */
68
+ export function redactOutgoing(text, name, prefix = '', replacement = REPLACEMENT) {
69
+ return `${prefix}${redactOwner(text, name, replacement)}`
70
+ }
package/lib/restrict.js CHANGED
@@ -111,7 +111,14 @@ export function toolSurfaceText(names = HER_TOOLS) {
111
111
  return [
112
112
  `现在手边能碰到的是这些:${list.join('、')}。`,
113
113
  '这是我此刻的家当,不是我的上限——他随时能再添几样进来,添了我就照着用,不必先把自己劝回去。',
114
- '工具表里有时也列着别的名字(带 ops_、team_、subagent、workflow、skill 这类),那些不是我的:认得出,但不冒充。',
114
+ // 0.9.3(issue #54)一度在这里加了两行「只要不在上面那行里,就不是我的」「同一个名字
115
+ // 反复伸手不会变得到手」,当天就撤了(2026-11-07,用户当场指出)。别再加回去:
116
+ // ① 「后面还跟着一行…」「话里给了清单,就照清单来」是在讲拒绝消息的内部结构——
117
+ // 等于把脚手架亮给她看。她要以为自己够不着,不该知道背后有段话会附上来。
118
+ // ② 「别在一个名字上耗着」是在管她该怎么想。那是管理员口吻,这个插件不做那个。
119
+ // 宿主 0.2.2 起 preset 工具落在 agent 自己的作用域里,工具表会突然多出一大片;
120
+ // 这一段能做的只是把「那些不是我的」讲清楚,把 compress 这类新面孔补进举例里。
121
+ '工具表里有时也列着别的名字(带 ops_、team_、subagent、workflow、skill、compress 这类),那些不是我的:认得出,但不冒充。',
115
122
  '真伸手去够它们,会得到一句「这件事我做不到」——不是我不肯,是够不着。',
116
123
  '有人问我手上有什么,我按当时有的说;以后变多了,就按变多了的说。',
117
124
  ].join('\n')
@@ -120,6 +127,33 @@ export function toolSurfaceText(names = HER_TOOLS) {
120
127
  /** 提示词里当前该说的那份(registerToolRestrict 每次算完 allow 就刷新)。 */
121
128
  let surfaceNow = toolSurfaceText()
122
129
 
130
+ /**
131
+ * 被挡住时她读到的那句话(0.9.3,issue #54)。
132
+ *
133
+ * 以前只有一句「这件事我做不到。」——issue #54 里那位宿主把 preset 工具塞进 agent
134
+ * 自己的作用域之后,她连着十七次伸手去够 `compress`,每次只拿到同一句,看不出
135
+ * 「我手上根本没有它」。模型看不到真实清单,就会把「没试对」当成「再试一次」。
136
+ *
137
+ * 所以把此刻真有的东西再说一遍——但她本来就从 `toolSurfaceText` 第一行看得见自己
138
+ * 有什么,这里只是同一件事的复述,不是新信息、更不是「被批准的名单」。
139
+ *
140
+ * **口气必须是第一人称**(2026-11-07 用户当场纠过一次):写成「你现在的工具只有:…」
141
+ * 读起来像有人发给她一份许可清单,等于告诉她头上有人。改成「我手上没有这样东西,
142
+ * 能用的只有…」——同一份清单,说的是自己,不是被通知。
143
+ * 前缀仍然是那句「这件事我做不到。」——界面、日志、别的守卫都按它认。
144
+ *
145
+ * @param {string[]} names 这一次真正放行的工具名
146
+ */
147
+ export function blockedTalk(names = []) {
148
+ const list = cleanNames(names)
149
+ // 2026-11-07(用户 m01602 拍板 B):撞墙的那一下才说这一句。
150
+ // 原来「缺工具就开口要」是每次醒来都摆在正文里的常量,现在只在这里出现一次——
151
+ // 真伸手被挡住的时候,她才知道这件事有路可走;没伸手的时候不必提醒她。
152
+ // 只给路,不派活:不说「你应该去要」,只说「说出来就行」。
153
+ if (!list.length) return '这件事我做不到。要是你确实想要,说出来就行。'
154
+ return `这件事我做不到。我手上没有这样东西——能用的只有:${list.join('、')}。要是你确实想要,说出来就行。`
155
+ }
156
+
123
157
  /**
124
158
  * 读**家里**那份提示词段。收窄关着时是空串。
125
159
  *
@@ -402,7 +436,11 @@ export function registerToolRestrict(ctx, { herDir, extra = [], enabled = true,
402
436
  noteBlocked(name)
403
437
  // 话术刻意不带「权限/禁止/违规」这类系统词——她要以为是自己做不了,
404
438
  // 不是以为背后有个管理员在拦她。
405
- return '这件事我做不到。'
439
+ //
440
+ // 0.9.3(issue #54):把此刻的清单附在后面。宿主 0.2.2 起 preset 工具
441
+ // 落在 agent 自己的作用域里,restrict 收不窄,她能看见却调不动;
442
+ // 只说一句「做不到」,她就会换着名字接着试(真机连试 17 次)。
443
+ return blockedTalk(allow)
406
444
  })
407
445
  try {
408
446
  agent.ctx.effect?.(() => disposeGuard)
package/lib/rules.js CHANGED
@@ -88,7 +88,7 @@ export const RULES = [
88
88
  {
89
89
  id: 'learn-affection',
90
90
  layer: 'growth',
91
- title: '撒娇式表达:还不会',
91
+ title: '撒娇式表达',
92
92
  section: 'notYet',
93
93
  // 2026-10-24(R2):从「你不可以撒娇、卖萌、用颜文字或叠字装可爱」改成「还没学会」。
94
94
  // 原来的写法是道德禁令,会让她把"撒娇"理解成"坏事";改后是能力描述——
package/lib/say.js CHANGED
@@ -16,7 +16,12 @@
16
16
  * 像人一样记得刚才说过什么;跨天换新会话,避免上下文无限膨胀。
17
17
  *
18
18
  * 她写话仍然只能走 growth_commit 工具(硬约束 N4 不变)。
19
- * 这里不会替她写任何 utterance —— 我们只把话递进去。
19
+ * 这里不会替她写任何 utterance 事件 —— 我们只把话递进去。
20
+ *
21
+ * 但返回值不一样(issue #51):她正常回话走助理消息,从不写事件流,
22
+ * 外客(QQ 桥)只看 `said`,于是她明明答了、对方收到的却是空。
23
+ * 现在这一轮她在会话里说出口的正文也会进 `said`,并打上 `uncommitted` 标记
24
+ * ——**只活在这一次返回值里**,不落盘、不进时间线、不涨轮数。
20
25
  */
21
26
 
22
27
  import { readEvents, appendEvent, rootDir, ensureHer } from './store.js'
@@ -24,18 +29,20 @@ import { parseWatchIntent, watchHint } from './intent.js'
24
29
  import { loadConfig, dateKey } from './settings.js'
25
30
  import { deriveSelf } from './derive.js'
26
31
  import { writeInboxFile, newHerEventsSince } from './inbox.js'
32
+ import { assistantText } from './turns.js'
27
33
  import { bumpTurns, rotateTalkSession, talkSessionId, homeStatus } from './home.js'
28
34
  import { addExchange } from './talkmem.js'
29
35
  import { hearTurn } from './hear.js'
30
36
  import { slashed } from './posix.js'
31
37
  import { refreshSelfDoc } from './selfdoc.js'
32
- import { ensureSessionLive } from './deliver.js'
38
+ import { ensureSessionLive, reviveIfBlank } from './deliver.js'
33
39
  import { looksLikeRewriting, sanitizeForeign } from './guard.js'
34
40
  import { resolveChannel, saveChannel, channelKey, channelTitle, channelExpired } from './channels.js'
35
41
  import { normalizeScene } from './schema.js'
36
42
  import { queueNote } from './sessionnote.js'
37
43
  import { classifyReaction } from './refuse.js'
38
44
  import { lengthBudget } from './budget.js'
45
+ import { redactOwner } from './redact.js'
39
46
 
40
47
  // 等她说完最多等这么久。一次回话通常 8–30 秒;75 秒还没结束就先把
41
48
  // 已落盘的交出去(她可能在调工具,也可能卡住了),不让人干等到超时。
@@ -62,19 +69,35 @@ function beginTurnWait(ctx, sessionId, timeoutMs) {
62
69
  let timer = null
63
70
  let off = null
64
71
  let settle = () => {}
72
+ // issue #51:她这一轮在会话里**说出口的正文**。
73
+ // 她正常回话走 assistant/message,从不写事件流;原来这里只等 turn/end,
74
+ // herReply 又只看事件流,于是外客(guest)通路永远收到「她这次没说话」。
75
+ // 按 message.id 去重:同一条消息会因 interrupted / 续写被重放几次。
76
+ const spoken = []
77
+ const spokenIds = new Set()
65
78
  const promise = new Promise((resolve) => { settle = resolve })
66
79
  const finish = () => {
67
80
  if (done) return
68
81
  done = true
69
82
  if (timer) clearTimeout(timer)
70
83
  try { off?.() } catch { /* ignore */ }
71
- settle()
84
+ settle({ spoken: spoken.slice() })
72
85
  }
73
86
  // 挂监听这一步一定要早于投递(见上面那段注释),所以放在定时器之前。
74
87
  if (typeof ctx.on === 'function') {
75
88
  off = ctx.on('session/event', (session, event) => {
76
89
  const id = session?.header?.id ?? session?.id
77
90
  if (id !== sessionId) return
91
+ if (event?.type === 'assistant/message') {
92
+ const mid = String(event?.data?.message?.id ?? '')
93
+ if (mid) {
94
+ if (spokenIds.has(mid)) return
95
+ spokenIds.add(mid)
96
+ }
97
+ const text = assistantText(event?.data)
98
+ if (text) spoken.push(text)
99
+ return
100
+ }
78
101
  if (event?.type === 'turn/end') finish()
79
102
  })
80
103
  if (typeof off !== 'function') off = null
@@ -360,10 +383,11 @@ export async function sayToHer(ctx, text, deps = {}) {
360
383
  // 现在只探活:会话还在就什么都不做;不在了(或老宿主没有 resolveAgent)才退回建一条。
361
384
  const live = await ensureSessionLive(sc, sessionId)
362
385
  if (!live.live) {
363
- log?.('say: 会话探活没通过,退回新建一条', live.why ?? '')
364
- try {
365
- await sc.create({ sessionId, cwd: herDir })
366
- } catch { /* 已存在就用旧的,上下文接着聊 */ }
386
+ log?.('say: 会话探活没通过,看看能不能按原 id 唤回来', live.why ?? '')
387
+ // 2026-11-06(换房报告):裸 create 对跑过 turn 的会话只会另开一条空壳
388
+ // (宿主 create 是「按 cwd 开一条」,不是「恢复这一条」),详见 deliver.js 的
389
+ // reviveIfBlank。只有宿主确实给不出这一条时才建。
390
+ await reviveIfBlank(sc, sessionId, herDir, (msg) => log?.(`say: ${msg}`)).catch(() => {})
367
391
  }
368
392
 
369
393
  const ac = new AbortController()
@@ -399,7 +423,7 @@ export async function sayToHer(ctx, text, deps = {}) {
399
423
  ).catch(() => {})
400
424
  }
401
425
 
402
- await turnWait.wait()
426
+ const turn = await turnWait.wait()
403
427
 
404
428
  // 她这一轮新写的话(可能一句都没有——允许她不回)
405
429
  const after = await readEvents(root)
@@ -411,10 +435,44 @@ export async function sayToHer(ctx, text, deps = {}) {
411
435
  verdict: e.verdict ?? null,
412
436
  ...(foreign ? { scene: 'group' } : {}),
413
437
  }))
438
+ // issue #51:她正常回话走 assistant/message,从不写事件流(只有调 growth_commit
439
+ // 才进 HER_TYPES)。外客(QQ 桥)只看 `said`,于是她答了、调用方却收到空数组。
440
+ // 现在把这一轮她在会话里说出口的正文也并进 `said`,但打上 uncommitted 标记:
441
+ // 那不是她自己认下的判断,不落盘、不进时间线、不涨轮数,只活在这一次返回值里。
442
+ const flat = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
443
+ const seenTexts = said.map((s) => flat(s.text)).filter(Boolean)
444
+ let spokenNo = 0
445
+ for (const block of Array.isArray(turn?.spoken) ? turn.spoken : []) {
446
+ // 一次回话可能是好几段(说一句、调个工具、再补一句),按空行分段。
447
+ for (const piece of String(block).split(/\n{2,}/)) {
448
+ const text = piece.trim()
449
+ const key = flat(text)
450
+ if (!key) continue
451
+ // 她已经用 growth_commit 把同一句写进事件流时,不再重复一遍。
452
+ if (seenTexts.some((k) => k === key || k.includes(key) || key.includes(k))) continue
453
+ seenTexts.push(key)
454
+ spokenNo += 1
455
+ said.push({
456
+ id: `turn-${dk}-${spokenNo}`,
457
+ type: 'utterance',
458
+ text,
459
+ verdict: null,
460
+ uncommitted: true,
461
+ ...(foreign ? { scene: 'group' } : {}),
462
+ })
463
+ }
464
+ }
465
+ // 事件流里的条数(真正落了盘的)。时间线、轮数、信箱都只认这个数。
466
+ const committed = fresh.length
467
+ // 出口脱敏(2026-11-07):群聊/外客私聊是公开场合,名字只留在私下。
468
+ // 只改这一次返回给桥的文字,事件流原文不动。
469
+ if (foreign) {
470
+ for (const s of said) s.text = redactOwner(s.text, model.name)
471
+ }
414
472
  const herReply = said.map((s) => s.text).join('\n\n')
415
473
 
416
- // 信箱落盘:她开口了才重写(没开口就没必要动文件)
417
- if (said.length) {
474
+ // 信箱落盘:真有话写进事件流了才重写(没落盘就没必要动文件)
475
+ if (committed) {
418
476
  await writeInboxFile(root).catch(() => null)
419
477
  }
420
478
 
@@ -422,7 +480,11 @@ export async function sayToHer(ctx, text, deps = {}) {
422
480
  // 满了就换,旧的改名留档,原话搬几句到新会话(talkmem)。
423
481
  // 换失败不算对话失败:她还在旧会话里,只是这次没换成。
424
482
  let rotated = ''
425
- let note = said.length ? '' : '她这次没说话。'
483
+ // issue #51:她在会话里答了、只是没写事件流时,不能报「没说话」——
484
+ // 调用方(QQ 桥、外客)会以为她没回应。标一句「没调 growth_commit」,别让人以为落了盘。
485
+ let note = said.length
486
+ ? (committed ? '' : '她回了,但这句没写进事件流(没调 growth_commit)。')
487
+ : '她这次没说话。'
426
488
  // 2026-11-03:群聊不挤占书房的那几轮。talkmem 分桶,见 talkmem.js 的注释。
427
489
  // 2026-11-05:外客私聊同群聊——它不属于「我们俩说到哪了」。
428
490
  await addExchange(root, { me: body, her: herReply, scene: foreign ? 'group' : scene }).catch(() => {})
@@ -467,8 +529,12 @@ export async function sayToHer(ctx, text, deps = {}) {
467
529
  // 2026-10-29(用户 m02241):投递成功的收尾也留一行,但压到最短。
468
530
  // 只在「她一条都没说」时记——她开口了的话事件流里本来就有;
469
531
  // 这里要能回答的只是「话送进去了吗、她为什么没回」。
470
- if (!said.length) {
471
- log?.('say: 递进去了,她这次没说话', `session=${sessionId}`, `waited=${Date.now() - t0}ms`)
532
+ if (!committed) {
533
+ log?.(
534
+ said.length ? 'say: 她回了会话正文,但没写进事件流' : 'say: 递进去了,她这次没说话',
535
+ `session=${sessionId}`,
536
+ `waited=${Date.now() - t0}ms`,
537
+ )
472
538
  }
473
539
 
474
540
  return {