@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/scheduler.js CHANGED
@@ -37,20 +37,19 @@ import { setWakeSection, clearWakeSection, getWakeSection } from './identity.js'
37
37
  import { awayNow, awayLine } from './outing.js'
38
38
  import { askInChat, picked } from './askinchat.js'
39
39
  import { restedToday, settleStaleAsks } from './ask.js'
40
- import { devModeSync } from './devmode.js'
41
40
  import { proactiveState, proactiveSection, recentMaterial, looseEnds, TOOL_ASK_NOTE } from './proactive.js'
42
41
  import { deliverWake, ensureSessionLive, WAKE_PING } from './deliver.js'
43
- import { followWake, planWakeCompaction, compactWake } from './compact.js'
42
+ import { followWake, planWakeCompaction, compactWake, looksLikeNothingSaid } from './compact.js'
44
43
  import { logLine } from './log.js'
45
44
  import { recordGrant } from './grant.js'
46
45
  import { readNotes, renderNotes } from './note.js'
47
46
  import { curiosityOf, renderEcho } from './curiosity.js'
48
47
  import { deskFor } from './desk.js'
49
- import { driveOf } from './drive.js'
50
- import { interestOf, renderInterest } from './interest.js'
48
+ import { driveOf, deskHasMaterial } from './drive.js'
49
+ import { interestOf, topicLabel } from './interest.js'
51
50
  import { lastLeaveAt, lastSeenAt } from './curiosity.js'
52
51
  import { dreamOn } from './dream.js'
53
- import { hoursSinceLastWake, staleLines, tableFor } from './dreamtable.js'
52
+ import { hoursSinceLastWake, staleForDesk, staleLines, tableFor } from './dreamtable.js'
54
53
  import { freeWakeFor } from './freewake.js'
55
54
  import { readOutings, readPossessions } from './outing.js'
56
55
 
@@ -179,7 +178,12 @@ export function startScheduler(ctx, deps = {}) {
179
178
  * 她可以选择开口,也可以什么都不做——不写任何「你应该」。
180
179
  */
181
180
  async function maybeProactive({ events, config, dk, now }) {
182
- if (!devModeSync(root)) return false
181
+ // 2026-11-05(用户 m02634):「主动性」有开关。关着 = 她不会自己攒劲儿开一轮;
182
+ // 日程唤醒与手动叫醒都不经过这里,照样能用。
183
+ if (config?.drive?.enabled === false) {
184
+ if (proactiveSkip !== '主动性关着') { proactiveSkip = '主动性关着'; log('proactive skip', '主动性关着') }
185
+ return false
186
+ }
183
187
  // 2026-11-04(用户 m01466):把她的成长档案交给判定函数。
184
188
  // 之前这里是 `proactiveState({ config, events, now })` —— 它收不到 model,
185
189
  // 于是第 3 天和第 27 天算出来的概率一模一样(「自己开口的出口不该是恒温的」)。
@@ -198,7 +202,7 @@ export function startScheduler(ctx, deps = {}) {
198
202
  lastProbe = now
199
203
  // ---- 劲儿(2026-11-05,用户 m02031)----
200
204
  // 用户的定案:把「什么时候开这一轮」从掷骰子换成攒劲儿。
201
- // 底噪:每小时 0.12,攒到 0.8 想动一次,动完扣 0.55,两次至少隔 40 分钟;
205
+ // 底噪:每小时 0.12,攒到 0.8 想动一次,两次至少隔两小时;
202
206
  // 冲动:桌上有没答的问题 +0.06、有新句子 +0.04、他回了她留的话 +0.12(封顶 +0.3);
203
207
  // 折扣:她上一次开口没人接,底噪 ×0.7(打扰成本,Horvitz)。
204
208
  // 这张桌子是**判据用的摘要**,不是正文那段(正文那段在 wake() 里现算现摆)。
@@ -210,7 +214,18 @@ export function startScheduler(ctx, deps = {}) {
210
214
  const leave = lastLeaveAt(events)
211
215
  const seen = lastSeenAt(events)
212
216
  const echoBack = !!(leave && seen && seen >= leave && now - leave <= 72 * 3600_000)
213
- const drv = driveOf({ events, now, stage: model.stage, desk: { gaps: gaps.length, stale: 0, echo: echoBack, favorite: interest.length > 0 } })
217
+ const driveDesk = { gaps: gaps.length, stale: 0, echo: echoBack, favorite: interest.length > 0 }
218
+ const drv = driveOf({ events, now, stage: model.stage, desk: driveDesk })
219
+ // 空桌不醒(2026-11-05,用户 m02937):桌上什么都没有时,劲儿再满也不起——
220
+ // 否则她只能回一句「没什么要说的」,既占额度又在对话里留一条噪音。
221
+ if (!deskHasMaterial(driveDesk)) {
222
+ const why = `桌上没有可说的东西(劲儿 ${drv.value.toFixed(2)})`
223
+ if (why !== proactiveSkip) {
224
+ proactiveSkip = why
225
+ log('drive', why)
226
+ }
227
+ return false
228
+ }
214
229
  if (!drv.ready) {
215
230
  if (drv.why && drv.why !== proactiveSkip) {
216
231
  proactiveSkip = `drive ${drv.value.toFixed(2)} · ${drv.why}`
@@ -297,7 +312,10 @@ export function startScheduler(ctx, deps = {}) {
297
312
  return
298
313
  }
299
314
  const meta = { day: model.day, stage: model.stage, source: 'timer' }
300
- if (out.spoke === true) {
315
+ // 「没什么要说的」不是开口(2026-11-05,用户 m02937):她写一句元话语
316
+ // 交代「这轮没话说」,在文本界面上几乎不可避免;按静默记、折掉,别当一次开口。
317
+ const quiet = out.spoke !== true || looksLikeNothingSaid(out.said)
318
+ if (!quiet) {
301
319
  await appendEvent({ type: 'meta', text: `proactive-turn:${dk}:${now}` }, meta, root).catch(() => {})
302
320
  log('proactive', `开了一次空手回合,她开口了(劲儿 ${drv.value.toFixed(2)})`)
303
321
  return
@@ -310,9 +328,10 @@ export function startScheduler(ctx, deps = {}) {
310
328
  // 一轮可能被宿主的投影切成几段:各段单独换,边界节点原样留着。
311
329
  let folded = 0
312
330
  if (plan) for (const run of plan.runs) if (await compactWake(out.session, run, { log })) folded += 1
331
+ const tail = out.spoke === true ? ',只说了句没什么要说的' : ''
313
332
  log('proactive', folded > 0
314
- ? `醒了一次,没有开口(这一轮折成 ${folded} 行,盖住 ${plan.shadowed} 个节点)`
315
- : `醒了一次,没有开口(${out.ok ? '这轮没什么可折' : out.why})`)
333
+ ? `醒了一次,没有开口${tail}(这一轮折成 ${folded} 行,盖住 ${plan.shadowed} 个节点)`
334
+ : `醒了一次,没有开口${tail}(${out.ok ? '这轮没什么可折' : out.why})`)
316
335
  } catch (err) {
317
336
  log('proactive settle', err?.message ?? err)
318
337
  }
@@ -653,7 +672,7 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
653
672
  // 旧句子:判「要不要空醒」要用渲染好的那段,摆桌子要的是原始行——一次算好两处都用。
654
673
  let staleRows = []
655
674
  let free = null
656
- if (devModeSync(root)) {
675
+ {
657
676
  try {
658
677
  const { events: evs } = await readEvents(root)
659
678
  if (!dreamOn(evs, model.day).length) {
@@ -693,7 +712,15 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
693
712
  // 顺序是刻意的:先看,再想。如果先出事件提示再塞素材,
694
713
  // 她就会先按模板说话、再拿素材硬套——那是模板,不是她。
695
714
  // 反过来(先看见世界的动静,再问她怎么想)才像一个人醒来的样子。
696
- const look = await lookBeforeWake(root, model, config, dk, deps, log)
715
+ //
716
+ // 2026-11-05(用户 m01128):摆料那一轮不摆世界。这一轮只该有一张桌子——
717
+ // 旧句子 + 「这一轮没有待办」。B 站动静与惦记清单都会替她提供「最像活的那件事」,
718
+ // 18:44 真机她就是挑了旧句子里唯一像活的一条去核对,没往梦里想。
719
+ // 手动「让她去刷 B 站」(forceBrowse)本来就指定了这一轮做什么,不跟着空醒一起收。
720
+ // 2026-11-05(用户 m02562):刷站不再挂在唤醒上。所有醒来都只有桌子与世界之外的事,
721
+ // 想上网由她自己调工具(bili_*),不再由唤醒替她决定看什么。
722
+ const showWorld = false
723
+ const look = showWorld ? await lookBeforeWake(root, model, config, dk, deps, log) : { text: '', intro: '', picks: [] }
697
724
  if (look.text) {
698
725
  parts.push('', look.intro, '', look.text)
699
726
  // 带链接的清单(2026-10-23 用户提):她能说出"有一条是一个人跑不起来了",
@@ -723,25 +750,33 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
723
750
  // 交给 deskFor 并成一段,配额与去重都在那个纯函数里;回音是另一件事(他有没有来过),
724
751
  // 仍然单独一句话,不并进桌子。
725
752
  // 只摆**事实**:不派活、不写「你可以去想想」、不给任何数字——派活了就又变成任务清单。
726
- // 和这一版的其余实验一样先只在 .dev 里生效;公开版等劲儿那一版接通后再开。
753
+ // 2026-11-05(用户 m02634):「兴趣」有开关。关掉 = 桌子那几行(好奇/旧句子/回音)
754
+ // 一概不摆;缺工具那句仍留着,它不是活、是她世界里的常量。
727
755
  // 读失败绝不拖垮一次唤醒:她醒着这件事比桌上的东西重要。
728
- if (devModeSync(root)) {
756
+ if (config?.interest?.enabled !== false) {
729
757
  try {
730
- const { events: evc } = await readEvents(root)
731
- // 兴趣(2026-11-05,用户 m02031):反复出现的那件事排进同一张桌子,
732
- // 不另起一段——「一张桌子」是 L 文档定下的约定。
733
- const fav = renderInterest(interestOf(evc, now.getTime()))
734
- const desk = deskFor({
735
- gaps: curiosityOf(evc, now),
736
- stale: staleRows,
737
- free: !!free,
738
- favorite: fav ? fav.replace(/^最近你总回到「|」这件事上。$/g, '') : '',
739
- at: now,
740
- })
741
- if (desk) parts.push('', desk)
742
- const echo = renderEcho(evc, now)
743
- if (echo) parts.push('', echo)
758
+ // 摆料那一轮不摆桌子:这一轮只留旧句子(用户 m01128)。
759
+ {
760
+ const { events: evc } = await readEvents(root)
761
+ // 兴趣(2026-11-05,用户 m02031):反复出现的那件事排进同一张桌子,
762
+ // 不另起一段——「一张桌子」是 L 文档定下的约定。
763
+ const favItem = interestOf(evc, now.getTime())[0]
764
+ const fav = favItem ? topicLabel(favItem.text) : ''
765
+ const desk = deskFor({
766
+ gaps: curiosityOf(evc, now),
767
+ // 摆料那一轮 tableText 已经把旧句子摆出来了,桌子别再摆第二遍
768
+ // (2026-11-05 真机 18:44:同三句原话出现了两次)。
769
+ stale: staleForDesk(tableText, staleRows),
770
+ free: !!free,
771
+ favorite: fav,
772
+ at: now,
773
+ })
774
+ if (desk) parts.push('', desk)
775
+ const echo = renderEcho(evc, now)
776
+ if (echo) parts.push('', echo)
777
+ }
744
778
  // 缺工具就开口要:每次醒来都摆这一句,不派活,只给路径。
779
+ // 摆料那一轮也留着——它不是活,是她世界里的常量(用户 m01128)。
745
780
  parts.push('', TOOL_ASK_NOTE)
746
781
  } catch (err) {
747
782
  log('curiosity', err?.message ?? err)
@@ -1159,14 +1194,9 @@ export async function wakeNow(ctx, deps = {}) {
1159
1194
  const model = deriveSelf(events, { growthDays: config.growthDays })
1160
1195
  const dk = dateKey()
1161
1196
  const counts = { dateKey: dk, sent: 0, questions: 0, proactive: 0 }
1162
- // focus='browse':设置页那个「让她去刷 B 站」按钮(**仅本机测试版**,2026-10-26)。
1163
- // 不走 plan()——plan 判的是「今天该不该醒」,而这个按钮是人工要求她醒一次去看站;
1164
- // 语气也别写成"任务",她看到"去看一眼"会自己决定看不看。
1165
- const forcedBrowse = deps.focus === 'browse'
1166
- if (forcedBrowse) deps = { ...deps, forceBrowse: true }
1167
- let decision = forcedBrowse
1168
- ? { action: 'wake', prompt: '(现在,去看一眼 B 站首页在推什么。)', template: { id: 'manual-browse' } }
1169
- : plan(model, config, counts, events, { squeeze: gapSqueeze(gapSinceLastWake(events)) })
1197
+ // 2026-11-05(用户 m02562):「让她去刷 B 站」这个按钮砍掉了,
1198
+ // 手动叫醒一律走 plan(),和定时那条一样。
1199
+ let decision = plan(model, config, counts, events, { squeeze: gapSqueeze(gapSinceLastWake(events)) })
1170
1200
  const log = deps.log ?? (() => {})
1171
1201
  // 2026-11-04(用户 m01466):手动叫醒也要认最小间隔。
1172
1202
  //
package/lib/selfcheck.js CHANGED
@@ -18,7 +18,6 @@ import { readConfig, readEvents, readOwner } from './store.js'
18
18
  import { inWindow, dateKey } from './settings.js'
19
19
  import { detectFfmpeg } from './media.js'
20
20
  import { FISH_TOOLS, HER_TOOLS, blockedTools } from './restrict.js'
21
- import { devModeSync } from './devmode.js'
22
21
 
23
22
  const MIN_NODE_MAJOR = 18
24
23
 
@@ -225,8 +224,8 @@ export async function runChecks(o = {}) {
225
224
  // 于是公开版装完自检必然报红:「有 1 个工具没注册上:her_dream」——
226
225
  // 和 2026-10-28 那次 read_image 误报同一类错误(拿「名单」当「真装上」的反面,
227
226
  // 这次是拿「本机清单」当「公开版清单」)。判据与注册处同一份:`devModeSync`。
228
- const dev = devModeSync(root)
229
- const expected = dev ? FISH_TOOLS : FISH_TOOLS.filter((n) => n !== 'her_dream')
227
+ // 0.9.0 起 her_dream 也恒注册,清单就是清单本身。
228
+ const expected = FISH_TOOLS
230
229
  const notRegistered = T && typeof T.get === 'function' ? expected.filter((n) => !T.get(n)) : []
231
230
  // read_image 是宿主的工具,别人的机器上可能没有。
232
231
  // 2026-10-28:以前它只是跟在「手上的工具」后面一句附注,而且那一项还是绿的。
package/lib/selfdoc.js CHANGED
@@ -26,7 +26,6 @@ import { promises as fs } from 'node:fs'
26
26
  import { join } from 'node:path'
27
27
  import { rootDir, paths, readEvents, readJson, writeAtomic, listDiary } from './store.js'
28
28
  import { loadConfig } from './settings.js'
29
- import { devModeSync } from './devmode.js'
30
29
  import { deriveSelf } from './derive.js'
31
30
  import { personaLine, toolBrief, RECALL_BRIEF, SPEAK_BRIEF, LETTER_BRIEF, NOTE_BRIEF, ASK_BRIEF, DREAM_BRIEF, PROACTIVE_BRIEF, SCHEDULE_BRIEF, DIARY_BRIEF, SLEEP_BRIEF } from './persona.js'
32
31
  import { LOOK_BRIEF, WATCH_RULE } from './browse.js'
@@ -263,11 +262,9 @@ export async function writeSelfDoc(root = rootDir(), { log, exec, tenant } = {})
263
262
  // 2026-10-27(用户 m01371):白名单里额外放行的工具要念给她听,
264
263
  // 否则「能不能在 QQ 上说话」她只能回一句不知道。
265
264
  extraTools: config?.tools?.allow ?? [],
266
- // 梦(2026-10-30 m02837):与工具注册同一个判据(.dev 文件 / DSH_FISH_DEV)。
267
- // 公开版既没有 her_dream,也不该在身份里读到它。
268
- dream: devModeSync(root),
269
- // 主动说话(2026-10-30 m04610):同样只在 .dev 下挂。
270
- proactive: devModeSync(root),
265
+ // 梦(2026-10-30 m02837)与主动说话(m04610):0.9.0 起公开,两处都恒为真。
266
+ dream: true,
267
+ proactive: true,
271
268
  // 定时工具默认不放;用户在白名单里加了 schedule_create 才告诉他这条路通。
272
269
  schedule: (config?.tools?.allow ?? []).some((n) => String(n).trim() === 'schedule_create'),
273
270
  })
package/lib/selfupdate.js CHANGED
@@ -35,8 +35,18 @@ import { fileURLToPath } from 'node:url'
35
35
 
36
36
  import { REPO, normalizeVersion } from './update.js'
37
37
 
38
- /** 包名:既用于 tar 里的校验,也用于 profile 里找依赖条目。 */
39
- export const PKG = 'dsh-fish'
38
+ /**
39
+ * 包名:既用于 tar 里的校验,也用于 profile 里找依赖条目。
40
+ *
41
+ * 2026-11-05 起走 npm 的 scoped 全名(`dsh-fish` 这个裸名被别的账号占了,
42
+ * 见 `market/` 那次排查)。老用户 profile 里还留着 `dsh-fish` 那条,所以
43
+ * 读依赖时两个名字都认(见 `readDependency`)。
44
+ */
45
+ export const PKG = '@ssamurias/dsh-fish'
46
+ /** npm 上的老名字(别人占的那条),只为兼容老安装去读它,**绝不往里装**。 */
47
+ export const LEGACY_PKG = 'dsh-fish'
48
+ /** tarball 文件名里不带 scope:`dsh-fish-0.8.10.tgz`。 */
49
+ const TARBALL_BASE = 'dsh-fish'
40
50
 
41
51
  /**
42
52
  * 单源超时 / 整批预算(毫秒)。
@@ -57,33 +67,38 @@ export function cleanVersion(v) {
57
67
  }
58
68
 
59
69
  /**
60
- * 下载地址:codeload 的 tag tarball,加两个镜像前缀。
61
- * 顺序=直连优先,失败再走镜像(镜像有缓存滞后的可能,所以只在直连不行时用)。
70
+ * 下载地址:npm registry 的 tarball,加一个国内镜像。
71
+ * 顺序=官方 registry 优先,失败再走 npmmirror(镜像同步有几十秒到几分钟的滞后,
72
+ * 所以只在官方不行时用)。
62
73
  *
63
- * 镜像前缀**不能直接套 codeload 的地址**(2026-10-29 实测:ghproxy.net / ghfast.top
64
- * 对 `…/codeload.github.com/…/tar.gz/…` 一律回 403 `Invalid input.`),要走
65
- * `github.com/<repo>/archive/refs/tags/v<ver>.tar.gz`——同一份包,两种入口。
74
+ * 为什么不再用 GitHub 的 tag tarball(2026-11-05 改):装了哪个版本,取决于
75
+ * **npm 上发了哪个版本**——GitHub 的 tag 可能忘了打,codeload 在国内又时通时不通。
76
+ * npm 的 tarball 就是 `pnpm add` 会拉的那一份,下载下来验过等于"这次装的一定是对的"。
66
77
  */
67
78
  export function sourcesFor(version) {
68
79
  const v = cleanVersion(version)
69
80
  if (!v) return []
70
- const direct = `https://codeload.github.com/${REPO}/tar.gz/refs/tags/v${v}`
71
- const archive = `https://github.com/${REPO}/archive/refs/tags/v${v}.tar.gz`
81
+ const file = `${TARBALL_BASE}-${v}.tgz`
72
82
  return [
73
- { name: 'GitHub 直连', url: direct },
74
- { name: 'ghproxy 镜像', url: `https://ghproxy.net/${archive}` },
75
- { name: 'ghfast 镜像', url: `https://ghfast.top/${archive}` },
83
+ { name: 'npm 官方源', url: `https://registry.npmjs.org/${PKG}/-/${file}` },
84
+ { name: 'npmmirror 镜像', url: `https://registry.npmmirror.com/${PKG}/-/${file}` },
76
85
  ]
77
86
  }
78
87
 
79
- /** `github:ssamurias/dsh-fish#v0.6.14` —— pnpm add 用的 spec。 */
88
+ /**
89
+ * 安装用的 spec。
90
+ * 用版本号而不是 `file:` —— `pnpm add file:…tgz` 会把 profile 里的依赖写成
91
+ * `file:updates/xxx.tgz`,下次升级时 `classifySpec` 会把它当开发版而拒绝升级,
92
+ * 而且那个路径本身是临时目录。写成 `@ssamurias/dsh-fish@x.y.z` 才是一条能一直往上走的记录。
93
+ */
80
94
  export function specFor(version) {
81
- return `github:${REPO}#v${cleanVersion(version)}`
95
+ return `${PKG}@${cleanVersion(version)}`
82
96
  }
83
97
 
84
98
  /** 下载到本地用的文件名。带上版本,同一份数据目录里留着几版也不会混。 */
85
99
  export function fileNameFor(version) {
86
- return `${PKG}-v${cleanVersion(version)}.tgz`
100
+ // 文件名里不能带 scope 的 `/`,用不带 scope 的基名。
101
+ return `${TARBALL_BASE}-v${cleanVersion(version)}.tgz`
87
102
  }
88
103
 
89
104
  /**
@@ -118,17 +133,28 @@ export function profileDirOf(moduleUrl) {
118
133
  } catch {
119
134
  return ''
120
135
  }
121
- const marker = '/node_modules/dsh-fish/'
122
- const i = file.lastIndexOf(marker)
123
- if (i < 0) return ''
124
- return file.slice(0, i)
136
+ // 新装法(npm scoped)落在 `node_modules/@ssamurias/dsh-fish/`,老装法在 `node_modules/dsh-fish/`。
137
+ // 两种都要认得,否则从 0.8.9 之前装上来的用户会被当成"没有 profile → 开发版"而永远升不了级。
138
+ for (const marker of [`/node_modules/${PKG}/`, `/node_modules/${LEGACY_PKG}/`]) {
139
+ const i = file.lastIndexOf(marker)
140
+ if (i >= 0) return file.slice(0, i)
141
+ }
142
+ return ''
125
143
  }
126
144
 
127
- /** 读 profile/package.json,返回 dsh-fish 那条依赖的 spec(读不到给空串)。 */
145
+ /**
146
+ * 读 profile/package.json,返回那条依赖的 spec(读不到给空串)。
147
+ * 先认新包名,再退回老包名——老用户 profile 里只有 `dsh-fish` 那一条。
148
+ */
128
149
  export function readDependency(profileDir) {
129
150
  try {
130
151
  const json = JSON.parse(fs.readFileSync(path.join(profileDir, 'package.json'), 'utf8'))
131
- return String(json?.dependencies?.[PKG] ?? json?.devDependencies?.[PKG] ?? '')
152
+ const deps = { ...(json?.dependencies ?? {}), ...(json?.devDependencies ?? {}) }
153
+ for (const name of [PKG, LEGACY_PKG]) {
154
+ const spec = deps[name]
155
+ if (spec) return String(spec)
156
+ }
157
+ return ''
132
158
  } catch {
133
159
  return ''
134
160
  }
@@ -321,6 +347,7 @@ export function scriptFor({
321
347
  backupDir,
322
348
  hostName,
323
349
  tarball = '',
350
+ normalizePath = '',
324
351
  } = {}) {
325
352
  const v = cleanVersion(version)
326
353
  if (!v) throw new Error('版本号认不出来')
@@ -330,11 +357,12 @@ export function scriptFor({
330
357
  // 没有就退回 PATH 上的 pnpm —— 用户机器上装过 pnpm 也能跑。
331
358
  const pnpmCmd = nodeBin && pnpmScript ? `"${nodeBin}" "${pnpmScript}"` : 'pnpm'
332
359
  const q = (s) => String(s ?? '')
333
- // 优先装**已经下载并验过**的那个包(`file:`),不走 `github:`。
334
- // 原因(2026-10-29 实测):`github:` spec 要 pnpm 跑 `git ls-remote` 打 github.com:443,
335
- // 这台机器上直接 Connection reset / ETIMEDOUT —— 而 codeload 和镜像都是通的。
336
- // 升级要能用,就不能把成败押在 git 协议上。
337
- const specArg = tarball ? `file:${q(tarball)}` : specFor(v)
360
+ // 从 npm 装(2026-11-05 改)。`tarball` 这一版只当"已经验过这个版本确实存在"的凭据,
361
+ // **不再用 `file:` 装**——`pnpm add file:…tgz` 会把依赖写成 `file:updates/xxx.tgz`,
362
+ // 下次升级时 classifySpec 会把它当开发版直接拒绝,而且那条路径本身是临时目录。
363
+ // 写成 `@ssamurias/dsh-fish@x.y.z` 才是一条能一直往上走的记录。
364
+ const specArg = specFor(v)
365
+ const modDir = PKG.replace('/', '\\\\')
338
366
 
339
367
  return [
340
368
  '@echo off',
@@ -365,26 +393,33 @@ export function scriptFor({
365
393
  'mkdir "%BK%" 2>nul',
366
394
  'copy /Y "%PROFILE%\\package.json" "%BK%\\package.json" >nul 2>&1',
367
395
  'copy /Y "%PROFILE%\\pnpm-lock.yaml" "%BK%\\pnpm-lock.yaml" >nul 2>&1',
368
- 'if exist "%PROFILE%\\node_modules\\dsh-fish" (',
369
- ' if not exist "%BK%\\dsh-fish" mkdir "%BK%\\dsh-fish"',
370
- ' xcopy /E /I /H /Y "%PROFILE%\\node_modules\\dsh-fish" "%BK%\\dsh-fish" >> "%LOG%" 2>&1',
396
+ `if exist "%PROFILE%\\\\node_modules\\\\${modDir}" (`,
397
+ ' if not exist "%BK%\\\\pkg" mkdir "%BK%\\\\pkg"',
398
+ ` xcopy /E /I /H /Y "%PROFILE%\\\\node_modules\\\\${modDir}" "%BK%\\\\pkg" >> "%LOG%" 2>&1`,
371
399
  ')',
372
400
  'cd /d "%PROFILE%"',
373
401
  `echo [3/6] install ${specArg} >> "%LOG%"`,
374
402
  `${pnpmCmd} add "${specArg}" --config.minimumReleaseAge=0 >> "%LOG%" 2>&1`,
375
403
  'if errorlevel 1 goto rollback',
376
404
  'echo [4/6] verify installed version >> "%LOG%"',
377
- `"${q(nodeBin) || 'node'}" -e "const p=require('./node_modules/dsh-fish/package.json');if(p.version!=='${v}'){console.error('got '+p.version);process.exit(3)}" >> "%LOG%" 2>&1`,
405
+ `"${q(nodeBin) || 'node'}" -e "const p=require('./node_modules/${PKG}/package.json');if(p.version!=='${v}'){console.error('got '+p.version);process.exit(3)}" >> "%LOG%" 2>&1`,
378
406
  'if errorlevel 1 goto rollback',
379
- 'echo [5/6] installed OK >> "%LOG%"',
407
+ ...(normalizePath
408
+ ? [
409
+ 'echo [5/6] normalize profile (legacy dep/bundle) >> "%LOG%"',
410
+ `"${q(nodeBin) || 'node'}" "${q(normalizePath)}" "%PROFILE%" "${PKG}" >> "%LOG%" 2>&1`,
411
+ 'echo [5/6] normalize exit %errorlevel% >> "%LOG%"',
412
+ ]
413
+ : []),
414
+ 'echo [6/6] installed OK >> "%LOG%"',
380
415
  'goto relaunch',
381
416
  ':rollback',
382
417
  'echo [4/6] FAILED - rolling back to the previous copy >> "%LOG%"',
383
418
  'copy /Y "%BK%\\package.json" "%PROFILE%\\package.json" >nul 2>&1',
384
419
  'copy /Y "%BK%\\pnpm-lock.yaml" "%PROFILE%\\pnpm-lock.yaml" >nul 2>&1',
385
- 'if exist "%BK%\\dsh-fish" (',
386
- ' rmdir /S /Q "%PROFILE%\\node_modules\\dsh-fish" >> "%LOG%" 2>&1',
387
- ' xcopy /E /I /H /Y "%BK%\\dsh-fish" "%PROFILE%\\node_modules\\dsh-fish" >> "%LOG%" 2>&1',
420
+ 'if exist "%BK%\\\\pkg" (',
421
+ ` rmdir /S /Q "%PROFILE%\\\\node_modules\\\\${modDir}" >> "%LOG%" 2>&1`,
422
+ ` xcopy /E /I /H /Y "%BK%\\\\pkg" "%PROFILE%\\\\node_modules\\\\${modDir}" >> "%LOG%" 2>&1`,
388
423
  ')',
389
424
  `cd /d "%PROFILE%"`,
390
425
  `${pnpmCmd} install --config.minimumReleaseAge=0 >> "%LOG%" 2>&1`,
@@ -397,6 +432,69 @@ export function scriptFor({
397
432
  ].join('\r\n')
398
433
  }
399
434
 
435
+ /**
436
+ * 升级后"收拾现场"的一次性脚本(Node CJS,由安装脚本调用)。
437
+ *
438
+ * 为什么必须有这一步(2026-11-05):0.8.9 之前包名是裸的 `dsh-fish`,
439
+ * profile 的 dependencies 与 bundles 里记的也是裸名。改名后只 `pnpm add`
440
+ * 新包,旧键会原地留着——node_modules 里于是同时有两条 fish,bundles 里
441
+ * 还是旧的那条,两条 insert 撞同一个 id,web boot 抛
442
+ * `duplicate factory registration` 直接崩。所以装完先把旧键、旧 bundle 条目清掉,
443
+ * 再把旧的 node_modules 目录改名收起来(不删,留着能回退)。
444
+ */
445
+ export function normalizeScript() {
446
+ return [
447
+ "'use strict'",
448
+ "const fs = require('node:fs')",
449
+ "const path = require('node:path')",
450
+ "const profileDir = process.argv[2] || ''",
451
+ "const pkg = process.argv[3] || ''",
452
+ "const LEGACY = 'dsh-fish'",
453
+ 'if (!profileDir || !pkg) {',
454
+ " console.error('normalize: missing args')",
455
+ ' process.exit(2)',
456
+ '}',
457
+ "const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)",
458
+ "const file = path.join(profileDir, 'package.json')",
459
+ "const json = JSON.parse(fs.readFileSync(file, 'utf8'))",
460
+ 'const done = []',
461
+ "for (const field of ['dependencies', 'devDependencies']) {",
462
+ ' const deps = json[field]',
463
+ " if (deps && typeof deps === 'object' && Object.prototype.hasOwnProperty.call(deps, LEGACY)) {",
464
+ ' delete deps[LEGACY]',
465
+ " done.push('drop ' + field + '.' + LEGACY)",
466
+ ' }',
467
+ '}',
468
+ 'const prof = json.dsh && (json.dsh.profile || json.dsh)',
469
+ 'const bundles = prof && Array.isArray(prof.bundles) ? prof.bundles : null',
470
+ 'if (bundles) {',
471
+ ' let n = 0',
472
+ ' for (let i = bundles.length - 1; i >= 0; i -= 1) {',
473
+ ' if (bundles[i] === LEGACY) { bundles.splice(i, 1); n += 1 }',
474
+ ' }',
475
+ " if (n) done.push('drop bundles.' + LEGACY + ' x' + n)",
476
+ " if (!bundles.includes(pkg)) { bundles.push(pkg); done.push('add bundles.' + pkg) }",
477
+ '}',
478
+ 'if (done.length) {',
479
+ " fs.copyFileSync(file, file + '.bak-normalize-' + stamp)",
480
+ " fs.writeFileSync(file, JSON.stringify(json, null, 2) + '\\n', 'utf8')",
481
+ " done.push('wrote package.json')",
482
+ '}',
483
+ "const legacyDir = path.join(profileDir, 'node_modules', LEGACY)",
484
+ 'try {',
485
+ ' if (fs.existsSync(legacyDir)) {',
486
+ " const target = legacyDir + '.disabled-' + stamp",
487
+ ' fs.renameSync(legacyDir, target)',
488
+ " done.push('moved node_modules/' + LEGACY)",
489
+ ' }',
490
+ '} catch (e) {',
491
+ " done.push('move failed: ' + (e && e.code ? e.code : e))",
492
+ '}',
493
+ "console.log(done.length ? done.join('; ') : 'nothing to normalize')",
494
+ '',
495
+ ].join('\n')
496
+ }
497
+
400
498
  /**
401
499
  * 找应用自带的 node 与 pnpm(`<应用目录>/resources/runtime/primary-runtime/dependencies/`)。
402
500
  *
@@ -417,6 +515,27 @@ export function runtimeBins({ exe = process.execPath, exists = fs.existsSync } =
417
515
  return { nodeBin: '', pnpmScript: '' }
418
516
  }
419
517
 
518
+ /**
519
+ * 隐藏启动器(Windows .vbs)。
520
+ *
521
+ * 为什么还需要这一层(2026-11-05 用户 m00633):之前直接 `spawn('cmd.exe', …, {detached:true})`,
522
+ * Node 在 Windows 上给 detached 子进程另开一个控制台——用户会看到一个黑窗口,
523
+ * 以为卡住了,而且那个窗口关不掉。实测 `windowsHide` 管不住它。
524
+ * 改成 wscript 跑一句 `WScript.Shell.Run "cmd /c ""…""", 0, False`:
525
+ * 窗口样式 0 = 完全不显示,脚本照样在跑、照样能杀掉宿主并重启。
526
+ */
527
+ export function vbsFor(cmdPath) {
528
+ // VBS 字符串里 `""` 表示一个引号。
529
+ const inner = String(cmdPath ?? '').replace(/"/g, '""')
530
+ return [
531
+ "' dsh-fish self-update launcher",
532
+ 'Set sh = CreateObject("WScript.Shell")',
533
+ `sh.Run "cmd /c ""${inner}""", 0, False`,
534
+ 'Set sh = Nothing',
535
+ '',
536
+ ].join('\r\n')
537
+ }
538
+
420
539
  /**
421
540
  * 写脚本 + 起进程(detached,宿主被杀也不会带走它)。
422
541
  * 返回脚本路径,调用方负责把状态切到 installing。
@@ -432,6 +551,7 @@ export async function armInstall({
432
551
  hostName,
433
552
  backupSuffix = '',
434
553
  tarball = '',
554
+ platform = process.platform,
435
555
  } = {}) {
436
556
  const v = cleanVersion(version)
437
557
  const dir = path.join(root, 'updates')
@@ -439,15 +559,29 @@ export async function armInstall({
439
559
  const scriptPath = path.join(dir, `install-v${v}${backupSuffix}.cmd`)
440
560
  const logFile = path.join(dir, 'last.log')
441
561
  const backupDir = path.join(dir, `backup-v${v}${backupSuffix}`)
442
- const script = scriptFor({ version: v, profileDir, exe, nodeBin, pnpmScript, logFile, backupDir, hostName, tarball })
562
+ const normalizePath = path.join(dir, 'normalize-package.cjs')
563
+ await fsp.writeFile(normalizePath, normalizeScript(), 'utf8')
564
+ const script = scriptFor({ version: v, profileDir, exe, nodeBin, pnpmScript, logFile, backupDir, hostName, tarball, normalizePath })
443
565
  // 无 BOM UTF-8:cmd 不认 BOM,带上反而会把第一行认成 `@echo`。
444
566
  await fsp.writeFile(scriptPath, script, 'utf8')
445
- const child = spawnImpl('cmd.exe', ['/c', scriptPath], {
446
- cwd: root,
447
- detached: true,
448
- stdio: 'ignore',
449
- windowsHide: true,
450
- })
567
+ let child
568
+ if (platform === 'win32') {
569
+ const vbsPath = path.join(dir, `install-v${v}${backupSuffix}.vbs`)
570
+ await fsp.writeFile(vbsPath, vbsFor(scriptPath), 'utf8')
571
+ child = spawnImpl('wscript.exe', ['//B', '//Nologo', vbsPath], {
572
+ cwd: root,
573
+ detached: true,
574
+ stdio: 'ignore',
575
+ windowsHide: true,
576
+ })
577
+ } else {
578
+ child = spawnImpl('cmd.exe', ['/c', scriptPath], {
579
+ cwd: root,
580
+ detached: true,
581
+ stdio: 'ignore',
582
+ windowsHide: true,
583
+ })
584
+ }
451
585
  if (child && typeof child.unref === 'function') child.unref()
452
586
  return { scriptPath, logFile, backupDir }
453
587
  }
@@ -572,6 +706,7 @@ export function createSelfUpdater({
572
706
  nodeBin,
573
707
  pnpmScript,
574
708
  spawnImpl,
709
+ platform,
575
710
  hostName: path.basename(exe),
576
711
  // 装的是刚才下载并验过的那个包;文件留在 updates/ 里,之后 pnpm install 还要用。
577
712
  tarball: st.file,
package/lib/settings.js CHANGED
@@ -63,7 +63,7 @@ export const DEFAULTS = {
63
63
  // 频率是上限不是行为要求:到上限就闭嘴,没到也不保证一定开口。
64
64
  // 概率随「多久没人说话」缓慢上升(baseChance + 超出 minIdleHours 的每小时 * gainPerHour,封顶 maxChance)。
65
65
  proactive: {
66
- enabled: false, // 默认关;公开版没有这个功能
66
+ enabled: false, // 默认关;功能公开可见,但不替用户打开
67
67
  maxPerDay: 2, // 一天最多开几次空手回合
68
68
  minIdleHours: 6, // 距上一次记录不足这么久就不打扰
69
69
  baseChance: 0.15, // 每次掷骰子的基础概率
@@ -83,7 +83,9 @@ export const DEFAULTS = {
83
83
  // 注意:这里**没有任何一项是"她喜欢什么"**。这些全是环境参数——
84
84
  // 她看什么、信不信、想不想写,是她自己的事,不是旋钮。
85
85
  browse: {
86
- enabled: true, // 装好就能用;不想让她上网就关掉
86
+ // 2026-11-05(用户 m02562):默认关闭。刷站这条线不再由唤醒驱动,
87
+ // 参数留着是为了老配置能读进来,不会因为缺字段炸掉。
88
+ enabled: false,
87
89
  perDay: 1, // 每天最多刷几次(0 = 从不)
88
90
  topN: 4, // 一次看几条
89
91
  // 阶段 1 每次轮换几个不同的分区排行(2026-10-27,用户 m00763:首批别只吃固定片单)
@@ -106,10 +108,19 @@ export const DEFAULTS = {
106
108
  // 放在设置里比写死合理(写死就是让一部分人永远用不了)。
107
109
  // 注意:这是环境参数,不是"她性格"的一部分。
108
110
  bili: {
111
+ // 2026-11-05(用户 m02562):刷站不再由唤醒驱动,她自己的 bili_* 工具默认还能用;
112
+ // 想彻底断网就把这一项设成 false。
113
+ enabled: true,
109
114
  minIntervalMs: 800, // 两次请求之间最少等多久
110
115
  jitterMs: 500, // 在最小间隔上再随机多等 0–jitterMs(打散固定节奏)
111
116
  },
112
117
 
118
+ // 她自己的三件事(2026-11-05,用户 m02634):各自一个开关,默认全开。
119
+ // 关掉只是「以后不再这么做」,已经发生的记录一条都不删。
120
+ drive: { enabled: true }, // 主动性:攒够了才自己醒(lib/drive.js)
121
+ interest: { enabled: true }, // 兴趣:摆到桌上的那几行(好奇/旧句子/回音/反复主题)
122
+ dream: { enabled: true }, // 梦(lib/dream.js)
123
+
113
124
  // 看画面(2026-10-23 加)
114
125
  // 她看一条视频的**画面**:抽 9 帧拼成一张图,她 read_image 看。
115
126
  // 为什么单独一个开关而不跟 browse 绑死:抽帧要十秒、要 ffmpeg、占带宽,
@@ -161,6 +172,14 @@ export const DEFAULTS = {
161
172
  replyChars: 0, // 单轮最多写多少字(0 = 不限,提示词里不出现任何长度要求)
162
173
  },
163
174
 
175
+ // 房间(2026-11-05,用户 m00304)
176
+ // 群房按「建了多久」轮换,不按轮数:群和群的热度差几十倍,同一阈值要么小群
177
+ // 永远换不了,要么热闹的群一天换三间。7 天是用户定的默认值;0 = 不轮换。
178
+ // 这项只管插件建的群房/客访房,书房仍按 chat.rotateAfter 轮数换。
179
+ rooms: {
180
+ groupRotateDays: 7, // 群房用满几天换新(0 = 不换)
181
+ },
182
+
164
183
  // 记忆与上下文(2026-10-23 加)
165
184
  // docBudget 是她身份文档的字数上限:超了不是报错,是**按优先级丢可选段**
166
185
  // (四条黑名单与"你是谁"永远不丢)。她记得越多,文档越该只留最要紧的。
@@ -171,6 +190,7 @@ export const DEFAULTS = {
171
190
  // 实测四档:1800/2000 丢打脸,2200 反而丢「你对自己的说法」(更糟),
172
191
  // 2400 一段不丢、余 167 字。数字不是品格,只是"别把已有的段挤掉"。
173
192
  sleepEveryDays: 7, // 每几天让她"想一想"一次(0 = 不主动想)
193
+ selfWake: true, // 醒来时没派活、桌上也没东西时,她可以自己找点事做(空醒,0.9.0)
174
194
  sleepKeep: 12, // 她总共留多少条自述(超出后最早的折进「更早以前」)
175
195
  beliefsShown: 3, // 她一轮里逐条摆出来的判断数(1–8,默认 3);其余折成一行
176
196
  // 2026-10-25 用户问「想让她记住更多能不能调」——原来写死在
@@ -229,6 +249,7 @@ const LIMITS = {
229
249
  'comment.scanEveryMin': [5, 720],
230
250
  'chat.rotateAfter': [0, 500],
231
251
  'chat.replyChars': [0, 2000],
252
+ 'rooms.groupRotateDays': [0, 365],
232
253
  'memory.docBudget': [600, 4000],
233
254
  'memory.sleepEveryDays': [0, 60],
234
255
  'memory.sleepKeep': [0, 100],
@@ -338,6 +359,10 @@ export function normalizeConfig(raw = {}) {
338
359
 
339
360
  // 主动说话(0.8.1 本机实验):布尔严格化 + 数值夹紧(夹紧规则真身在 proactive.js)
340
361
  cfg.proactive = { ...DEFAULTS.proactive, ...cfg.proactive }
362
+ cfg.memory.selfWake = cfg.memory.selfWake !== false
363
+ cfg.drive = { enabled: cfg.drive?.enabled !== false }
364
+ cfg.interest = { enabled: cfg.interest?.enabled !== false }
365
+ cfg.dream = { enabled: cfg.dream?.enabled !== false }
341
366
  cfg.proactive.enabled = Boolean(cfg.proactive.enabled)
342
367
 
343
368
  // 她的工具面:preset 名 trim(空 = 用宿主默认组合),allow 去重去空
@@ -349,6 +374,7 @@ export function normalizeConfig(raw = {}) {
349
374
 
350
375
  // 对话与界面(2026-10-25):replyChars 由 LIMITS 夹值,这里只补布尔严格化
351
376
  cfg.chat = { ...DEFAULTS.chat, ...cfg.chat }
377
+ cfg.rooms = { ...DEFAULTS.rooms, ...cfg.rooms }
352
378
  cfg.ui = { ...DEFAULTS.ui, ...cfg.ui }
353
379
  cfg.ui.ownerHintDismissed = Boolean(cfg.ui.ownerHintDismissed)
354
380