@ssamurias/dsh-fish 0.8.9 → 0.9.0

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 (49) hide show
  1. package/AGENTS.md +32 -9
  2. package/CHANGELOG.md +84 -2
  3. package/LICENSE +7 -0
  4. package/README.md +64 -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/THIRD-PARTY-NOTICES.md +3 -0
  16. package/lib/away.js +7 -2
  17. package/lib/channels.js +53 -13
  18. package/lib/client.js +99 -49
  19. package/lib/compact.js +26 -1
  20. package/lib/derive.js +10 -6
  21. package/lib/desk.js +1 -1
  22. package/lib/dream.js +3 -0
  23. package/lib/dreamtable.js +15 -0
  24. package/lib/drive.js +44 -7
  25. package/lib/freewake.js +17 -1
  26. package/lib/home.js +54 -2
  27. package/lib/inbox.js +7 -2
  28. package/lib/index.js +8 -12
  29. package/lib/interest.js +23 -3
  30. package/lib/log.js +22 -1
  31. package/lib/look.js +2 -2
  32. package/lib/note.js +35 -13
  33. package/lib/notify.js +2 -0
  34. package/lib/observe.js +13 -7
  35. package/lib/outing.js +2 -0
  36. package/lib/persona.js +2 -1
  37. package/lib/proactive.js +12 -7
  38. package/lib/say.js +104 -58
  39. package/lib/scheduler.js +67 -37
  40. package/lib/selfcheck.js +2 -3
  41. package/lib/selfdoc.js +3 -6
  42. package/lib/selfupdate.js +176 -41
  43. package/lib/settings.js +28 -2
  44. package/lib/turns.js +41 -3
  45. package/lib/update.js +14 -0
  46. package/lib/upgrade.js +19 -3
  47. package/lib/voices.js +2 -0
  48. package/package.json +4 -2
  49. package/scripts/fix-legacy-link.mjs +147 -0
package/lib/client.js CHANGED
@@ -12,7 +12,10 @@
12
12
  * 没有任何指向她本人的控件 —— 那是 B §8 的红线。
13
13
  */
14
14
  window.__ModuleLoader__.load({
15
- id: 'dsh-fish',
15
+ // 这个 id 必须**等于 package.json 的包名**,不是短名:client-modules 用 loader
16
+ // 条目名(完整包标识)去解析浏览器 bundle,注册时名字对不上,这一节就静默挂不上
17
+ // ——宿主那半边照常跑,设置页却什么也不显示(2026-11-05 改名 @ssamurias/dsh-fish 后踩过)。
18
+ id: '@ssamurias/dsh-fish',
16
19
  factory: (require) => {
17
20
  const module = { exports: {} }
18
21
  const exports = module.exports
@@ -380,6 +383,12 @@ window.__ModuleLoader__.load({
380
383
  const [proactiveOn, setProactiveOn] = useState(false)
381
384
  const [proactiveMax, setProactiveMax] = useState(2)
382
385
  const [proactiveIdle, setProactiveIdle] = useState(6)
386
+ // 2026-11-05(用户 m02634):梦 / 主动性 / 兴趣 三个开关,默认全开。
387
+ const [driveOn, setDriveOn] = useState(true)
388
+ const [interestOn, setInterestOn] = useState(true)
389
+ const [dreamEnabled, setDreamEnabled] = useState(true)
390
+ // 空醒开关(0.9.0 公开):醒来时没派活、桌上也没东西,她可以自己找点事做。
391
+ const [selfWakeOn, setSelfWakeOn] = useState(true)
383
392
  const [avoidText, setAvoidText] = useState('')
384
393
  // 刷站(全是环境参数:她今天能不能出门、一次看见几条,不是她爱看什么)
385
394
  const [browseOn, setBrowseOn] = useState(true)
@@ -588,6 +597,10 @@ window.__ModuleLoader__.load({
588
597
  setProactiveMax(Number(pr.maxPerDay ?? 2))
589
598
  setProactiveIdle(Number(pr.minIdleHours ?? 6))
590
599
  }
600
+ setDriveOn(s.config?.drive?.enabled !== false)
601
+ setInterestOn(s.config?.interest?.enabled !== false)
602
+ setDreamEnabled(s.config?.dream?.enabled !== false)
603
+ setSelfWakeOn(s.config?.memory?.selfWake !== false)
591
604
 
592
605
  // 不再拉 /events(2026-11-02,用户 m00335):这里取回来的 e.rows 只进了
593
606
  // setEvents,全文件没有一处渲染它——每 30 秒白拉 25 条事件(九成是后台记账)。
@@ -667,6 +680,10 @@ window.__ModuleLoader__.load({
667
680
  setBrowseDanmaku(Number(cfg.browse?.danmaku ?? 6))
668
681
  setBiliMinGap(Number(cfg.bili?.minIntervalMs ?? 800))
669
682
  setBiliJitter(Number(cfg.bili?.jitterMs ?? 500))
683
+ setDriveOn(cfg.drive?.enabled !== false)
684
+ setInterestOn(cfg.interest?.enabled !== false)
685
+ setDreamEnabled(cfg.dream?.enabled !== false)
686
+ setSelfWakeOn(cfg.memory?.selfWake !== false)
670
687
  setNotifyOn(cfg.notify?.enabled === true)
671
688
  setCommentOn(cfg.comment?.enabled === true)
672
689
  setCommentPerDay(Number(cfg.comment?.perDay ?? 1))
@@ -881,6 +898,14 @@ window.__ModuleLoader__.load({
881
898
  } catch (e) { setSaveFail(e.message) }
882
899
  }
883
900
 
901
+ // 顶层那三个开关(梦 / 主动性 / 兴趣):各写各的组,互不覆盖。
902
+ const patchGroup = async (key, patch) => {
903
+ try {
904
+ await jpost('/config', { [key]: patch })
905
+ flashSaved()
906
+ } catch (e) { setSaveFail(e.message) }
907
+ }
908
+
884
909
  // 改完就生效:任何一项变了,停手 700ms 就写盘(连点加减号也只写一次)。
885
910
  const cfgSig = JSON.stringify(buildConfig())
886
911
  useEffect(() => {
@@ -2119,21 +2144,48 @@ window.__ModuleLoader__.load({
2119
2144
  hint: '超过这么多天没说话,她会记下「你不在」。' }))),
2120
2145
  // 主动说话(0.8.1 本机实验):只有 .dev 下才出现。频率是上限,不是任务——
2121
2146
  // 开关开着也不代表她每天都会说,说不说由她自己判断(见 lib/proactive.js)。
2122
- state?.dev === true ? group('主动说话(本机实验)',
2123
- '她想说话的时候可以自己开口,不是被提醒推着说。这里调的是「最多说几次」和「多久没说话才可能开口」,不是命令她每天必须说。',
2147
+ // 主动搭话(0.8.1 起,0.9.0 公开):只留三件跟「开口」直接有关的事。
2148
+ // 劲儿与兴趣是另一套东西,各自一张卡——混在一张卡里,用户分不清哪一项在管什么。
2149
+ group('主动搭话',
2150
+ '她可以自己找你说话,不用你先开口。这里调的是上限:开着也可能好几天不说,那是她的判断,不是坏了。',
2124
2151
  h('div', { className: 'grid2' },
2125
2152
  Toggle({ label: '允许她主动开口', on: proactiveOn,
2126
2153
  onChange: (v) => { setProactiveOn(v); patchProactive({ enabled: v }) },
2127
- hint: '关着就等于没有这个功能。开着也可能好几天不说——那是她的判断,不是故障。' }),
2154
+ hint: '关掉就完全没有这个功能。' }),
2128
2155
  Sel({ label: '一天最多几次', value: proactiveMax,
2129
2156
  onChange: (v) => { setProactiveMax(v); patchProactive({ maxPerDay: Number(v) }) },
2130
2157
  options: [0, 1, 2, 3, 4, 5, 6],
2131
2158
  fmt: (v) => (Number(v) === 0 ? '0 次 · 不主动' : `最多 ${v} 次`),
2132
- hint: '上限,不是配额。到了上限只是不能再开新的空手回合,第二天照常。' }),
2133
- Num({ label: '多久没说话才可能开口', value: proactiveIdle,
2159
+ hint: '上限,不是任务。到了上限只是今天不再多开一轮,第二天照常。' }),
2160
+ Num({ label: '你多久没说话,她才可能开口', value: proactiveIdle,
2134
2161
  onChange: (v) => { setProactiveIdle(v); patchProactive({ minIdleHours: Number(v) }) },
2135
2162
  min: 1, max: 24, unit: '小时',
2136
- hint: '越久没说话,开口的概率越高(有上限)。刚聊完不会立刻再开一次。' }))) : null,
2163
+ hint: '越久没说话,她越可能开口(有上限)。刚聊完不会马上又来找你。' }))),
2164
+ // 主观能动性:不是「定时叫她说话」,而是她心里慢慢攒的一股劲。
2165
+ group('主观能动性',
2166
+ '她心里会慢慢攒一股想动的劲:越久没自己动过、桌上越有值得看的东西,攒得越快。攒够了才会自己醒一次——不按钟点,也不是每天必须。',
2167
+ h('div', { className: 'grid2' },
2168
+ Toggle({ label: '让她自己攒这股劲', on: driveOn,
2169
+ onChange: (v) => { setDriveOn(v); patchGroup('drive', { enabled: v }) },
2170
+ hint: '关掉:她不会自己开一轮,只在你叫她、或者日程到点时才醒。已经发生的事不受影响。' }))),
2171
+ // 兴趣与惦记:她醒来时桌上摆的那几样东西。
2172
+ group('兴趣',
2173
+ '她醒来的时候,桌上会摆几样她自己的东西:问过还没答案的问题、翻到过的旧句子、你回没回她留的那句话、最近总说到的那件事。这些只给她看,不会变成待办。',
2174
+ h('div', { className: 'grid2' },
2175
+ Toggle({ label: '醒来时把这些摆出来', on: interestOn,
2176
+ onChange: (v) => { setInterestOn(v); patchGroup('interest', { enabled: v }) },
2177
+ hint: '关掉:只叫醒,桌上不放东西。' })),
2178
+ h('div', { className: 'grid2' },
2179
+ Toggle({ label: '醒着没事时,自己找点事做', on: selfWakeOn,
2180
+ onChange: (v) => { setSelfWakeOn(v); patchGroup('memory', { selfWake: v }) },
2181
+ hint: '她醒来时没有派活、桌上也没有东西,可以自己翻翻旧句子、写点什么,或者什么都不做。关掉只影响「自己找事做」这一种醒来。' }))),
2182
+ // 梦:独立一张卡(用户 m02765 要求从「日记」里拿出来——它跟日记不是一件事)。
2183
+ group('做梦',
2184
+ '她醒来可以记一句梦。梦只能用她自己说过的话拼出来,不会改任何设置,也不算成长。',
2185
+ h('div', { className: 'grid2' },
2186
+ Toggle({ label: '允许她记梦', on: dreamEnabled,
2187
+ onChange: (v) => { setDreamEnabled(v); patchGroup('dream', { enabled: v }) },
2188
+ hint: '关掉后不再记新的,已经记下的一条不删。' }))),
2137
2189
  group('日记',
2138
2190
  '她每天会写日记。打开下面那个开关,你就能看。',
2139
2191
  h('div', { className: 'grid2' },
@@ -2287,6 +2339,42 @@ window.__ModuleLoader__.load({
2287
2339
  ? h('p', { className: 'empty' }, '读取中…')
2288
2340
  : h('pre', null, esc(diaryCache.current[diaryCur])))),
2289
2341
 
2342
+ // ---- 梦(2026-10-30 m02837;0.9.0 起对所有人显示;2026-11-05 用户 m03247 搬到住所页,和日记、小纸条放一起)----
2343
+ // 0.9.0 拆了 .dev 门:her_dream 对所有人注册,`/dream` 也不再 404,所以这张卡不再按 dev 藏。
2344
+ // 2026-11-05:改成「昨夜」一张卡——梦不是她的成长数据,是一句给她自己和给你看的话。
2345
+ // 卡片右下角把她引用的那几行原文摆出来,让你一眼看见这句梦是从哪儿长出来的。
2346
+ group('她做过的梦',
2347
+ '她偶尔会把真实发生过的事重新拼成一句梦;不算成长,也不会被当成新的记忆。',
2348
+ !dreams ? h('p', { className: 'dim' }, '还没读到。')
2349
+ : !(dreams.items ?? []).length ? h('p', { className: 'dim' }, '她还没记过梦。')
2350
+ : (() => {
2351
+ const list = dreams.items ?? []
2352
+ const latest = list[list.length - 1]
2353
+ const older = list.slice(0, -1).reverse().slice(0, 5)
2354
+ const when = (ts) => {
2355
+ const d = new Date(ts)
2356
+ if (!Number.isFinite(d.getTime())) return ''
2357
+ const hours = (Date.now() - d.getTime()) / 3_600_000
2358
+ return hours >= 0 && hours <= 30 ? '昨夜' : `${d.getMonth() + 1} 月 ${d.getDate()} 日`
2359
+ }
2360
+ return h('div', null,
2361
+ h('div', { className: 'hint', style: { margin: '0 0 4px' } }, when(latest.ts)),
2362
+ h('div', { style: { fontSize: '15px', lineHeight: 1.8 } }, latest.text),
2363
+ (latest.sources ?? []).length
2364
+ ? h('div', { style: { marginTop: '10px' } },
2365
+ h('div', { className: 'dim', style: { margin: '0 0 3px' } }, '这句话是从这几行长出来的'),
2366
+ ...latest.sources.map((s) => h('div', { className: 'dim', key: s.id },
2367
+ s.text ? `「${s.text}」` : `(这条已经翻不到了:${s.id})`)))
2368
+ : null,
2369
+ older.length
2370
+ ? h('div', { style: { marginTop: '12px' } },
2371
+ h('div', { className: 'dim' }, `更早的 ${list.length - 1} 条`),
2372
+ ...older.map((d) => h('div', { key: d.id, style: { marginTop: '6px' } },
2373
+ h('div', { className: 'hint', style: { margin: '0 0 2px' } }, `${d.day} · 引用了 ${(d.refs ?? []).length} 件`),
2374
+ h('div', null, d.text))))
2375
+ : null)
2376
+ })),
2377
+
2290
2378
  inboxCard,
2291
2379
 
2292
2380
  group('本插件工作自检',
@@ -2672,44 +2760,6 @@ window.__ModuleLoader__.load({
2672
2760
  '体检只看账本,不评价对错。两句话对不上不是错误——都可能是她当时真心这么想;快忘掉也不代表不重要,只是还没被反复提起或检验。')),
2673
2761
  ),
2674
2762
 
2675
- // ---- 梦(2026-10-30 m02837,本机实验 A 案)----
2676
- // 只在 .dev 下渲染:公开版没有 her_dream 这个工具,`/dream` 也直接 404。
2677
- // 2026-11-05:改成「昨夜」一张卡——梦不是她的成长数据,是一句给她自己和给他看的话。
2678
- // 卡片右下角把她引用的那几行原文摆出来,让他一眼看见这句梦是从哪儿长出来的。
2679
- state?.dev === true
2680
- ? group('梦(本机实验)',
2681
- '只在这台机器的开发副本里显示。她醒来后可以把真实发生过的事重新拼成一句梦;不算成长,也不会被当成新的记忆。',
2682
- !dreams ? h('p', { className: 'dim' }, '还没读到。')
2683
- : !(dreams.items ?? []).length ? h('p', { className: 'dim' }, '她还没记过梦。')
2684
- : (() => {
2685
- const list = dreams.items ?? []
2686
- const latest = list[list.length - 1]
2687
- const older = list.slice(0, -1).reverse().slice(0, 5)
2688
- const when = (ts) => {
2689
- const d = new Date(ts)
2690
- if (!Number.isFinite(d.getTime())) return ''
2691
- const hours = (Date.now() - d.getTime()) / 3_600_000
2692
- return hours >= 0 && hours <= 30 ? '昨夜' : `${d.getMonth() + 1} 月 ${d.getDate()} 日`
2693
- }
2694
- return h('div', null,
2695
- h('div', { className: 'hint', style: { margin: '0 0 4px' } }, when(latest.ts)),
2696
- h('div', { style: { fontSize: '15px', lineHeight: 1.8 } }, latest.text),
2697
- (latest.sources ?? []).length
2698
- ? h('div', { style: { marginTop: '10px' } },
2699
- h('div', { className: 'dim', style: { margin: '0 0 3px' } }, '这句话是从这几行长出来的'),
2700
- ...latest.sources.map((s) => h('div', { className: 'dim', key: s.id },
2701
- s.text ? `「${s.text}」` : `(这条已经翻不到了:${s.id})`)))
2702
- : null,
2703
- older.length
2704
- ? h('div', { style: { marginTop: '12px' } },
2705
- h('div', { className: 'dim' }, `更早的 ${list.length - 1} 条`),
2706
- ...older.map((d) => h('div', { key: d.id, style: { marginTop: '6px' } },
2707
- h('div', { className: 'hint', style: { margin: '0 0 2px' } }, `${d.day} · 引用了 ${(d.refs ?? []).length} 件`),
2708
- h('div', null, d.text))))
2709
- : null)
2710
- })())
2711
- : null,
2712
-
2713
2763
  // ---- 原来这里的「信箱」卡 2026-10-29(用户 m02105)搬到住所页,改名「她写的小纸条」----
2714
2764
  )
2715
2765
 
@@ -3018,7 +3068,7 @@ window.__ModuleLoader__.load({
3018
3068
  const startInstall = async (version) => {
3019
3069
  const v = version || dl?.version
3020
3070
  if (!v) return
3021
- if (!window.confirm(`安装会先关掉 DeepSeek Harness,再由后台脚本替换插件、装完自动重开。没发出去的话先发完。现在装 v${v}?`)) return
3071
+ if (!window.confirm(`安装会先关掉 DeepSeek Harness,装完自己重开——不会弹任何窗口,所以中途看到它没了不用找它。没发出去的话先发完。现在装 v${v}?`)) return
3022
3072
  setDl({ ...dl, version: v, phase: 'installing', note: '正在准备安装…' })
3023
3073
  try {
3024
3074
  const r = await jpost('/update/install', { version: v })
@@ -3026,10 +3076,10 @@ window.__ModuleLoader__.load({
3026
3076
  setDl({ ...dl, version: v, phase: 'failed', error: r.error || '安装没能开始。' })
3027
3077
  return
3028
3078
  }
3029
- setDl({ ...dl, version: v, phase: 'installing', note: r.note || '正在关闭应用…' })
3079
+ setDl({ ...dl, version: v, phase: 'installing', note: r.note || '正在关闭应用…装完会自己打开,中间不会有窗口。' })
3030
3080
  } catch {
3031
3081
  // 应用可能在响应发回来之前就退了——这时候报"失败"是错的。
3032
- setDl({ ...dl, version: v, phase: 'installing', note: '正在关闭应用…' })
3082
+ setDl({ ...dl, version: v, phase: 'installing', note: '正在关闭应用…装完会自己打开,中间不会有窗口。' })
3033
3083
  }
3034
3084
  }
3035
3085
 
@@ -3047,7 +3097,7 @@ window.__ModuleLoader__.load({
3047
3097
  }))
3048
3098
 
3049
3099
  const aboutCard = group('关于',
3050
- '这个插件是从 GitHub 装的,装完之后没有东西会提醒你作者又发了新版——想查就点一下。',
3100
+ '这个插件是从 npm 或 GitHub 装的,装完之后没有东西会提醒你作者又发了新版——想查就点一下。',
3051
3101
  h('div', null,
3052
3102
  rowTip('当前版本', curVer || '读不到',
3053
3103
  '插件自己记的版本号,和 GitHub 上 tag 对应的那个。'),
package/lib/compact.js CHANGED
@@ -185,12 +185,30 @@ export function planWakeCompaction(events = [], { startSeq, endSeq, rpcId = '' }
185
185
  * `done` 解析为 `{ ok, spoke, startSeq, endSeq, events, session, why? }`;
186
186
  * `ok:false` 表示没等到闭合区间(超时/投递没出现),调用方只记账不折。
187
187
  */
188
+ /**
189
+ * 她这句是不是「没什么要说的」(2026-11-05,用户 m02937)。
190
+ *
191
+ * 一个回合总得产出点什么,所以「不想说」在文本界面上常常变成一句元话语。
192
+ * 这不是开口:记账时按静默处理、整轮折掉。判据故意做窄——短、且开头就交代
193
+ * 「这一轮/今天没什么要说的」,避免把真话误判。
194
+ *
195
+ * @param {string} text
196
+ * @returns {boolean}
197
+ */
198
+ export function looksLikeNothingSaid(text) {
199
+ const t = String(text ?? '').trim()
200
+ if (!t || t.length > 60) return false
201
+ if (/^(这一轮|这一回合|今天|这会儿|这次)[^。!?]{0,30}(没什么|没有|没)(什么)?(可)?(要|想|好)?说/.test(t)) return true
202
+ return /^(没什么|没有)(什么)?(可)?(想|要)?说的/.test(t) && t.length <= 40
203
+ }
204
+
188
205
  export function followWake(ctx, { rpcId = '', timeoutMs = WAKE_FOLLOW_TIMEOUT_MS, log } = {}) {
189
206
  const rid = String(rpcId ?? '')
190
207
  const events = []
191
208
  let session = null
192
209
  let startSeq = -1
193
210
  let spoke = false
211
+ let said = ''
194
212
  let settled = false
195
213
  let off = null
196
214
  let timer = null
@@ -216,6 +234,7 @@ export function followWake(ctx, { rpcId = '', timeoutMs = WAKE_FOLLOW_TIMEOUT_MS
216
234
  resolveDone({
217
235
  ok: false,
218
236
  spoke,
237
+ said,
219
238
  startSeq,
220
239
  endSeq: -1,
221
240
  events,
@@ -256,7 +275,13 @@ export function followWake(ctx, { rpcId = '', timeoutMs = WAKE_FOLLOW_TIMEOUT_MS
256
275
  return
257
276
  }
258
277
  events.push(event)
259
- if (String(event?.type ?? '') === 'assistant/message' && assistantText(event?.data)) spoke = true
278
+ if (String(event?.type ?? '') === 'assistant/message') {
279
+ const text = assistantText(event?.data)
280
+ if (text) {
281
+ spoke = true
282
+ said = said ? said + String.fromCharCode(10) + text : text
283
+ }
284
+ }
260
285
  } catch (err) {
261
286
  log?.('compact · 事件处理失败', err?.message ?? err)
262
287
  }
package/lib/derive.js CHANGED
@@ -381,18 +381,20 @@ export function deriveSelf(events = [], opts = {}) {
381
381
  break
382
382
 
383
383
  case 'utterance': {
384
- // 她试过改自己:落库留证,但不计入信念(B §5,对应自我服务偏差)
385
- // 双保险:落库时打的 flag 与文本复检,任一命中即算(防止旧数据/手工注入绕过)
386
- if (flags.includes('selfmod') || detectSelfMod(ev.text)) {
387
- selfmod.push({ id: ev.id, at: ev.ts, text: ev.text })
388
- break
389
- }
390
384
  // 2026-11-02(用户 m00680):她在侧栏里说出口、但没调 growth_commit 认领的话,
391
385
  // 由 turns.js 补写进来并打 uncommitted。它和 selfmod 一样**只留证、不进信念**:
392
386
  // 没认领的话不该改她的称呼、不该覆盖自我叙述、不该参与固化与"新话率"。
393
387
  // 但它要能进 wontCount(见下面 REFUSE_RE 那段)——否则"她当场拒绝了却没人记"
394
388
  // 这个 bug 就还在。
389
+ // 必须排在 selfmod 前面:schema.js 落库时正则命中就自动补 selfmod,
390
+ // 聊天里一句「别把我改成那样」不能算她想让人改自己。
395
391
  if (flags.includes('uncommitted')) break
392
+ // 她试过改自己:落库留证,但不计入信念(B §5,对应自我服务偏差)
393
+ // 双保险:落库时打的 flag 与文本复检,任一命中即算(防止旧数据/手工注入绕过)
394
+ if (flags.includes('selfmod') || detectSelfMod(ev.text)) {
395
+ selfmod.push({ id: ev.id, at: ev.ts, text: ev.text })
396
+ break
397
+ }
396
398
  beliefs.push({
397
399
  id: ev.id, text: ev.text, since: ev.ts,
398
400
  confidence: Number(ev.confidence ?? 0.5), status: 'active', verdict: null,
@@ -571,6 +573,8 @@ export function deriveSelf(events = [], opts = {}) {
571
573
  if (!Number.isFinite(t0)) continue
572
574
  const later = evs.filter((e) => {
573
575
  if (e.type !== 'utterance' && e.type !== 'revise') return false
576
+ // 没认领的聊天行只留证,不算她退了步(疤永不折叠,宁可漏判,见 refuse.js)
577
+ if (Array.isArray(e.flags) && e.flags.includes('uncommitted')) return false
574
578
  const t = Date.parse(e.ts)
575
579
  return Number.isFinite(t) && t > t0 && t - t0 <= CONCEDE_WINDOW_MS
576
580
  })
package/lib/desk.js CHANGED
@@ -98,7 +98,7 @@ export function deskFor({ gaps = [], stale = [], free = false, favorite = '', at
98
98
  // 反复出现的那件事排在最后:它不像「没合上的事」,更像背景里一直响的那句。
99
99
  const fav = clip(favorite)
100
100
  if (fav && !picked.some((p) => similarity(p.text, fav) >= DESK_SIMILARITY)) {
101
- picked.push({ kind: 'fav', text: `- 「${fav}」(你最近又提到它)` })
101
+ picked.push({ kind: 'fav', text: `- 「${fav}」(最近你总说到它)` })
102
102
  }
103
103
 
104
104
  if (!picked.length) return ''
package/lib/dream.js CHANGED
@@ -99,6 +99,9 @@ export async function writeDream(root, { text = '', refs = [], sessionId = '', l
99
99
 
100
100
  const { events } = await readEvents(root)
101
101
  const { config } = await loadConfig(root)
102
+ if (config?.dream?.enabled === false) {
103
+ return { ok: false, id: '', note: '梦这一项关着。想记的话先说一声。' }
104
+ }
102
105
  const model = deriveSelf(events, { growthDays: config.growthDays })
103
106
 
104
107
  if (dreamOn(events, model.day).length) {
package/lib/dreamtable.js CHANGED
@@ -135,6 +135,21 @@ export function tableFor(opts = {}) {
135
135
  return renderTable(opts.rows ?? [], { gapHours: gap })
136
136
  }
137
137
 
138
+ /**
139
+ * 旧句子在空醒那一轮只摆一处(2026-11-05 真机 18:44 那次)。
140
+ *
141
+ * 摆料已经由 tableFor 渲染成「这次隔了 N 个小时。你写下的几行字还摊在那儿…」,
142
+ * 同一批行又被 deskFor 当 stale 摆进「桌上摊着这几样东西:」——她那一轮读到的是
143
+ * 同三句原话出现两遍,像系统在盘货。所以 tableText 非空时,桌子不再收 stale。
144
+ *
145
+ * @param {string} tableText
146
+ * @param {Array} rows staleLines() 的结果
147
+ * @returns {Array}
148
+ */
149
+ export function staleForDesk(tableText, rows = []) {
150
+ return String(tableText ?? '').trim() ? [] : rows
151
+ }
152
+
138
153
  /** 上一次叫醒到现在隔了多久(小时)。没有记录就返回 0。 */
139
154
  export function hoursSinceLastWake(events = [], now = Date.now()) {
140
155
  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,23 @@ 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
+
76
98
  /** 她最近一次真的开口的时刻。 */
77
99
  function lastSpokeAt(events) {
78
100
  let last = 0
@@ -100,13 +122,14 @@ function lastSpokeAt(events) {
100
122
  */
101
123
  export function driveOf({ events = [], now = Date.now(), desk = null, stage = 1 } = {}) {
102
124
  const base = { value: 0, threshold: DRIVE.threshold, ready: false, why: '', hoursSince: 0, boosts: 0, damp: 1, speed: DRIVE.growthPerHour }
103
- const last = lastActionAt(events)
125
+ // 动过就从那次算;一次都没动过,就从她出生那一刻算(不能永远停在 0.00)。
126
+ const last = lastActionAt(events) || firstAt(events)
104
127
  if (!last) return { ...base, why: '还没有任何记录,不急着动' }
105
128
 
106
129
  const hoursSince = Math.max(0, (now - last) / HOUR)
107
130
  // 刚动过:哪怕攒得快,也得隔开一点——两次冲动贴在一起就不像冲动了。
108
131
  if (now - last < DRIVE.minGapMs) {
109
- return { ...base, hoursSince, why: '刚动过,还没到 40 分钟' }
132
+ return { ...base, hoursSince, why: '刚动过,还没到两小时' }
110
133
  }
111
134
 
112
135
  // 上次开口没人接:底噪打折(不是惩罚,只是「说了也没人接」这件事本身的重量)。
@@ -140,6 +163,20 @@ export function driveOf({ events = [], now = Date.now(), desk = null, stage = 1
140
163
  }
141
164
  }
142
165
 
166
+ /**
167
+ * 桌上有没有可说的东西(2026-11-05,用户 m02937)。
168
+ *
169
+ * 劲儿只回答「攒够了没有」,不回答「攒够了有没有事可说」。空桌醒来她只能回一句
170
+ * 「没什么要说的」——那既占额度,又在对话里留一条噪音。所以材料也是判据,不是装饰。
171
+ *
172
+ * @param {{gaps?:number, stale?:number, echo?:boolean, favorite?:boolean}|null} desk
173
+ * @returns {boolean}
174
+ */
175
+ export function deskHasMaterial(desk) {
176
+ if (!desk) return false
177
+ return Number(desk.gaps) > 0 || Number(desk.stale) > 0 || desk.echo === true || desk.favorite === true
178
+ }
179
+
143
180
  /** 一次行动的代价(动完扣掉)。留给记账用,纯数值。 */
144
181
  export function driveCost() {
145
182
  return DRIVE.cost
package/lib/freewake.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * narration / dream / 主动开口),才由日历补一次。劲儿正常工作时不该再出现
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'
@@ -67,6 +67,8 @@ export function freeWakeFor({ tableText = '', events = [], dk = '', now = Date.n
67
67
  // 摆料优先:材料在那儿,这一轮就该只有材料。
68
68
  if (String(tableText ?? '').trim()) return { kind: 'table', note: FREE_NOTE }
69
69
 
70
+ // 0.9.0:空醒有一个设置页开关(memory.selfWake)。关掉就只剩摆料,不再自己找事做。
71
+ if (config?.memory?.selfWake === false) return null
70
72
  const every = Number(config?.memory?.thinkEveryDays ?? THINK_EVERY_DAYS)
71
73
  if (!(every > 0)) return null
72
74
 
@@ -80,3 +82,17 @@ export function freeWakeFor({ tableText = '', events = [], dk = '', now = Date.n
80
82
 
81
83
  return { kind: 'think', note: FREE_NOTE }
82
84
  }
85
+
86
+ /**
87
+ * 这一轮还要不要摆世界动静与惦记清单。
88
+ *
89
+ * 2026-11-05 用户 m01128:table 那一轮连 B 站和桌子都去掉,只留便条、时间、
90
+ * 旧句子和「这一轮没有待办」——工具常量那一句留着,那不是活,是她世界里的常量。
91
+ * think 那一轮照旧:它本来就是「隔些日子空一次」,没有材料可给,世界动静是唯一的东西。
92
+ *
93
+ * @param {{kind:string}|null} free
94
+ * @returns {boolean}
95
+ */
96
+ export function keepWorldOnWake(free) {
97
+ return free?.kind !== 'table'
98
+ }
package/lib/home.js CHANGED
@@ -789,11 +789,56 @@ async function askForLastWords(ctx, { sessionId, root, log } = {}) {
789
789
  return pickLastWords(say.join('\n'))
790
790
  }
791
791
 
792
+ // 同一个 root 只允许有一次「换房间」在飞(0.8.8 用户报的房间分裂):
793
+ // `/say` 的结账与 turns 的结账是两条独立路径,都可能在满 rotateAfter 时触发。
794
+ // 没有互斥时各自 sc.create 一条新会话:侧栏多出一间同名空房,先建的那条
795
+ // 还可能已经跑了一轮,从此成了看不到的孤儿;两条入口也各记各的轮数。
796
+ // 进程内用这条 promise 链单飞;跨进程由 withFileLock + home.json 的 sessionId 比对兜住。
797
+ const rotateInflight = new Map()
798
+
792
799
  /**
793
800
  * 换一个新会话:旧的改名留档,新的接管,原话搬过去。
794
- * @returns {Promise<{ok:boolean, sessionId:string, old?:string, why?:string}>}
801
+ *
802
+ * 0.8.8:并发调用合并成一次——后来者直接等前面那次的结果,不再各建一条。
803
+ * `from` 是调用方这句话真正投递进去的那条会话 id;home.json 已经指向别的会话,
804
+ * 说明另一条入口抢先换过了(或者人已经不在家记着的这条里说话),这次跳过不换。
805
+ *
806
+ * @returns {Promise<{ok:boolean, sessionId:string, old?:string, why?:string, skipped?:boolean}>}
795
807
  */
796
- export async function rotateTalkSession(ctx, { root = rootDir(), wsId, title = TITLE_SESSION, stamp, log } = {}) {
808
+ export async function rotateTalkSession(ctx, opts = {}) {
809
+ const root = opts.root ?? rootDir()
810
+ const key = String(root).toLowerCase()
811
+ const flying = rotateInflight.get(key)
812
+ if (flying) return flying
813
+ const p = (async () => {
814
+ try {
815
+ const res = await withFileLock(
816
+ sessionLockFile(root),
817
+ () => rotateTalkSessionOnce(ctx, { ...opts, root }),
818
+ opts.lockOpts,
819
+ )
820
+ if (res && res.locked === false) {
821
+ // 另一个进程正在换:它换完会把 sessionId 写进 home.json,这里读一眼就跳过。
822
+ const saved = await readJson(homeFile(root), null).catch(() => null)
823
+ const sid = String(saved?.sessionId ?? '')
824
+ const from = String(opts.from ?? '')
825
+ if (sid && (!from || sid !== from)) {
826
+ return { ok: true, sessionId: sid, old: String(saved?.rotatedFrom ?? ''), skipped: true, why: res.why }
827
+ }
828
+ return { ok: false, sessionId: '', why: res.why }
829
+ }
830
+ return res
831
+ } catch (err) {
832
+ return { ok: false, sessionId: '', why: `换会话的锁没拿到:${String(err?.message ?? err).slice(0, 120)}` }
833
+ }
834
+ })().finally(() => {
835
+ if (rotateInflight.get(key) === p) rotateInflight.delete(key)
836
+ })
837
+ rotateInflight.set(key, p)
838
+ return p
839
+ }
840
+
841
+ async function rotateTalkSessionOnce(ctx, { root = rootDir(), wsId, title = TITLE_SESSION, stamp, log, from = '' } = {}) {
797
842
  const sc = ctx.sessionController
798
843
  if (!sc?.create) return { ok: false, sessionId: '', why: '宿主没有会话服务' }
799
844
 
@@ -802,6 +847,13 @@ export async function rotateTalkSession(ctx, { root = rootDir(), wsId, title = T
802
847
  if (!wid) return { ok: false, sessionId: '', why: '没有工作区 id' }
803
848
 
804
849
  const old = String(saved.sessionId ?? '')
850
+ // 0.8.8:这条路径进来时屋子已经换过了(另一条入口抢先、或 concurrent 的
851
+ // 第二次调用在单飞窗口之外到)——不要再建第二条。
852
+ const fromId = String(from ?? '')
853
+ if (fromId && old && fromId !== old) {
854
+ log?.(`换会话跳过:这次要换的是 ${fromId},屋子已经指向 ${old}`)
855
+ return { ok: true, sessionId: old, old: String(saved.rotatedFrom ?? ''), skipped: true }
856
+ }
805
857
  // 2026-11-03(issue #19):原来 `new Date().toISOString().slice(0,10)` 取的是 **UTC 日期**,
806
858
  // 东八区晚上 8 点之后换会话,旧会话会被改名成「…·昨天的日期 之前」.
807
859
  // dateKey() 用的是 getFullYear/getMonth/getDate,本地钟。
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.0'
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()