@ssamurias/dsh-fish 0.8.9 → 0.9.1

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 (60) hide show
  1. package/AGENTS.md +64 -10
  2. package/CHANGELOG.md +129 -2
  3. package/LICENSE +7 -0
  4. package/README.md +69 -17
  5. package/RELEASE-NOTES-0.8.0.md +87 -0
  6. package/RELEASE-NOTES-0.8.1.md +93 -0
  7. package/RELEASE-NOTES-0.8.2.md +57 -0
  8. package/RELEASE-NOTES-0.8.3.md +71 -0
  9. package/RELEASE-NOTES-0.8.4.md +47 -0
  10. package/RELEASE-NOTES-0.8.5.md +244 -0
  11. package/RELEASE-NOTES-0.8.6.md +224 -0
  12. package/RELEASE-NOTES-0.8.7.md +202 -0
  13. package/RELEASE-NOTES-0.8.8.md +224 -0
  14. package/RELEASE-NOTES-0.9.0.md +267 -0
  15. package/RELEASE-NOTES-0.9.1.md +296 -0
  16. package/RELEASE.md +151 -0
  17. package/THIRD-PARTY-NOTICES.md +3 -0
  18. package/lib/ask.js +18 -5
  19. package/lib/away.js +7 -2
  20. package/lib/browse.js +1 -0
  21. package/lib/capture.js +16 -1
  22. package/lib/channels.js +53 -13
  23. package/lib/client.js +145 -56
  24. package/lib/comment.js +74 -0
  25. package/lib/compact.js +71 -2
  26. package/lib/curiosity.js +6 -2
  27. package/lib/derive.js +21 -8
  28. package/lib/desk.js +4 -2
  29. package/lib/dream.js +3 -0
  30. package/lib/dreamtable.js +28 -2
  31. package/lib/drive.js +58 -9
  32. package/lib/freewake.js +21 -2
  33. package/lib/home.js +141 -24
  34. package/lib/inbox.js +7 -2
  35. package/lib/index.js +8 -12
  36. package/lib/interest.js +26 -4
  37. package/lib/log.js +22 -1
  38. package/lib/look.js +25 -5
  39. package/lib/note.js +35 -13
  40. package/lib/notify.js +2 -0
  41. package/lib/observe.js +22 -7
  42. package/lib/outing.js +2 -0
  43. package/lib/owner.js +5 -2
  44. package/lib/persona.js +18 -3
  45. package/lib/proactive.js +78 -18
  46. package/lib/say.js +104 -58
  47. package/lib/scheduler.js +162 -60
  48. package/lib/selfcheck.js +2 -3
  49. package/lib/selfdoc.js +8 -9
  50. package/lib/selfupdate.js +183 -41
  51. package/lib/settings.js +33 -2
  52. package/lib/status.js +31 -3
  53. package/lib/store.js +14 -2
  54. package/lib/tenant.js +12 -0
  55. package/lib/turns.js +46 -4
  56. package/lib/update.js +14 -0
  57. package/lib/upgrade.js +23 -4
  58. package/lib/voices.js +2 -0
  59. package/package.json +5 -2
  60. package/scripts/fix-legacy-link.mjs +254 -0
package/lib/dreamtable.js CHANGED
@@ -13,10 +13,14 @@
13
13
  * 三条边界(沿用 dream.js 的四条硬约束):
14
14
  * - 只用她自己说过的话(utterance / revise / reunion / narration / refusal),
15
15
  * 别人的话、系统记录一律不进;refs 的合法性因此天然满足。
16
+ * 只留证的也不进(isProofOnly):没认领的聊天行不是「写下的」,
17
+ * 想改自己的话不算她的判断(derive.js 同样不算信念)。
16
18
  * - 出门/请神那几趟写下的事件不摆(与 writeDream 的拒绝规则同源)。
17
19
  * - 只陈述事实,不给台词、不派活:不出现「梦」「记一句」「你可以」。
18
20
  */
19
21
 
22
+ import { isProofOnly } from './derive.js'
23
+
20
24
  /** 一句话要淡多久才值得被摆出来。太新的还热着,太旧的可能她已经不认了。 */
21
25
  export const STALE_MIN_DAYS = 7
22
26
  export const STALE_MAX_DAYS = 30
@@ -65,6 +69,7 @@ export function staleLines(events = [], opts = {}) {
65
69
  const rows = []
66
70
  for (const e of events) {
67
71
  if (!HER_WORDS.has(e?.type)) continue
72
+ if (isProofOnly(e)) continue
68
73
  if (trips.has(String(e?.sessionId ?? ''))) continue
69
74
  const t = Date.parse(e?.ts ?? '')
70
75
  if (!Number.isFinite(t)) continue
@@ -107,12 +112,18 @@ export function clipLine(text, max = LINE_CLIP) {
107
112
  export function renderTable(rows = [], { gapHours = 0 } = {}) {
108
113
  const list = (Array.isArray(rows) ? rows : []).filter((r) => r && r.id && String(r.text ?? '').trim())
109
114
  if (list.length < TABLE_MIN) return ''
110
- const head = gapHours >= 1 ? `这次隔了 ${Math.round(gapHours)} 个小时。` : ''
115
+ // 2026-11-06(issue #42):原来写「这次隔了 N 个小时」,读起来像「她睡了 N 小时」。
116
+ // hoursSinceLastWake 只数 `wake:`,中间她可能一直在跟人说话、开过好几轮。
117
+ // 改成只说它真算的那件事:距上一次醒来多久。
118
+ const head = gapHours >= 1 ? `距你上次醒来,隔了 ${Math.round(gapHours)} 个小时。` : ''
111
119
  const lines = list.slice(0, TABLE_MAX).map((r) => {
112
120
  const when = shortDay(r.ts)
113
121
  return `- 「${clipLine(r.text)}」${when ? ` · ${when}` : ''} · ${r.id}`
114
122
  })
115
- return [head, '你写下的几行字还摊在那儿,这些天再没被提起过:', '', ...lines]
123
+ // 2026-11-06(issue #42):原来后半句写「这些天再没被提起过」。判据其实只是
124
+ // 「之后有没有事件 refs 指回它」——她闲聊时提过一句但没有 refs,就仍然会被说成「没提起过」。
125
+ // 这句是给她读的,不该撒谎,所以把那半句去掉,只陈述摆在那儿这件事。
126
+ return [head, '你写下的几行字还摊在那儿:', '', ...lines]
116
127
  .filter((x) => x !== '')
117
128
  .join('\n')
118
129
  }
@@ -135,6 +146,21 @@ export function tableFor(opts = {}) {
135
146
  return renderTable(opts.rows ?? [], { gapHours: gap })
136
147
  }
137
148
 
149
+ /**
150
+ * 旧句子在空醒那一轮只摆一处(2026-11-05 真机 18:44 那次)。
151
+ *
152
+ * 摆料已经由 tableFor 渲染成「这次隔了 N 个小时。你写下的几行字还摊在那儿…」,
153
+ * 同一批行又被 deskFor 当 stale 摆进「桌上摊着这几样东西:」——她那一轮读到的是
154
+ * 同三句原话出现两遍,像系统在盘货。所以 tableText 非空时,桌子不再收 stale。
155
+ *
156
+ * @param {string} tableText
157
+ * @param {Array} rows staleLines() 的结果
158
+ * @returns {Array}
159
+ */
160
+ export function staleForDesk(tableText, rows = []) {
161
+ return String(tableText ?? '').trim() ? [] : rows
162
+ }
163
+
138
164
  /** 上一次叫醒到现在隔了多久(小时)。没有记录就返回 0。 */
139
165
  export function hoursSinceLastWake(events = [], now = Date.now()) {
140
166
  let last = 0
package/lib/drive.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * drive.js —— 「劲儿」:她什么时候攒够了想动一动(2026-11-05,用户 m02031)
3
3
  *
4
- * 由来:江屿那条线的做法是「攒一个 0..1 的数,到 0.8 想动一次,动完扣 0.55」。
4
+ * 由来:江屿那条线的做法是「攒一个 0..1 的数,到 0.8 想动一次,动完扣一点」。
5
5
  * 用户认可这个手感,但要它**从事件流算出来**,不另存一份 `drive.json`:
6
6
  * 落盘的状态会跟事件流各说各话,而她是谁只该由事件流决定。
7
7
  *
@@ -21,8 +21,8 @@
21
21
  export const DRIVE = Object.freeze({
22
22
  growthPerHour: 0.12,
23
23
  threshold: 0.8,
24
- cost: 0.55,
25
- minGapMs: 40 * 60_000,
24
+ cost: 0.8,
25
+ minGapMs: 2 * 60 * 60_000,
26
26
  /** 桌上每一样没合上的事 / 每一行旧句子各加多少(各最多算两份 / 三份)。 */
27
27
  boostGap: 0.06,
28
28
  boostStale: 0.04,
@@ -42,8 +42,13 @@ const HOUR = 3_600_000
42
42
  const DAY = 86_400_000
43
43
 
44
44
  function tsOf(e) {
45
- const t = Number(e?.ts ?? e?.time ?? e?.at)
46
- if (Number.isFinite(t) && t > 0) return t
45
+ const raw = e?.ts ?? e?.time ?? e?.at
46
+ // 事件里的 `ts` 是 ISO 字符串(不是毫秒数):先按数字试,再按日期解析。
47
+ // 2026-11-05 真机:只写 `Number(raw)` 时字符串一律 NaN,出生时刻与「上次动过」
48
+ // 全被算成 0,劲儿永远 0.00(`还没有任何记录,不急着动`),修了锚点也没用。
49
+ if (typeof raw === 'number') return Number.isFinite(raw) && raw > 0 ? raw : 0
50
+ const asDate = Date.parse(String(raw ?? ''))
51
+ if (Number.isFinite(asDate) && asDate > 0) return asDate
47
52
  const d = Date.parse(String(e?.iso ?? e?.date ?? ''))
48
53
  return Number.isFinite(d) ? d : 0
49
54
  }
@@ -73,6 +78,34 @@ function lastHeardAt(events) {
73
78
  return last
74
79
  }
75
80
 
81
+ /**
82
+ * 最早一条事件的时刻(她开始存在的那一刻)。
83
+ *
84
+ * 为什么要它:只认 `drive-spend:` / `proactive-turn:` / `proactive-silent:`
85
+ * 时,一个从没主动过的她会永远算成 0.00——那三类事件本来就要「先动一次」
86
+ * 才会写,于是第一动永远等不到(2026-11-05 真机 `drive 0.00 · 还没有任何记录`)。
87
+ * 出生就是起跑线:从她存在那一刻起,时间就在攒劲儿。
88
+ */
89
+ function firstAt(events) {
90
+ let first = 0
91
+ for (const e of events) {
92
+ const ts = tsOf(e)
93
+ if (ts > 0 && (!first || ts < first)) first = ts
94
+ }
95
+ return first
96
+ }
97
+
98
+ /** 他最近一次来过(`meta seen:`)的时刻。 */
99
+ function lastSeenLine(events) {
100
+ let last = 0
101
+ for (const e of events) {
102
+ if (e?.type !== 'meta' || !String(e.text ?? '').startsWith('seen:')) continue
103
+ const ts = tsOf(e)
104
+ if (ts > last) last = ts
105
+ }
106
+ return last
107
+ }
108
+
76
109
  /** 她最近一次真的开口的时刻。 */
77
110
  function lastSpokeAt(events) {
78
111
  let last = 0
@@ -98,20 +131,22 @@ function lastSpokeAt(events) {
98
131
  * @returns {{value:number, threshold:number, ready:boolean, why:string,
99
132
  * hoursSince:number, boosts:number, damp:number}}
100
133
  */
101
- export function driveOf({ events = [], now = Date.now(), desk = null, stage = 1 } = {}) {
134
+ export function driveOf({ events = [], now = Date.now(), desk = null, stage = 1, seenCountsAsHeard = false } = {}) {
102
135
  const base = { value: 0, threshold: DRIVE.threshold, ready: false, why: '', hoursSince: 0, boosts: 0, damp: 1, speed: DRIVE.growthPerHour }
103
- const last = lastActionAt(events)
136
+ // 动过就从那次算;一次都没动过,就从她出生那一刻算(不能永远停在 0.00)。
137
+ const last = lastActionAt(events) || firstAt(events)
104
138
  if (!last) return { ...base, why: '还没有任何记录,不急着动' }
105
139
 
106
140
  const hoursSince = Math.max(0, (now - last) / HOUR)
107
141
  // 刚动过:哪怕攒得快,也得隔开一点——两次冲动贴在一起就不像冲动了。
108
142
  if (now - last < DRIVE.minGapMs) {
109
- return { ...base, hoursSince, why: '刚动过,还没到 40 分钟' }
143
+ return { ...base, hoursSince, why: '刚动过,还没到两小时' }
110
144
  }
111
145
 
112
146
  // 上次开口没人接:底噪打折(不是惩罚,只是「说了也没人接」这件事本身的重量)。
113
147
  const spoke = lastSpokeAt(events)
114
- const heard = lastHeardAt(events)
148
+ // 对话不进事件流的部署(proactive.idleCountsSeen):他来过就算接了,否则这个折扣永远解不掉
149
+ const heard = Math.max(lastHeardAt(events), seenCountsAsHeard ? lastSeenLine(events) : 0)
115
150
  const damp = spoke && heard < spoke ? DRIVE.dampUnanswered : 1
116
151
 
117
152
  const gaps = Math.max(0, Number(desk?.gaps) || 0)
@@ -140,6 +175,20 @@ export function driveOf({ events = [], now = Date.now(), desk = null, stage = 1
140
175
  }
141
176
  }
142
177
 
178
+ /**
179
+ * 桌上有没有可说的东西(2026-11-05,用户 m02937)。
180
+ *
181
+ * 劲儿只回答「攒够了没有」,不回答「攒够了有没有事可说」。空桌醒来她只能回一句
182
+ * 「没什么要说的」——那既占额度,又在对话里留一条噪音。所以材料也是判据,不是装饰。
183
+ *
184
+ * @param {{gaps?:number, stale?:number, echo?:boolean, favorite?:boolean}|null} desk
185
+ * @returns {boolean}
186
+ */
187
+ export function deskHasMaterial(desk) {
188
+ if (!desk) return false
189
+ return Number(desk.gaps) > 0 || Number(desk.stale) > 0 || desk.echo === true || desk.favorite === true
190
+ }
191
+
143
192
  /** 一次行动的代价(动完扣掉)。留给记账用,纯数值。 */
144
193
  export function driveCost() {
145
194
  return DRIVE.cost
package/lib/freewake.js CHANGED
@@ -13,10 +13,10 @@
13
13
  * - table:旧句子要摆出来的那一次(lib/dreamtable.js 的三道门已经判过)。
14
14
  * - think:**兜底**(2026-11-05,用户 m02008 拍板)。原来按「每 3 天一次」的日历开,
15
15
  * 现在改成:过去 `QUIET_DAYS` 天里她一件自发的事都没有(没有 utterance /
16
- * narration / dream / 主动开口),才由日历补一次。劲儿正常工作时不该再出现
16
+ * narration / dream / 主动开口;聊天回合补写的 uncommitted 行不算),才由日历补一次。劲儿正常工作时不该再出现
17
17
  * 「同一天醒两次」,所以这一条只在劲儿长期趴着时生效。
18
18
  *
19
- * 只在本机开发副本里生效(调用方用 devModeSync 包住)。公开版看到的仍是原来那张卡。
19
+ * 0.9.0 起公开:所有机器都能空醒;想关掉把 config.memory.thinkEveryDays 设 0。
20
20
  */
21
21
 
22
22
  import { tsMs, HER_TYPES } from './schema.js'
@@ -45,6 +45,9 @@ export function quietDays(events = [], now = Date.now()) {
45
45
  continue
46
46
  }
47
47
  if (!HER_TYPES.includes(e?.type)) continue
48
+ // 聊天回合里的回复不是她自己动的,否则每天有人聊,这条兜底就永远等不到。
49
+ // selfmod 不跳:想改自己也是她自己动过,这里问的不是算不算信念。
50
+ if (Array.isArray(e.flags) && e.flags.includes('uncommitted')) continue
48
51
  const ts = tsMs(e)
49
52
  if (Number.isFinite(ts) && ts > last) last = ts
50
53
  }
@@ -67,6 +70,8 @@ export function freeWakeFor({ tableText = '', events = [], dk = '', now = Date.n
67
70
  // 摆料优先:材料在那儿,这一轮就该只有材料。
68
71
  if (String(tableText ?? '').trim()) return { kind: 'table', note: FREE_NOTE }
69
72
 
73
+ // 0.9.0:空醒有一个设置页开关(memory.selfWake)。关掉就只剩摆料,不再自己找事做。
74
+ if (config?.memory?.selfWake === false) return null
70
75
  const every = Number(config?.memory?.thinkEveryDays ?? THINK_EVERY_DAYS)
71
76
  if (!(every > 0)) return null
72
77
 
@@ -80,3 +85,17 @@ export function freeWakeFor({ tableText = '', events = [], dk = '', now = Date.n
80
85
 
81
86
  return { kind: 'think', note: FREE_NOTE }
82
87
  }
88
+
89
+ /**
90
+ * 这一轮还要不要摆世界动静与惦记清单。
91
+ *
92
+ * 2026-11-05 用户 m01128:table 那一轮连 B 站和桌子都去掉,只留便条、时间、
93
+ * 旧句子和「这一轮没有待办」——工具常量那一句留着,那不是活,是她世界里的常量。
94
+ * think 那一轮照旧:它本来就是「隔些日子空一次」,没有材料可给,世界动静是唯一的东西。
95
+ *
96
+ * @param {{kind:string}|null} free
97
+ * @returns {boolean}
98
+ */
99
+ export function keepWorldOnWake(free) {
100
+ return free?.kind !== 'table'
101
+ }
package/lib/home.js CHANGED
@@ -197,8 +197,16 @@ export async function ensureTalkSession(ctx, opts = {}) {
197
197
  * 现在先把「归档的那条」和「这个工作区里另外的活会话」分开判断:只要还有一条
198
198
  * 没归档的,就认它,而不是让整个家停摆。
199
199
  *
200
- * 取最后一条(宿主按创建顺序往 sessionIds 里追加,末尾即最近那条),但不做别的假设:
201
- * 拿不到 registry、sessionIds 不是数组、全是归档的,都退回空串,由调用方按原样拒绝。
200
+ * 取最前面那条(宿主建会话时是 `sessionIds: [新id, ...旧的]`,往**前**插,
201
+ * 所以头部才是最新那条),但不做别的假设:拿不到 registry、sessionIds 不是数组、
202
+ * 全是归档的,都退回空串,由调用方按原样拒绝。
203
+ *
204
+ * issue #33:这里以前取 `live[live.length - 1]`,注释还写着「宿主按创建顺序往
205
+ * sessionIds 里追加」——方向反了。结果是当前会话被归档、同一工作区还有别的活会话时,
206
+ * 认回来的是**最旧**那条,她于是坐进一条早就没在用的对话。
207
+ * 需要提醒的是:用户在侧栏拖动过顺序时 `live[0]` 是列表最上面那条,未必就是最新;
208
+ * 真要按时间严格排序,得问 `sessionController.list()`(`sessionListed` 那条路)。
209
+ * 这里只修「明显反了」的方向,不对侧栏顺序做额外假设。
202
210
  */
203
211
  function pickLiveSession(reg, wsId, avoid = '') {
204
212
  try {
@@ -208,7 +216,7 @@ function pickLiveSession(reg, wsId, avoid = '') {
208
216
  const live = ids
209
217
  .map((id) => String(id ?? ''))
210
218
  .filter((id) => id && id !== avoid && !archived.includes(id))
211
- return live.length ? live[live.length - 1] : ''
219
+ return live.length ? live[0] : ''
212
220
  } catch { return '' }
213
221
  }
214
222
 
@@ -684,6 +692,35 @@ export const LAST_WORDS_WAIT_MS = 60_000
684
692
  */
685
693
  export const WAKE_VERIFY_MS = 12_000
686
694
 
695
+ /**
696
+ * 换会话失败之后多久再试(2026-11-06)。
697
+ *
698
+ * 结账那条路上每次人说话都会 bumpTurns,`turns` 到了线就试着换。换失败时
699
+ * `turns` 不归零(这是对的:屋子确实还没换),于是下一轮又会试——宿主真
700
+ * 建不出会话时就成了每回一句话都重试一次。这里记下失败的时刻,冷却期内
701
+ * 直接跳过,不再打扰宿主,也不再往对话里发东西。
702
+ */
703
+ export const ROTATE_RETRY_MS = 5 * 60_000
704
+
705
+ /**
706
+ * 记一笔「这次没换成」。只写 home.json 的两个字段,任何失败都吞掉:
707
+ * 记不下来顶多是下次不回退避,不能让记账本身把错误放大。
708
+ */
709
+ async function noteRotateFail(root, why) {
710
+ const at = new Date().toISOString()
711
+ try {
712
+ await mutateHome(async () => {
713
+ const fresh = (await readJson(homeFile(root), null)) ?? {}
714
+ await writeAtomic(
715
+ homeFile(root),
716
+ JSON.stringify({ ...fresh, rotateFailAt: at, rotateFailWhy: String(why ?? '').slice(0, 160) }, null, 2),
717
+ )
718
+ })
719
+ } catch {
720
+ /* 记不下来就算了 */
721
+ }
722
+ }
723
+
687
724
  /** 她说「不用」的各种说法。命中就当没留。 */
688
725
  const DECLINE_RE = /^(不用|不想留|不想|不留|不了|算了|没事|没有|无|no|nope)[。.!!…\s]*$/i
689
726
 
@@ -789,11 +826,56 @@ async function askForLastWords(ctx, { sessionId, root, log } = {}) {
789
826
  return pickLastWords(say.join('\n'))
790
827
  }
791
828
 
829
+ // 同一个 root 只允许有一次「换房间」在飞(0.8.8 用户报的房间分裂):
830
+ // `/say` 的结账与 turns 的结账是两条独立路径,都可能在满 rotateAfter 时触发。
831
+ // 没有互斥时各自 sc.create 一条新会话:侧栏多出一间同名空房,先建的那条
832
+ // 还可能已经跑了一轮,从此成了看不到的孤儿;两条入口也各记各的轮数。
833
+ // 进程内用这条 promise 链单飞;跨进程由 withFileLock + home.json 的 sessionId 比对兜住。
834
+ const rotateInflight = new Map()
835
+
792
836
  /**
793
837
  * 换一个新会话:旧的改名留档,新的接管,原话搬过去。
794
- * @returns {Promise<{ok:boolean, sessionId:string, old?:string, why?:string}>}
838
+ *
839
+ * 0.8.8:并发调用合并成一次——后来者直接等前面那次的结果,不再各建一条。
840
+ * `from` 是调用方这句话真正投递进去的那条会话 id;home.json 已经指向别的会话,
841
+ * 说明另一条入口抢先换过了(或者人已经不在家记着的这条里说话),这次跳过不换。
842
+ *
843
+ * @returns {Promise<{ok:boolean, sessionId:string, old?:string, why?:string, skipped?:boolean}>}
795
844
  */
796
- export async function rotateTalkSession(ctx, { root = rootDir(), wsId, title = TITLE_SESSION, stamp, log } = {}) {
845
+ export async function rotateTalkSession(ctx, opts = {}) {
846
+ const root = opts.root ?? rootDir()
847
+ const key = String(root).toLowerCase()
848
+ const flying = rotateInflight.get(key)
849
+ if (flying) return flying
850
+ const p = (async () => {
851
+ try {
852
+ const res = await withFileLock(
853
+ sessionLockFile(root),
854
+ () => rotateTalkSessionOnce(ctx, { ...opts, root }),
855
+ opts.lockOpts,
856
+ )
857
+ if (res && res.locked === false) {
858
+ // 另一个进程正在换:它换完会把 sessionId 写进 home.json,这里读一眼就跳过。
859
+ const saved = await readJson(homeFile(root), null).catch(() => null)
860
+ const sid = String(saved?.sessionId ?? '')
861
+ const from = String(opts.from ?? '')
862
+ if (sid && (!from || sid !== from)) {
863
+ return { ok: true, sessionId: sid, old: String(saved?.rotatedFrom ?? ''), skipped: true, why: res.why }
864
+ }
865
+ return { ok: false, sessionId: '', why: res.why }
866
+ }
867
+ return res
868
+ } catch (err) {
869
+ return { ok: false, sessionId: '', why: `换会话的锁没拿到:${String(err?.message ?? err).slice(0, 120)}` }
870
+ }
871
+ })().finally(() => {
872
+ if (rotateInflight.get(key) === p) rotateInflight.delete(key)
873
+ })
874
+ rotateInflight.set(key, p)
875
+ return p
876
+ }
877
+
878
+ async function rotateTalkSessionOnce(ctx, { root = rootDir(), wsId, title = TITLE_SESSION, stamp, log, from = '' } = {}) {
797
879
  const sc = ctx.sessionController
798
880
  if (!sc?.create) return { ok: false, sessionId: '', why: '宿主没有会话服务' }
799
881
 
@@ -802,24 +884,72 @@ export async function rotateTalkSession(ctx, { root = rootDir(), wsId, title = T
802
884
  if (!wid) return { ok: false, sessionId: '', why: '没有工作区 id' }
803
885
 
804
886
  const old = String(saved.sessionId ?? '')
887
+ // 0.8.8:这条路径进来时屋子已经换过了(另一条入口抢先、或 concurrent 的
888
+ // 第二次调用在单飞窗口之外到)——不要再建第二条。
889
+ const fromId = String(from ?? '')
890
+ if (fromId && old && fromId !== old) {
891
+ log?.(`换会话跳过:这次要换的是 ${fromId},屋子已经指向 ${old}`)
892
+ return { ok: true, sessionId: old, old: String(saved.rotatedFrom ?? ''), skipped: true }
893
+ }
894
+ // 2026-11-06:建会话失败后冷却一段时间再试。
895
+ // 不冷却的话,结账每轮都会走到这里:`turns` 没清零,下一轮结账又到点,
896
+ // 于是每回一句话都重试一次(旧版还会每次都先问一遍「要不要留话」,见 2.5 步)。
897
+ // 宿主建不出会话多半是这类问题要待一会儿才好,5 分钟够它缓过来,也不至于
898
+ // 让屋子永远卡在旧会话里。
899
+ const failAt = Date.parse(String(saved.rotateFailAt ?? ''))
900
+ if (Number.isFinite(failAt) && Date.now() - failAt < ROTATE_RETRY_MS) {
901
+ return {
902
+ ok: false,
903
+ sessionId: '',
904
+ cooled: true,
905
+ why: `上次没换成(${String(saved.rotateFailWhy ?? '原因不明').slice(0, 80)}),${Math.ceil((ROTATE_RETRY_MS - (Date.now() - failAt)) / 60_000)} 分钟后再试`,
906
+ }
907
+ }
908
+
805
909
  // 2026-11-03(issue #19):原来 `new Date().toISOString().slice(0,10)` 取的是 **UTC 日期**,
806
910
  // 东八区晚上 8 点之后换会话,旧会话会被改名成「…·昨天的日期 之前」.
807
911
  // dateKey() 用的是 getFullYear/getMonth/getDate,本地钟。
808
912
  const when = stamp ?? dateKey()
809
913
 
914
+ // 1) 旧会话改名留档。失败不影响后面(改不了名也还是那条会话)。
915
+ if (old && typeof sc.rename === 'function') {
916
+ await sc.rename({ sessionId: old, title: `${title}·${when} 之前` }).catch(() => {})
917
+ }
918
+
919
+ // 2) 建新的
920
+ let sessionId = ''
921
+ try {
922
+ const created = await sc.create({ workspaceId: wid })
923
+ sessionId = String(created?.sessionId ?? created?.id ?? '')
924
+ } catch (err) {
925
+ await noteRotateFail(root, `建会话失败:${String(err?.message ?? err).slice(0, 160)}`)
926
+ return { ok: false, sessionId: '', why: `建会话失败:${String(err?.message ?? err).slice(0, 160)}` }
927
+ }
928
+ if (!sessionId) {
929
+ await noteRotateFail(root, '宿主建了会话但没给 id')
930
+ return { ok: false, sessionId: '', why: '宿主建了会话但没给 id' }
931
+ }
932
+ if (typeof sc.rename === 'function') await sc.rename({ sessionId, title }).catch(() => {})
933
+
810
934
  /**
811
- * 0) 换房间之前,先请旧会话里的她自己留一句话(0.8.7,用户 m00998)。
935
+ * 2.5) 换房间之前,先请旧会话里的她自己留一句话(0.8.7,用户 m00998)。
812
936
  *
813
937
  * 为什么要这一句:轮换之后新会话拿到的身份文档里全是**结论**,没有一句是
814
938
  * 「上一个房间里的我留下的」。于是她读到旧日记会说「像另一个人写的」。
815
939
  * 一句原话解决的不是记忆问题(原文一直都在),是**归属**问题——
816
940
  * 「那是你说的」这句话只有她本人说出来才算数。
817
941
  *
942
+ * **为什么放在建会话成功之后**(2026-11-06 修):这句话是往她正在用的那条
943
+ * 会话里发一条 prompt,会作为一条消息直接出现在他的对话窗口里。原来写在
944
+ * 第 0 步(建会话之前),于是只要 `sc.create()` 抛错/不回 id,这一问就已经
945
+ * 发出去了、轮换却失败了,`turns` 也不清零——下一轮结账又到点、又问一遍,
946
+ * 他那边就变成「每次她回完都弹这一句」。挪到建会话成功之后,换不成就不问。
947
+ *
818
948
  * **只把话发出去,不等她回**。轮换比这句话重要得多:
819
949
  * 上一版是 `await askForLastWords(...)`,宿主不派 `turn/end` 时会一直等到
820
950
  * 60 秒超时,把整个轮换(以及叫醒新会话)全卡住。所以这里只拿到一个
821
- * Promise,改名/建会话/写 home 照常往下走;等这句话回来了再补写进
822
- * home.json 并刷一次身份文档(她那一下的人设段是新房间里第一次读到的)。
951
+ * Promise,写 home 照常往下走;等这句话回来了再补写进 home.json 并刷一次
952
+ * 身份文档(她那一下的人设段是新房间里第一次读到的)。
823
953
  *
824
954
  * 三个约束:
825
955
  * - 失败绝不影响轮换:没有 prompt / 超时 / 她说「不用」都只是没有这一节;
@@ -837,22 +967,6 @@ export async function rotateTalkSession(ctx, { root = rootDir(), wsId, title = T
837
967
  pendingWords = null
838
968
  }
839
969
 
840
- // 1) 旧会话改名留档。失败不影响后面(改不了名也还是那条会话)。
841
- if (old && typeof sc.rename === 'function') {
842
- await sc.rename({ sessionId: old, title: `${title}·${when} 之前` }).catch(() => {})
843
- }
844
-
845
- // 2) 建新的
846
- let sessionId = ''
847
- try {
848
- const created = await sc.create({ workspaceId: wid })
849
- sessionId = String(created?.sessionId ?? created?.id ?? '')
850
- } catch (err) {
851
- return { ok: false, sessionId: '', why: `建会话失败:${String(err?.message ?? err).slice(0, 160)}` }
852
- }
853
- if (!sessionId) return { ok: false, sessionId: '', why: '宿主建了会话但没给 id' }
854
- if (typeof sc.rename === 'function') await sc.rename({ sessionId, title }).catch(() => {})
855
-
856
970
  // 3) 原话搬运
857
971
  // 2026-10-31(issue #14 第二报):这里以前是**整个覆盖写**,只留七个字段,
858
972
  // 于是 `preset` / `title` / `wokeFor` / `hearAt` / `adoptedAt` 换一次会话全丢:
@@ -880,6 +994,9 @@ export async function rotateTalkSession(ctx, { root = rootDir(), wsId, title = T
880
994
  }
881
995
  delete next.status
882
996
  delete next.archivedAt
997
+ // 换成了,把上次失败的记账清掉(否则下一次到点时会被误判成还在冷却期)
998
+ delete next.rotateFailAt
999
+ delete next.rotateFailWhy
883
1000
  // 0.8.7:这一轮先清掉旧的那句。她要是留了,后台回来之后再补写
884
1001
  // (见下面 pendingWords.then)。留着上一轮那句会让新房间读到一句
885
1002
  // 不是这次说的「最后一句」——比没有更糟。
package/lib/inbox.js CHANGED
@@ -196,9 +196,14 @@ export async function writeInboxFile(root = rootDir(), { limit = 60 } = {}) {
196
196
  return file
197
197
  }
198
198
 
199
- /** 事件流里已经存在的她自己写的话(供 say/wake 判断"这轮她开没开口") */
199
+ /**
200
+ * 事件流里已经存在的她自己写的话(供 say/wake 判断"这轮她开没开口")
201
+ *
202
+ * 不收 uncommitted:那是别处聊天回合结束时补写的,没认领,也不是这一轮说的。
203
+ * 群里 /say 最长等 75 秒,侧栏回合正好结束时,私聊原话会被当成她在群里的回复。
204
+ */
200
205
  export function newHerEventsSince(events, sinceId) {
201
206
  const idx = sinceId ? events.findIndex((e) => e.id === sinceId) : events.length
202
207
  const from = idx >= 0 ? idx + 1 : 0
203
- return events.slice(from).filter((e) => SPEAK_TYPES.has(e.type))
208
+ return events.slice(from).filter((e) => SPEAK_TYPES.has(e.type) && !(Array.isArray(e.flags) && e.flags.includes('uncommitted')))
204
209
  }
package/lib/index.js CHANGED
@@ -25,7 +25,6 @@ import { registerBringBack } from './keep.js'
25
25
  import { registerAsk } from './ask.js'
26
26
  import { registerDream } from './dream.js'
27
27
  import { registerProactive } from './proactive.js'
28
- import { devModeSync } from './devmode.js'
29
28
  import { registerObserve } from './observe.js'
30
29
  import { startScheduler } from './scheduler.js'
31
30
  import { registerTurns } from './turns.js'
@@ -47,7 +46,7 @@ import { logBoot, makeLogger } from './log.js'
47
46
  export const name = 'dsh-fish'
48
47
 
49
48
  /** 版本号:只用于日志导出(排查时第一件事是确认跑的是哪一版)。 */
50
- export const VERSION = '0.8.9'
49
+ export const VERSION = '0.9.1'
51
50
 
52
51
  // ⚠️ 一个数组写全,不要拆成对象
53
52
  // 2026-10-30(m02961):`userQuestions` **不再写进 inject**。写进 inject 的东西缺一个,
@@ -223,10 +222,9 @@ export function apply(ctx, baseConfig = {}) {
223
222
  herDir,
224
223
  extra: config?.tools?.allow ?? [],
225
224
  enabled: config?.tools?.restrict !== false,
226
- // 本机实验工具(m02837):与注册、提示词共用 devModeSync 一个判据。
227
- dream: devModeSync(root),
228
- // 主动说话(m04610):her_say 同样只在 .dev 下进白名单。
229
- proactive: devModeSync(root),
225
+ // 0.9.0 起公开:梦与主动说话不再是本机实验。
226
+ dream: true,
227
+ proactive: true,
230
228
  log: (...a) => logger.info(...a),
231
229
  onResult: (r) => { restrictState = r },
232
230
  })
@@ -355,14 +353,12 @@ export function apply(ctx, baseConfig = {}) {
355
353
  // 0.7.6(2026-10-30 m01274):请求事件。她有事想征得同意时弹一张卡,
356
354
  // 每天 1 次、每周 3 次;只有 time / rest 真会改东西(见 lib/ask.js)。
357
355
  () => registerAsk(ctx, { root, tenant: tenancy }),
358
- // 0.8.1 本机实验(2026-10-30 m02837):梦 A 案——醒来记一句。
359
- // **只在 .dev 存在时注册**:公开版里她既没有这个工具,也不该在身份里读到它。
360
- ...(devModeSync(root) ? [() => registerDream(ctx, { root, tenant: tenancy })] : []),
361
- // 0.8.1 本机实验(2026-10-30 m04610):主动说话——她自己判断有没有话想说。
362
- // 同样只在 .dev 下注册;公开版没有 her_say,也没有空手回合。
356
+ // 0.8.1 起(2026-10-30 m02837):梦 A 案——醒来记一句。0.9.0 起公开。
357
+ () => registerDream(ctx, { root, tenant: tenancy }),
358
+ // 0.8.1 起(2026-10-30 m04610):主动说话——她自己判断有没有话想说。0.9.0 起公开。
363
359
  // 2026-10-30 真机:这里原来只有 { root, log },guardWrite 拿到 undefined 的 state,
364
360
  // checkCaller(undefined) 每次都判「不在这个家」;写入守卫要的是 tenancy。
365
- ...(devModeSync(root) ? [() => registerProactive(ctx, { root, tenant: tenancy, log: (...a) => logger.info(...a) })] : []),
361
+ () => registerProactive(ctx, { root, tenant: tenancy, log: (...a) => logger.info(...a) }),
366
362
  ]) {
367
363
  try {
368
364
  const d = reg()
package/lib/interest.js CHANGED
@@ -7,12 +7,13 @@
7
7
  *
8
8
  * 三条边界:
9
9
  * 1. **只读她说过的话**(`HER_TYPES`),不去猜主人喜欢什么——兴趣是她的,不是画像。
10
+ * 只留证的不算(isProofOnly):「好的,我看一下」说十遍也不是她最近总回到的事。
10
11
  * 2. **正文里没有分数、没有次数**。权重只决定摆哪一条(四条黑名单第三条)。
11
12
  * 3. **会腻**:同一件事刚在 24 小时内出现过,这一次的分量打对折;
12
13
  * 两周前的老话题按 14 天半衰——越久没提,越不像「最近总回到」。
13
14
  */
14
15
 
15
- import { similarity } from './derive.js'
16
+ import { isProofOnly, similarity } from './derive.js'
16
17
  import { tsMs, HER_TYPES } from './schema.js'
17
18
 
18
19
  /** 至少在几个不同时刻出现过,才算「反复」。 */
@@ -52,6 +53,7 @@ export function topicsOf(events = []) {
52
53
  const out = []
53
54
  for (const e of events) {
54
55
  if (!HER_TYPES.includes(e?.type)) continue
56
+ if (isProofOnly(e)) continue
55
57
  const ts = tsMs(e)
56
58
  if (!Number.isFinite(ts)) continue
57
59
  for (const s of sentences(e.text)) {
@@ -103,13 +105,33 @@ export function interestOf(events = [], now = Date.now(), { minHits = INTEREST_M
103
105
  }
104
106
 
105
107
  /**
106
- * 摆到桌上的那一行。没有就返回空串(调用方直接不 push)。
108
+ * 把一整句话收成一个能读的短标签(2026-11-05 用户 m02562:原来的整句看不懂)。
109
+ *
110
+ * 四步,每一步都只在做「去掉不是主题的部分」,不改她自己的说法:
111
+ * 1. 去掉开头的旁注(`(她主动说)`这种,是我们加的标注,不是她的话);
112
+ * 2. 内层引号换成双引号,避免 `「「` 嵌套;
113
+ * 3. 断在第一个逗号——后面的多半是她对这句话的解释;
114
+ * 4. 太长再截断。
107
115
  */
116
+ export const TOPIC_LABEL_MAX = 24
117
+
118
+ export function topicLabel(text, max = TOPIC_LABEL_MAX) {
119
+ let s = String(text ?? '').trim()
120
+ s = s.replace(/^([^)]*)\s*/, '')
121
+ s = s.replace(/「/g, '“').replace(/」/g, '”')
122
+ s = s.split(/[,,。;;]/)[0].trim()
123
+ s = s.replace(/^[“”"']+|[“”"']+$/g, '')
124
+ if (!s) return ''
125
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s
126
+ }
127
+
128
+ /** 摆到桌上的那一行。没有就返回空串(调用方直接不 push)。 */
108
129
  export function renderInterest(items = []) {
109
130
  const first = Array.isArray(items) ? items[0] : null
110
131
  if (!first?.text) return ''
111
- const text = first.text.length > INTEREST_TEXT_MAX ? `${first.text.slice(0, INTEREST_TEXT_MAX - 1)}…` : first.text
112
- return `最近你总回到「${text}」这件事上。`
132
+ const label = topicLabel(first.text)
133
+ if (!label) return ''
134
+ return `你最近总说到「${label}」。`
113
135
  }
114
136
 
115
137
  /** 把兴趣当劲儿的一笔冲动:有就返回 true。 */
package/lib/log.js CHANGED
@@ -218,6 +218,27 @@ export function redactConfig(cfg) {
218
218
  return clone
219
219
  }
220
220
 
221
+ /**
222
+ * 事件流尾巴的一行,进导出包之前过一遍。
223
+ *
224
+ * 设置页答应了「不含正文」「没有她说过的话」,导出又是让人转手交给别的 AI 的。
225
+ * 用白名单而不是删 text:refuse.quote、ask.subject 也带原话,以后加的字段也可能带。
226
+ * meta 不走白名单:`watched:` 这类计数和失败记录,正是这段尾巴存在的理由。它和别的行
227
+ * 一样还要过 scrub。
228
+ */
229
+ function exportEventLine(line) {
230
+ let ev = null
231
+ try {
232
+ ev = JSON.parse(line)
233
+ } catch { /* 下面按坏行算 */ }
234
+ // 解析不了、不是对象(裸字符串之类),或没有 type 这个基本字段:不知道里面是什么,只报长度
235
+ if (!ev || typeof ev !== 'object' || Array.isArray(ev) || typeof ev.type !== 'string') return `(坏行,长 ${line.length} 字符,内容不导出)`
236
+ if (ev.type === 'meta') return line
237
+ const { id, ts, type } = ev
238
+ const flags = Array.isArray(ev.flags) ? ev.flags : []
239
+ return JSON.stringify({ id, ts, type, flags, textLen: typeof ev.text === 'string' ? ev.text.length : 0 })
240
+ }
241
+
221
242
  /**
222
243
  * 导出包:一次给 AI 看够。
223
244
  * 顺序按"排查时最先想看什么"排:环境 → 模型 → 自检 → 工具面 → 事件流 → 日志 → 宿主报错。
@@ -286,7 +307,7 @@ export function buildBundle(root, {
286
307
  if (eventLines > 0) {
287
308
  const ev = tailEvents(root, { lines: eventLines })
288
309
  out.push('', `--- 事件流(最近 ${ev.lines.length} 条,文件共 ${ev.bytes} 字节)---`)
289
- out.push(ev.exists ? (ev.lines.length ? ev.lines.map((l) => scrub(l)).join('\n') : '(空)') : '(还没有 events.jsonl)')
310
+ out.push(ev.exists ? (ev.lines.length ? ev.lines.map((l) => scrub(exportEventLine(l))).join('\n') : '(空)') : '(还没有 events.jsonl)')
290
311
  }
291
312
 
292
313
  const log = tailLog(root, { lines })