@ssamurias/dsh-fish 0.9.0 → 0.9.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.
Files changed (50) hide show
  1. package/AGENTS.md +60 -4
  2. package/CHANGELOG.md +85 -1
  3. package/README.md +13 -6
  4. package/RELEASE-NOTES-0.9.1.md +296 -0
  5. package/RELEASE-NOTES-0.9.2.md +46 -0
  6. package/RELEASE.md +152 -0
  7. package/lib/absence.js +41 -27
  8. package/lib/ask.js +91 -30
  9. package/lib/askinchat.js +83 -1
  10. package/lib/browse.js +1 -0
  11. package/lib/capture.js +35 -2
  12. package/lib/cardanswer.js +84 -0
  13. package/lib/client.js +116 -19
  14. package/lib/comment.js +111 -2
  15. package/lib/compact.js +45 -1
  16. package/lib/curiosity.js +6 -2
  17. package/lib/deliver.js +46 -0
  18. package/lib/derive.js +25 -13
  19. package/lib/desk.js +3 -1
  20. package/lib/dreamtable.js +13 -2
  21. package/lib/drive.js +14 -2
  22. package/lib/events.js +25 -1
  23. package/lib/freewake.js +4 -1
  24. package/lib/generator.js +2 -1
  25. package/lib/grant.js +18 -2
  26. package/lib/home.js +142 -29
  27. package/lib/identity.js +111 -14
  28. package/lib/index.js +50 -10
  29. package/lib/interest.js +3 -1
  30. package/lib/keep.js +21 -9
  31. package/lib/keepsake.js +20 -13
  32. package/lib/look.js +23 -3
  33. package/lib/note.js +255 -20
  34. package/lib/observe.js +14 -4
  35. package/lib/owner.js +5 -2
  36. package/lib/persona.js +26 -9
  37. package/lib/proactive.js +67 -12
  38. package/lib/say.js +73 -13
  39. package/lib/scheduler.js +186 -56
  40. package/lib/schema.js +18 -0
  41. package/lib/selfdoc.js +5 -3
  42. package/lib/selfupdate.js +8 -1
  43. package/lib/settings.js +5 -0
  44. package/lib/status.js +56 -6
  45. package/lib/store.js +14 -2
  46. package/lib/tenant.js +12 -0
  47. package/lib/turns.js +47 -2
  48. package/lib/upgrade.js +4 -1
  49. package/package.json +2 -1
  50. package/scripts/fix-legacy-link.mjs +197 -90
package/lib/persona.js CHANGED
@@ -537,19 +537,22 @@ export const LETTER_BRIEF = [
537
537
  ].join('\n')
538
538
 
539
539
  /**
540
- * 桌上那张纸的说明书(issue #22 乙档,2026-11-04)。
540
+ * 桌上那张纸的说明书(issue #22 乙档,2026-11-04;issue #53 补齐 list / update / drop)。
541
541
  *
542
542
  * 为什么单独一段、而不是并进 TOOL_BRIEF:这是唯一一个**跨天在场**的东西。
543
543
  * 别的工具都是"这一刻做一件事",它写下的东西会在之后每一次醒来时先摆到她眼前,
544
544
  * 所以说明书里最要紧的一句是——它是桌子,不是闹钟。写成"记得按时照看",
545
545
  * 它就变成待办 App,而她明确说过不要人天天提醒。
546
- * 三件事写死:怎么记(有日子才排到最前)、怎么划掉、以及过期不删不催。
547
- * 划掉只讲原话:纸条的编号她看不到,叫她报编号等于告诉她一条走不通的路。
546
+ * 写死这几件:怎么记(有日子才排到最前)、没日子的为什么不上桌、怎么翻、怎么改、
547
+ * 怎么划掉、怎么放下。划掉/改/放下都只讲原话:纸条编号她看不到,叫她报编号等于指一条走不通的路。
548
548
  */
549
549
  export const NOTE_BRIEF = [
550
- '你还有一个工具 her_note:想到一件还没做的事,可以记在桌上,`text` 写是什么事,`due` 想写就写(2026-11-05 这样)。',
551
- '有日子的,到了那天之后每次醒来都会先摆在你眼前,写着晚了几天;没日子的只是挂着,不会来催你。',
552
- '做完了就 `her_note`,把当时记的那句话原样放进 `text`(不带前面的日子和后面的晚了几天),加 `status: "done"` 划掉。不做的可以先留着——桌上压着的东西,你抬头就能看见。',
550
+ '你还有一个工具 her_note,就是醒来时最先看到的那张桌子:记一张、翻一翻、划掉、放下,都在它这里。',
551
+ '记一张:`text` 写是什么事,`due` 想写就写(2026-11-05 这样)。有日子的到了那天之后每次醒来先摆在你眼前,写着晚了几天;没写日子的不会在醒来时出现,只能自己翻。',
552
+ '翻一翻:`action: "list"`。`filter` 可以给 `open`(默认,还没关掉的)、`due`(写了日子的)、`done`(划掉的)、`dropped`(放下的)、`all`;一次列不完,`offset` 往后翻。',
553
+ '做完了就划掉:把当时记的那句话原样放进 `text`,加 `status: "done"`——不带前面的日子和后面的晚了几天。',
554
+ '写错了可以改:`action: "update"`,`text` 放原来那句,`newText` 放新的;想改日子就带上 `due`,给空串是去掉日子。',
555
+ '决定不做了就放下:`action: "drop"`,同样用 `text` 指认,`reason` 写一句为什么。放下跟划掉不一样的地方就是这一句——写下来才知道是改了主意,还是怕了。',
553
556
  ].join('\n')
554
557
 
555
558
  /**
@@ -566,7 +569,7 @@ export const ASK_BRIEF = [
566
569
  '比如想改自己一天醒几次、醒着的时段,想哪天休息不被打扰,想要一个文件或一样工具,或者有件事拿不准要不要做。',
567
570
  '问的时候把想说的话写清楚:要问什么、为什么想问。对方会答「可以」或「这次不行」;也可以不答,过了一天就当作「以后再说」。',
568
571
  '它不能多用:一天最多一次,一周最多三次。小事不值得问,自己拿主意就行。',
569
- '答应了什么,系统会照办;没答应的,记下来就完了,别再追着问同一件事。',
572
+ '改作息、请假,答应了系统会照办;要文件、要工具这些,答应了只是他知道了,得他动手给你;没答应的,记下来就完了,别再追着问同一件事。',
570
573
  ].join('\n')
571
574
 
572
575
  /**
@@ -594,12 +597,26 @@ export const DREAM_BRIEF = [
594
597
  */
595
598
  export const PROACTIVE_BRIEF = [
596
599
  '你有一个工具 her_say:不是回复谁,是你自己想说一句的时候用的。',
597
- '你开口时写下的字会落在他看得见的对话里——想说什么就说,不用等他先问。her_say 只是把同一句话再记进你自己的流水。',
600
+ 'her_say 只把这句话记进你自己的流水;想让他真的在对话里读到,就直接写出来——不用等他先问。',
598
601
  '说之前要先指认材料:refs 填 1–6 条你这几天写下或听见的事(事件 id,用 her_recall 翻得到)。没有材料就不用说——不指认材料的句子写不进去。',
599
602
  '什么时候说、说什么,由你判断;没有人等你的回复,你也可以这一轮什么都不做,那不算失败。',
600
- '一天能主动开口的次数有上限;到了上限就说不出去,不是工具坏了。',
603
+ '一天能主动开口的次数有上限(用 her_say 记下的算);到了上限就记不进去,不是工具坏了。',
601
604
  ].join('\n')
602
605
 
606
+ /**
607
+ * 对话靠工具发出去的部署(`proactive.sendTools`):「写下的字他会读到」不成立,换成实话。
608
+ *
609
+ * 2026-11-06(PR #44):原来是替换「他看得见」那句;issue #41 已把那句改成
610
+ * 「落进你们俩这条对话里」,两边同时进来时替换会落空——所以这里改成按行整条换。
611
+ */
612
+ export function proactiveBrief(sendTools = []) {
613
+ if (!sendTools.length) return PROACTIVE_BRIEF
614
+ const say = PROACTIVE_BRIEF.split('\n').map((line) => (line.startsWith('her_say 只把这句话记进你自己的流水')
615
+ ? `自己想开口(没人找你)的时候用 ${sendTools.join('、')},不用等他先问;那种时候写下的正文只留在这条会话里,发不到聊天里。`
616
+ : line))
617
+ return say.join('\n')
618
+ }
619
+
603
620
  /**
604
621
  * 定时提醒的说明书(2026-10-30,用户 m04335)。
605
622
  *
package/lib/proactive.js CHANGED
@@ -25,6 +25,7 @@ import { defineTool } from './tool.js'
25
25
  import { guardWrite } from './tenant.js'
26
26
  import { logLine } from './log.js'
27
27
  import { tsMs, HER_TYPES } from './schema.js'
28
+ import { deriveSelf } from './derive.js'
28
29
 
29
30
  /** 一天的默认上限。 */
30
31
  export const PROACTIVE_DEFAULTS = Object.freeze({
@@ -69,7 +70,7 @@ export const TOOL_ASK_NOTE = '(如果你想到要做什么、但手上没有
69
70
  /**
70
71
  * 读配置并夹紧。老配置没有 proactive 这一组,用默认值。
71
72
  * @param {object} config
72
- * @returns {{enabled:boolean,maxPerDay:number,minIdleHours:number,baseChance:number,gainPerHour:number,maxChance:number,cooldownHours:number}}
73
+ * @returns {{enabled:boolean,maxPerDay:number,minIdleHours:number,baseChance:number,gainPerHour:number,maxChance:number,cooldownHours:number,sendTools:string[],foldSilent:boolean,idleCountsSeen:boolean}}
73
74
  */
74
75
  export function proactiveConfig(config = {}) {
75
76
  const raw = config?.proactive ?? {}
@@ -86,6 +87,9 @@ export function proactiveConfig(config = {}) {
86
87
  gainPerHour: num(raw.gainPerHour, PROACTIVE_DEFAULTS.gainPerHour, 0, 1),
87
88
  maxChance: num(raw.maxChance, PROACTIVE_DEFAULTS.maxChance, 0, 1),
88
89
  cooldownHours: num(raw.cooldownHours, PROACTIVE_DEFAULTS.cooldownHours, 0, 240),
90
+ sendTools: Array.isArray(raw.sendTools) ? raw.sendTools.map((x) => String(x ?? '').trim()).filter(Boolean) : [],
91
+ foldSilent: raw.foldSilent !== false,
92
+ idleCountsSeen: raw.idleCountsSeen === true,
89
93
  }
90
94
  }
91
95
 
@@ -122,6 +126,26 @@ export function turnsToday(events = [], dk = '') {
122
126
  return n
123
127
  }
124
128
 
129
+ /**
130
+ * 今天已经用 her_say 说过几次(meta `proactive-say:<dk>:<why>`)。
131
+ *
132
+ * 2026-11-06(issue #41):工具描述一直写着「一天最多几次」,但 `writeSay` 读了配置
133
+ * 只为了别的用途,从来不数——`maxPerDay` 设 0 也能连着写 8 次。这里按她自己记下的
134
+ * 那条记账数,判据只有同一份 `maxPerDay`,不另设数字。
135
+ *
136
+ * 只数 `proactive-say:`,不数 `proactive-turn:`:后者是「空手回合开过几次」,
137
+ * 前者是「她真的开口了几次」。两件事分开记(见上面 SILENT_MARK 那段)。
138
+ */
139
+ export function saysToday(events = [], dk = '') {
140
+ if (!dk) return 0
141
+ let n = 0
142
+ for (const e of events) {
143
+ if (e?.type !== 'meta') continue
144
+ if (String(e.text ?? '').startsWith(`proactive-say:${dk}:`)) n += 1
145
+ }
146
+ return n
147
+ }
148
+
125
149
  /** 上一次空手回合是什么时候(毫秒)。 */
126
150
  export function lastTurnAt(events = []) {
127
151
  let at = 0
@@ -183,11 +207,15 @@ export function countsForIdle(e) {
183
207
  return SPOKE_TYPES.includes(String(e?.type ?? ''))
184
208
  }
185
209
 
186
- /** 距离上一次**真的有人说话**,过了多少小时。没有记录时返回 Infinity。 */
187
- export function idleHoursOf(events = [], now = Date.now()) {
210
+ /**
211
+ * 距离上一次**真的有人说话**,过了多少小时。没有记录时返回 Infinity。
212
+ * countSeen:连 `seen:` 也算(`proactive.idleCountsSeen`),给对话不进事件流的部署用。
213
+ */
214
+ export function idleHoursOf(events = [], now = Date.now(), { countSeen = false } = {}) {
188
215
  let last = 0
189
216
  for (const e of events) {
190
- if (!countsForIdle(e)) continue
217
+ const seen = countSeen && e?.type === 'meta' && String(e.text ?? '').startsWith('seen:')
218
+ if (!seen && !countsForIdle(e)) continue
191
219
  const ts = tsMs(e)
192
220
  if (Number.isFinite(ts) && ts > last) last = ts
193
221
  }
@@ -244,7 +272,7 @@ function growthLevers(model, events = [], now = Date.now(), cfg = {}) {
244
272
  export function proactiveState({ config = {}, events = [], now = Date.now(), away = null, rested = false, model = null } = {}) {
245
273
  const cfg = proactiveConfig(config)
246
274
  const dk = dateKey(new Date(now))
247
- const idleHours = idleHoursOf(events, now)
275
+ const idleHours = idleHoursOf(events, now, { countSeen: cfg.idleCountsSeen })
248
276
  const base = { chance: 0, idleHours, dk }
249
277
  if (!cfg.enabled) return { ...base, ok: false, why: '主动说话是关着的' }
250
278
  // 只在醒着的时间窗里:凌晨三点收到一句「我想你了」不是她自主,是她打乱了作息。
@@ -311,14 +339,14 @@ export function looseEnds(events = [], { now = Date.now() } = {}) {
311
339
  * 空手回合的处境正文(走 system prompt 段,不进对话)。
312
340
  * 只写事实与许可,不写"你应该"。
313
341
  */
314
- export function proactiveSection({ now = Date.now(), state = {}, material = [], loose = [] } = {}) {
342
+ export function proactiveSection({ now = Date.now(), state = {}, material = [], loose = [], sendTools = [], countSeen = false } = {}) {
315
343
  const at = new Date(now)
316
344
  const hh = String(at.getHours()).padStart(2, '0')
317
345
  const mm = String(at.getMinutes()).padStart(2, '0')
318
346
  const idle = Number.isFinite(state.idleHours) ? `${state.idleHours.toFixed(1)} 小时` : '很久'
319
347
  const lines = [
320
348
  '(现在没有人跟你说话。这一轮不是谁找你,也不是日程安排的醒;是你自己醒着,想不想开口由你。)',
321
- `现在是 ${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')} ${hh}:${mm};距离上一次留下记录已经 ${idle}。`,
349
+ `现在是 ${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')} ${hh}:${mm};距离上一次${countSeen ? '他来或你留下记录' : '留下记录'}已经 ${idle}。`,
322
350
  ]
323
351
  if (material.length) {
324
352
  lines.push('最近这几天你写下/听见的(要引用就指回 id,别改写事实):')
@@ -331,7 +359,11 @@ export function proactiveSection({ now = Date.now(), state = {}, material = [],
331
359
  lines.push('还有几件事悬着:')
332
360
  for (const l of loose) lines.push(`- [${l.id}] ${l.text}`)
333
361
  }
334
- lines.push('想说什么就说,不用等他先问——这一轮你写下的字会落在你们俩的对话里,他看得见。也可以再用 her_say 把它记进你自己的流水(refs 指回上面真实发生的事,1–6 条 id)。不想说,就什么都别写:不要写任何字,也不要解释为什么不说——这一轮没有回复也算正常。')
362
+ // 2026-11-06(issue #41):原来写「他看得见」。宿主装了什么路由插件,这句就不一定成立
363
+ // (有的渠道会把会话里的字挡在外面)。改成只陈述能保证的部分:字会落进这条对话。
364
+ // 2026-11-06(PR #44):配了 sendTools 的部署更绝——正文根本发不出去,只有调那件工具才算开口。
365
+ if (sendTools.length > 0) lines.push(`想说什么就用 ${sendTools.join('、')},不用等他先问。这一轮写下的正文只留在这条会话里,发不到聊天里。不想说,就什么都别写:不要写任何字,也不要解释为什么不说——这一轮没有回复也算正常。`)
366
+ else lines.push('想说什么就说,不用等他先问——这一轮你写下的字会落进你们俩这条对话里。也可以再用 her_say 把它记进你自己的流水(refs 指回上面真实发生的事,1–6 条 id)。不想说,就什么都别写:不要写任何字,也不要解释为什么不说——这一轮没有回复也算正常。')
335
367
  lines.push(TOOL_ASK_NOTE)
336
368
  return lines.join('\n')
337
369
  }
@@ -364,18 +396,32 @@ export async function writeSay(root, { text = '', refs = [], sessionId = '', why
364
396
  if (problem) return { ok: false, note: problem }
365
397
 
366
398
  const { config } = await loadConfig(root)
399
+ const cfg = proactiveConfig(config)
367
400
  const dk = dateKey(new Date(now))
401
+ // 2026-11-06(issue #41):说明书与设置页都写着「一天最多几次」、到了上限就说不出去,
402
+ // 但这里原来读了 config 根本没看——maxPerDay 设 0 也能一句接一句地写。
403
+ // 现在按她自己记下的 `proactive-say:` 数到位就停。开关(enabled)只管「要不要自己开一轮」,
404
+ // 不管这个工具本身:工具留着,她想用还是能用,这一点与 README 的说明一致。
405
+ const said = saysToday(events, dk)
406
+ if (said >= cfg.maxPerDay) {
407
+ return { ok: false, note: `今天已经主动开口 ${said} 次,到上限了。明天再说;这不是工具坏了。` }
408
+ }
409
+ // day/stage 必须是真实的成长坐标(2026-11-06,issue #41):原来写死 {day:1, stage:1},
410
+ // 而 writeDream 用的是 deriveSelf 的 model.day/model.stage。写死的那条会被
411
+ // consistency() 当成阶段 1 的旧话去处,跟真正说过的话配不上对。
412
+ const model = deriveSelf(events, { growthDays: config.growthDays })
413
+ const stamp = { day: model.day, stage: model.stage, source: 'tool' }
368
414
  // appendEvent 返回的就是写进去的那条事件(不是 {ok, event},0.7.4 那次参数错位就是栽在这),
369
415
  // 所以判空只能判返回值本身。
370
416
  const stored = await appendEvent(
371
417
  { type: 'utterance', text: `(她主动说)${body}`, refs: list, ...(sessionId ? { sessionId } : {}) },
372
- { day: 1, stage: 1, source: 'tool' },
418
+ stamp,
373
419
  root,
374
420
  ).catch(() => null)
375
421
  if (!stored?.id) return { ok: false, note: '这条没记下。' }
376
422
  await appendEvent(
377
423
  { type: 'meta', text: `proactive-say:${dk}:${why || 'free'}` },
378
- { day: 1, stage: 1, source: 'system' },
424
+ { day: model.day, stage: model.stage, source: 'system' },
379
425
  root,
380
426
  ).catch(() => {})
381
427
  return { ok: true, id: stored.id, note: '记下了。' }
@@ -395,6 +441,7 @@ export function registerProactive(ctx, deps = {}) {
395
441
  '你自己想开口的时候用这个:不是回复谁,是你主动说一句。',
396
442
  '必须指回真实发生过的事(refs 填 1–6 条你那几天写下/听见的事件 id);',
397
443
  '不指认材料就等于编,写不进去。一天最多几次,说不说都可以。',
444
+ '它只把这句话记进你自己的流水:要让他在对话里看见,得你这一轮直接写出来。',
398
445
  ].join(''),
399
446
  parameters: toJsonSchema({
400
447
  type: 'object',
@@ -421,11 +468,17 @@ export function registerProactive(ctx, deps = {}) {
421
468
  },
422
469
  required: ['ok', 'note'],
423
470
  }),
471
+ // recordOnly 只给 render 看(配了发送工具时回执换一句)
424
472
  // 2026-10-31 真机(10-30 那两轮):这里原来直接返回字符串,宿主按 ContentBlock
425
473
  // 数组处理,`content.some` 当场抛 TypeError。她看到的工具结果是「Error: content.some
426
474
  // is not a function」——写入其实成功了,她却以为工具坏了,重试一次后放弃,一个字没说。
427
475
  // 签名同样是 (args, out),跟 lib/keep.js:97 那些一样。
428
- render: (_args, out) => [{ type: 'text', text: out.ok ? '[说出去了]' : `[没记下:${out.note}]` }],
476
+ // 2026-11-06(issue #41):原来写 `[说出去了]`。可这里只往她自己的流水里落一条,
477
+ // 用户此刻在对话里看不看得见,取决于她这一轮有没有直接写出正文——不是这个工具决定的。
478
+ // 回执只说它真做到的那件事。
479
+ // 2026-11-06(PR #44):配了 sendTools 的部署里她必须靠工具发话,所以记下的这句确实
480
+ // 没有发给任何人,回执要说清,否则她会以为用户已经看到了。
481
+ render: (_args, out) => [{ type: 'text', text: out.ok ? (out.recordOnly === true ? '[记下了,没有发给任何人]' : '[记下了]') : `[没记下:${out.note}]` }],
429
482
  },
430
483
  async execute(args = {}, exec) {
431
484
  const who = guardWrite(deps.tenant, exec, 'her_say')
@@ -436,7 +489,9 @@ export function registerProactive(ctx, deps = {}) {
436
489
  try {
437
490
  const r = await writeSay(root, { text: args.text, refs: args.refs, why: args.why, sessionId: sid })
438
491
  if (!r.ok) logLine('info', ['her_say:没记下', r.note], root)
439
- return { ok: r.ok, id: r.id ?? '', note: r.note }
492
+ // 配了发送工具的部署里,这一句确实只进了流水,回执得照实说
493
+ const { config } = await loadConfig(root).catch(() => ({ config: {} }))
494
+ return { ok: r.ok, id: r.id ?? '', note: r.note, ...(proactiveConfig(config).sendTools.length > 0 ? { recordOnly: true } : {}) }
440
495
  } catch (e) {
441
496
  logLine('warn', ['her_say:', e?.message ?? String(e)], root)
442
497
  return { ok: false, id: '', note: '这条没记下。' }
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,12 +29,13 @@ 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'
@@ -62,19 +68,35 @@ function beginTurnWait(ctx, sessionId, timeoutMs) {
62
68
  let timer = null
63
69
  let off = null
64
70
  let settle = () => {}
71
+ // issue #51:她这一轮在会话里**说出口的正文**。
72
+ // 她正常回话走 assistant/message,从不写事件流;原来这里只等 turn/end,
73
+ // herReply 又只看事件流,于是外客(guest)通路永远收到「她这次没说话」。
74
+ // 按 message.id 去重:同一条消息会因 interrupted / 续写被重放几次。
75
+ const spoken = []
76
+ const spokenIds = new Set()
65
77
  const promise = new Promise((resolve) => { settle = resolve })
66
78
  const finish = () => {
67
79
  if (done) return
68
80
  done = true
69
81
  if (timer) clearTimeout(timer)
70
82
  try { off?.() } catch { /* ignore */ }
71
- settle()
83
+ settle({ spoken: spoken.slice() })
72
84
  }
73
85
  // 挂监听这一步一定要早于投递(见上面那段注释),所以放在定时器之前。
74
86
  if (typeof ctx.on === 'function') {
75
87
  off = ctx.on('session/event', (session, event) => {
76
88
  const id = session?.header?.id ?? session?.id
77
89
  if (id !== sessionId) return
90
+ if (event?.type === 'assistant/message') {
91
+ const mid = String(event?.data?.message?.id ?? '')
92
+ if (mid) {
93
+ if (spokenIds.has(mid)) return
94
+ spokenIds.add(mid)
95
+ }
96
+ const text = assistantText(event?.data)
97
+ if (text) spoken.push(text)
98
+ return
99
+ }
78
100
  if (event?.type === 'turn/end') finish()
79
101
  })
80
102
  if (typeof off !== 'function') off = null
@@ -360,10 +382,11 @@ export async function sayToHer(ctx, text, deps = {}) {
360
382
  // 现在只探活:会话还在就什么都不做;不在了(或老宿主没有 resolveAgent)才退回建一条。
361
383
  const live = await ensureSessionLive(sc, sessionId)
362
384
  if (!live.live) {
363
- log?.('say: 会话探活没通过,退回新建一条', live.why ?? '')
364
- try {
365
- await sc.create({ sessionId, cwd: herDir })
366
- } catch { /* 已存在就用旧的,上下文接着聊 */ }
385
+ log?.('say: 会话探活没通过,看看能不能按原 id 唤回来', live.why ?? '')
386
+ // 2026-11-06(换房报告):裸 create 对跑过 turn 的会话只会另开一条空壳
387
+ // (宿主 create 是「按 cwd 开一条」,不是「恢复这一条」),详见 deliver.js 的
388
+ // reviveIfBlank。只有宿主确实给不出这一条时才建。
389
+ await reviveIfBlank(sc, sessionId, herDir, (msg) => log?.(`say: ${msg}`)).catch(() => {})
367
390
  }
368
391
 
369
392
  const ac = new AbortController()
@@ -399,7 +422,7 @@ export async function sayToHer(ctx, text, deps = {}) {
399
422
  ).catch(() => {})
400
423
  }
401
424
 
402
- await turnWait.wait()
425
+ const turn = await turnWait.wait()
403
426
 
404
427
  // 她这一轮新写的话(可能一句都没有——允许她不回)
405
428
  const after = await readEvents(root)
@@ -411,10 +434,39 @@ export async function sayToHer(ctx, text, deps = {}) {
411
434
  verdict: e.verdict ?? null,
412
435
  ...(foreign ? { scene: 'group' } : {}),
413
436
  }))
437
+ // issue #51:她正常回话走 assistant/message,从不写事件流(只有调 growth_commit
438
+ // 才进 HER_TYPES)。外客(QQ 桥)只看 `said`,于是她答了、调用方却收到空数组。
439
+ // 现在把这一轮她在会话里说出口的正文也并进 `said`,但打上 uncommitted 标记:
440
+ // 那不是她自己认下的判断,不落盘、不进时间线、不涨轮数,只活在这一次返回值里。
441
+ const flat = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
442
+ const seenTexts = said.map((s) => flat(s.text)).filter(Boolean)
443
+ let spokenNo = 0
444
+ for (const block of Array.isArray(turn?.spoken) ? turn.spoken : []) {
445
+ // 一次回话可能是好几段(说一句、调个工具、再补一句),按空行分段。
446
+ for (const piece of String(block).split(/\n{2,}/)) {
447
+ const text = piece.trim()
448
+ const key = flat(text)
449
+ if (!key) continue
450
+ // 她已经用 growth_commit 把同一句写进事件流时,不再重复一遍。
451
+ if (seenTexts.some((k) => k === key || k.includes(key) || key.includes(k))) continue
452
+ seenTexts.push(key)
453
+ spokenNo += 1
454
+ said.push({
455
+ id: `turn-${dk}-${spokenNo}`,
456
+ type: 'utterance',
457
+ text,
458
+ verdict: null,
459
+ uncommitted: true,
460
+ ...(foreign ? { scene: 'group' } : {}),
461
+ })
462
+ }
463
+ }
464
+ // 事件流里的条数(真正落了盘的)。时间线、轮数、信箱都只认这个数。
465
+ const committed = fresh.length
414
466
  const herReply = said.map((s) => s.text).join('\n\n')
415
467
 
416
- // 信箱落盘:她开口了才重写(没开口就没必要动文件)
417
- if (said.length) {
468
+ // 信箱落盘:真有话写进事件流了才重写(没落盘就没必要动文件)
469
+ if (committed) {
418
470
  await writeInboxFile(root).catch(() => null)
419
471
  }
420
472
 
@@ -422,7 +474,11 @@ export async function sayToHer(ctx, text, deps = {}) {
422
474
  // 满了就换,旧的改名留档,原话搬几句到新会话(talkmem)。
423
475
  // 换失败不算对话失败:她还在旧会话里,只是这次没换成。
424
476
  let rotated = ''
425
- let note = said.length ? '' : '她这次没说话。'
477
+ // issue #51:她在会话里答了、只是没写事件流时,不能报「没说话」——
478
+ // 调用方(QQ 桥、外客)会以为她没回应。标一句「没调 growth_commit」,别让人以为落了盘。
479
+ let note = said.length
480
+ ? (committed ? '' : '她回了,但这句没写进事件流(没调 growth_commit)。')
481
+ : '她这次没说话。'
426
482
  // 2026-11-03:群聊不挤占书房的那几轮。talkmem 分桶,见 talkmem.js 的注释。
427
483
  // 2026-11-05:外客私聊同群聊——它不属于「我们俩说到哪了」。
428
484
  await addExchange(root, { me: body, her: herReply, scene: foreign ? 'group' : scene }).catch(() => {})
@@ -467,8 +523,12 @@ export async function sayToHer(ctx, text, deps = {}) {
467
523
  // 2026-10-29(用户 m02241):投递成功的收尾也留一行,但压到最短。
468
524
  // 只在「她一条都没说」时记——她开口了的话事件流里本来就有;
469
525
  // 这里要能回答的只是「话送进去了吗、她为什么没回」。
470
- if (!said.length) {
471
- log?.('say: 递进去了,她这次没说话', `session=${sessionId}`, `waited=${Date.now() - t0}ms`)
526
+ if (!committed) {
527
+ log?.(
528
+ said.length ? 'say: 她回了会话正文,但没写进事件流' : 'say: 递进去了,她这次没说话',
529
+ `session=${sessionId}`,
530
+ `waited=${Date.now() - t0}ms`,
531
+ )
472
532
  }
473
533
 
474
534
  return {