@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/scheduler.js CHANGED
@@ -16,7 +16,7 @@ import { join } from 'node:path'
16
16
  import { deriveSelf } from './derive.js'
17
17
  import { validateEvent } from './schema.js'
18
18
  import { readEvents, appendEvent, rootDir, ensureHer, paths, readJson } from './store.js'
19
- import { loadConfig, dateKey, inWindow, pickWakeTime } from './settings.js'
19
+ import { loadConfig, dateKey, inWindow, pickWakeTime, updateConfig } from './settings.js'
20
20
  import { plan } from './generator.js'
21
21
  import { findTemplate } from './events.js'
22
22
  import { offerPrompt, SLEEP_TODAY } from './persona.js'
@@ -29,28 +29,28 @@ import { seenOn } from './whatseen.js'
29
29
  import { renderWatchList } from './history.js'
30
30
  import { fetchCover, coverLines } from './cover.js'
31
31
  import { riskState, browsePlan, noteRisk, isRiskCode } from './risk.js'
32
- import { scanReplies, renderReplies } from './comment.js'
32
+ import { scanReplies, renderReplies, repliesScanDue } from './comment.js'
33
33
  import { flushReport } from './report.js'
34
- import { talkSessionId } from './home.js'
34
+ import { talkSessionId, homeStatus } from './home.js'
35
35
  import { refreshSelfDoc } from './selfdoc.js'
36
- import { setWakeSection, clearWakeSection, getWakeSection } from './identity.js'
36
+ import { setWakeSection, clearWakeSection, getWakeSection, liveAgentOf } from './identity.js'
37
+ import { queueNote, deliverQueued } from './sessionnote.js'
37
38
  import { awayNow, awayLine } from './outing.js'
38
39
  import { askInChat, picked } from './askinchat.js'
39
40
  import { restedToday, settleStaleAsks } from './ask.js'
40
- import { devModeSync } from './devmode.js'
41
- import { proactiveState, proactiveSection, recentMaterial, looseEnds, TOOL_ASK_NOTE } from './proactive.js'
41
+ import { proactiveConfig, proactiveState, proactiveSection, recentMaterial, looseEnds, TOOL_ASK_NOTE } from './proactive.js'
42
42
  import { deliverWake, ensureSessionLive, WAKE_PING } from './deliver.js'
43
- import { followWake, planWakeCompaction, compactWake } from './compact.js'
43
+ import { followWake, planWakeCompaction, compactWake, looksLikeNothingSaid, looksLikeStreamFailure } from './compact.js'
44
44
  import { logLine } from './log.js'
45
45
  import { recordGrant } from './grant.js'
46
46
  import { readNotes, renderNotes } from './note.js'
47
47
  import { curiosityOf, renderEcho } from './curiosity.js'
48
48
  import { deskFor } from './desk.js'
49
- import { driveOf } from './drive.js'
50
- import { interestOf, renderInterest } from './interest.js'
49
+ import { driveOf, deskHasMaterial } from './drive.js'
50
+ import { interestOf, topicLabel } from './interest.js'
51
51
  import { lastLeaveAt, lastSeenAt } from './curiosity.js'
52
52
  import { dreamOn } from './dream.js'
53
- import { hoursSinceLastWake, staleLines, tableFor } from './dreamtable.js'
53
+ import { hoursSinceLastWake, staleForDesk, staleLines, tableFor } from './dreamtable.js'
54
54
  import { freeWakeFor } from './freewake.js'
55
55
  import { readOutings, readPossessions } from './outing.js'
56
56
 
@@ -96,18 +96,27 @@ const DAY_END_CUE = '(今天到这里了。这一刻是我自己的。)'
96
96
 
97
97
 
98
98
  /**
99
- * 等这一次唤醒的回合真正结束。
99
+ * 等这一轮真说完:认准「我们自己排进去的那一轮」的 turn/end。
100
100
  *
101
- * 坑(真机抓到):`sessionController.prompt()` 在把消息**排进队列**时就 resolve 了,
102
- * 模型还在后面慢慢想。之前紧接着就 flushReport,报告在她开口前 8 秒就写完,
103
- * 于是 out/YYYY-MM-DD.md 永远显示"今天她什么都没说"——她其实说了,只是晚了。
101
+ * 为什么要有这一步(真机抓到):`sessionController.prompt()` 在把消息**排进队列**时就
102
+ * resolve 了,模型还在后面慢慢想。之前紧接着就 flushReport,报告在她开口前 8 秒就写完,
103
+ * 于是 out/YYYY-MM-DD.md 永远显示「今天她什么都没说」——她其实说了,只是晚了。
104
104
  *
105
- * 宿主的信号是 `session/event`,事件里 `type === 'turn/end'`。
106
- * 拿不到事件总线(单测/老宿主)时退回"等一会儿再写一次",
107
- * 反正 flushReport 是幂等的重写,多写一次不会错。
105
+ * 坑(issue #36):以前只认会话不认回合——同一会话里任何一个 turn/end 都算数。
106
+ * 手动「叫她一次」时她往往正开在别的一轮里(比如他刚发过话),宿主的 `agent.followup`
107
+ * 会把我们这条排进下一轮,于是**当前那一轮**先 turn/end,处境段(fish:wake)当场被清,
108
+ * 真正醒来那一轮反而什么都没有。
109
+ *
110
+ * 现在的判据:监听挂上之后,必须先见到本条会话的 `turn/start`,之后的 `turn/end`
111
+ * 才算「我们这一轮说完了」;turn/start 之前来的 turn/end 属于上一轮,忽略。
112
+ * 拿不到事件总线(单测/老宿主)时退回原来的即时行为,反正就绪后写一次就够了,
113
+ * 而且 flushReport 是幂等的重写,多写一次不会错。
108
114
  */
109
115
  function watchTurnEnd(ctx, sessionId, done) {
110
116
  let fired = false
117
+ // 宿主没给事件总线:没有回合可认,退回「等一会儿就动手」的老行为。
118
+ const noBus = typeof ctx.on !== 'function'
119
+ let started = noBus
111
120
  const once = () => {
112
121
  if (fired) return
113
122
  fired = true
@@ -116,7 +125,8 @@ function watchTurnEnd(ctx, sessionId, done) {
116
125
  const off = ctx.on?.('session/event', (session, event) => {
117
126
  const id = session?.header?.id ?? session?.id
118
127
  if (id !== sessionId) return
119
- if (event?.type === 'turn/end') once()
128
+ if (event?.type === 'turn/start') { started = true; return }
129
+ if (event?.type === 'turn/end' && started) once()
120
130
  })
121
131
  return { off, once, get fired() { return fired } }
122
132
  }
@@ -162,6 +172,17 @@ export function startScheduler(ctx, deps = {}) {
162
172
  const t = busy.get(id)
163
173
  return t !== undefined && Date.now() - t < BUSY_STALE_MS
164
174
  }
175
+ // 别的插件知道她在忙(比如在打一局游戏),这个调度器不知道。它们可以
176
+ // `ctx.on('dsh-fish/busy', (sessionId) => true)`,不用知道这个包叫什么、装在哪。
177
+ // 监听要同步返回,只有 true 算忙。抛错、返回 Promise 都按不忙算;Promise 的拒绝要接住,
178
+ // 宿主把未处理的拒绝当致命错误,别人的一个监听不该把整个进程带走。
179
+ const busyElsewhere = (id) => {
180
+ try {
181
+ const r = ctx.bail?.('dsh-fish/busy', id)
182
+ if (typeof r?.then === 'function') r.then(undefined, (e) => log('dsh-fish/busy 监听拒绝', e?.message ?? e))
183
+ return r === true
184
+ } catch { return false }
185
+ }
165
186
  const waitIdle = async (id) => {
166
187
  const until = Date.now() + IDLE_WAIT_MS
167
188
  while (!disposed && isBusy(id) && Date.now() < until) await new Promise((r) => setTimeout(r, 2000))
@@ -179,12 +200,18 @@ export function startScheduler(ctx, deps = {}) {
179
200
  * 她可以选择开口,也可以什么都不做——不写任何「你应该」。
180
201
  */
181
202
  async function maybeProactive({ events, config, dk, now }) {
182
- if (!devModeSync(root)) return false
203
+ // 2026-11-05(用户 m02634):「主动性」有开关。关着 = 她不会自己攒劲儿开一轮;
204
+ // 日程唤醒与手动叫醒都不经过这里,照样能用。
205
+ if (config?.drive?.enabled === false) {
206
+ if (proactiveSkip !== '主动性关着') { proactiveSkip = '主动性关着'; log('proactive skip', '主动性关着') }
207
+ return false
208
+ }
183
209
  // 2026-11-04(用户 m01466):把她的成长档案交给判定函数。
184
210
  // 之前这里是 `proactiveState({ config, events, now })` —— 它收不到 model,
185
211
  // 于是第 3 天和第 27 天算出来的概率一模一样(「自己开口的出口不该是恒温的」)。
186
- // deriveSelf 在下面本来就要算一次,只是提前到判定之前。
187
- const model = deriveSelf(events, { growthDays: config.growthDays })
212
+ // 关着的时候判定用不上 model,不算:tick 每分钟一次,deriveSelf 是同步的,
213
+ // 事件流长了会卡住主线程,而这个开关默认是关的。开着时和原来一样,先算再判定。
214
+ const model = proactiveConfig(config).enabled ? deriveSelf(events, { growthDays: config.growthDays }) : null
188
215
  const st = proactiveState({ config, events, now, model })
189
216
  if (!st.ok) {
190
217
  if (st.why && st.why !== proactiveSkip) {
@@ -198,7 +225,7 @@ export function startScheduler(ctx, deps = {}) {
198
225
  lastProbe = now
199
226
  // ---- 劲儿(2026-11-05,用户 m02031)----
200
227
  // 用户的定案:把「什么时候开这一轮」从掷骰子换成攒劲儿。
201
- // 底噪:每小时 0.12,攒到 0.8 想动一次,动完扣 0.55,两次至少隔 40 分钟;
228
+ // 底噪:每小时 0.12,攒到 0.8 想动一次,两次至少隔两小时;
202
229
  // 冲动:桌上有没答的问题 +0.06、有新句子 +0.04、他回了她留的话 +0.12(封顶 +0.3);
203
230
  // 折扣:她上一次开口没人接,底噪 ×0.7(打扰成本,Horvitz)。
204
231
  // 这张桌子是**判据用的摘要**,不是正文那段(正文那段在 wake() 里现算现摆)。
@@ -210,7 +237,19 @@ export function startScheduler(ctx, deps = {}) {
210
237
  const leave = lastLeaveAt(events)
211
238
  const seen = lastSeenAt(events)
212
239
  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 } })
240
+ const driveDesk = { gaps: gaps.length, stale: 0, echo: echoBack, favorite: interest.length > 0 }
241
+ const pc = proactiveConfig(config)
242
+ const drv = driveOf({ events, now, stage: model.stage, desk: driveDesk, seenCountsAsHeard: pc.idleCountsSeen })
243
+ // 空桌不醒(2026-11-05,用户 m02937):桌上什么都没有时,劲儿再满也不起——
244
+ // 否则她只能回一句「没什么要说的」,既占额度又在对话里留一条噪音。
245
+ if (!deskHasMaterial(driveDesk)) {
246
+ const why = `桌上没有可说的东西(劲儿 ${drv.value.toFixed(2)})`
247
+ if (why !== proactiveSkip) {
248
+ proactiveSkip = why
249
+ log('drive', why)
250
+ }
251
+ return false
252
+ }
214
253
  if (!drv.ready) {
215
254
  if (drv.why && drv.why !== proactiveSkip) {
216
255
  proactiveSkip = `drive ${drv.value.toFixed(2)} · ${drv.why}`
@@ -222,10 +261,19 @@ export function startScheduler(ctx, deps = {}) {
222
261
  if (!sc) return false
223
262
  const sessionId = await talkSessionId(ctx, { root, log })
224
263
  if (!sessionId) return false
264
+ // 她正忙时不投:处境段会挂在正在跑的那一轮上,那一轮结束又把它清掉,
265
+ // 真正的这一轮就只收到一个空格。过 5 分钟再看(定时叫醒那条路一直有这个判断)。
266
+ const elsewhere = busyElsewhere(sessionId)
267
+ if (elsewhere || isBusy(sessionId)) {
268
+ lastProbe = now - 25 * 60_000
269
+ const why = elsewhere ? '别的插件说她在忙(dsh-fish/busy)' : '她正忙'
270
+ if (proactiveSkip !== why) { proactiveSkip = why; log('proactive skip', why) }
271
+ return false
272
+ }
225
273
 
226
274
  const material = recentMaterial(events, { now })
227
275
  const loose = looseEnds(events, { now })
228
- const text = proactiveSection({ now, state: st, material, loose })
276
+ const text = proactiveSection({ now, state: st, material, loose, sendTools: pc.sendTools, countSeen: pc.idleCountsSeen })
229
277
 
230
278
  const requestId = `proactive-${now}`
231
279
  // 这一轮她写的话不算他来过:唤醒时间窗开到 turn/end(投递只等入队,见 wakewin.js),
@@ -254,7 +302,7 @@ export function startScheduler(ctx, deps = {}) {
254
302
 
255
303
  // 投递之前先把这一轮盯起来(2026-11-05,用户 m00803)。认领信号是 rpcId:
256
304
  // 每次投递一个唯一值,命中之前一律当「还没开始」,所以早挂不会认错轮。
257
- const follow = followWake(ctx, { rpcId: requestId, log })
305
+ const follow = followWake(ctx, { rpcId: requestId, log, sendTools: pc.sendTools })
258
306
 
259
307
  // 2026-11-04(用户 m00428):投递换成正解 —— 自建 `source.kind='fish-wake'`
260
308
  // 的消息交给 `agent.followup()`。宿主把它画成一张默认收起的折叠条,
@@ -297,22 +345,32 @@ export function startScheduler(ctx, deps = {}) {
297
345
  return
298
346
  }
299
347
  const meta = { day: model.day, stage: model.stage, source: 'timer' }
300
- if (out.spoke === true) {
348
+ // 「没什么要说的」不是开口(2026-11-05,用户 m02937):她写一句元话语
349
+ // 交代「这轮没话说」,在文本界面上几乎不可避免;按静默记、折掉,别当一次开口。
350
+ // 配了发送工具时正文到不了人,只认工具;没配时还是看正文
351
+ // 上游断流写成的那行红字也不是她开口(2026-11-06 真机看到的 `❌ [ACP] stream error`)
352
+ const broken = out.spoke === true && looksLikeStreamFailure(out.said)
353
+ const quiet = pc.sendTools.length > 0
354
+ ? out.toolSpoke !== true
355
+ : (out.spoke !== true || broken || looksLikeNothingSaid(out.said))
356
+ if (!quiet) {
301
357
  await appendEvent({ type: 'meta', text: `proactive-turn:${dk}:${now}` }, meta, root).catch(() => {})
302
358
  log('proactive', `开了一次空手回合,她开口了(劲儿 ${drv.value.toFixed(2)})`)
303
359
  return
304
360
  }
305
361
  await appendEvent({ type: 'meta', text: `proactive-silent:${dk}:${now}` }, meta, root).catch(() => {})
306
362
  // 折叠只在拿到闭合区间时做:超时、或没认出那条 ping,out.endSeq 都是 -1。
307
- const plan = out.endSeq >= 0
363
+ const plan = out.endSeq >= 0 && pc.foldSilent
308
364
  ? planWakeCompaction(out.events, { startSeq: out.startSeq, endSeq: out.endSeq, rpcId: requestId })
309
365
  : null
310
366
  // 一轮可能被宿主的投影切成几段:各段单独换,边界节点原样留着。
311
367
  let folded = 0
312
368
  if (plan) for (const run of plan.runs) if (await compactWake(out.session, run, { log })) folded += 1
369
+ const tail = broken ? ',这一轮上游断流了(那行报错不算她开口)'
370
+ : out.spoke === true ? ',只说了句没什么要说的' : ''
313
371
  log('proactive', folded > 0
314
- ? `醒了一次,没有开口(这一轮折成 ${folded} 行,盖住 ${plan.shadowed} 个节点)`
315
- : `醒了一次,没有开口(${out.ok ? '这轮没什么可折' : out.why})`)
372
+ ? `醒了一次,没有开口${tail}(这一轮折成 ${folded} 行,盖住 ${plan.shadowed} 个节点)`
373
+ : `醒了一次,没有开口${tail}(${out.ok ? '这轮没什么可折' : out.why})`)
316
374
  } catch (err) {
317
375
  log('proactive settle', err?.message ?? err)
318
376
  }
@@ -386,6 +444,31 @@ export function startScheduler(ctx, deps = {}) {
386
444
  return
387
445
  }
388
446
 
447
+ // ---- 有人回她了吗(2026-11-06,用户 m01641)----
448
+ // 原来这里只有 wake() 拼正文时顺带扫一次:没被叫醒的那天就完全看不到回复,
449
+ // 而设置页那个「多久看一次有没有人回复」一直是个空旋钮(scanEveryMin
450
+ // 全仓没有消费者)。现在按那个间隔真去问一次,问到了就把那几句排队投给她。
451
+ // 投递用 queueNote:她醒着就立刻补进当前这条会话,没开着就等她下次打开。
452
+ try {
453
+ if (repliesScanDue(config?.comment, now)) {
454
+ const waiting = await scanReplies({ root, events, dateKey: dk }).catch(() => [])
455
+ // 无论有没有人回都记一次:不记的话每 30 秒就会重新问一遍 B 站(-412 就是这么撞出来的)。
456
+ await updateConfig({ comment: { lastScanAt: new Date(now).toISOString() } }, root).catch(() => {})
457
+ if (waiting.length) {
458
+ const txt = renderReplies(waiting)
459
+ if (txt) {
460
+ await queueNote(root, txt).catch(() => null)
461
+ const h = await homeStatus(root).catch(() => null)
462
+ const agent = h?.sessionId ? liveAgentOf(ctx, h.sessionId) : null
463
+ if (agent) await deliverQueued(agent, root, { home: true }).catch(() => null)
464
+ log('comment', `有人回她了(${waiting.length} 条),已经递过去。`)
465
+ }
466
+ }
467
+ }
468
+ } catch (err) {
469
+ log('comment', `扫回复失败:${err?.message ?? err}`)
470
+ }
471
+
389
472
  // ---- 主动说话(0.8.1 本机实验,2026-10-30 用户 m04610)----
390
473
  // 它不是唤醒:不占 wakesPerDay,也不推成长事件。只在 .dev 下生效。
391
474
  // 开了就 return——这一趟由她自己决定说还是不说,不再叠一次醒一醒。
@@ -451,8 +534,9 @@ export function startScheduler(ctx, deps = {}) {
451
534
  // 轮换之后这里查的是旧会话忙不忙,新会话正说着话也照样插进去。
452
535
  const sid = (await readJson(join(paths(root).root, 'home.json'), null).catch(() => null))?.sessionId
453
536
  ?? deps.sessionId
454
- if (sid && isBusy(sid)) {
455
- const why = '她正在说话,等这一轮结束再叫'
537
+ const elsewhere = sid ? busyElsewhere(sid) : false
538
+ if (sid && (elsewhere || isBusy(sid))) {
539
+ const why = elsewhere ? '别的插件说她在忙(dsh-fish/busy),等一等再叫' : '她正在说话,等这一轮结束再叫'
456
540
  const next = shouldLogSkip(skipLogged, dk, now, why)
457
541
  if (next) { skipLogged = next; log('skip', why) }
458
542
  return
@@ -653,10 +737,16 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
653
737
  // 旧句子:判「要不要空醒」要用渲染好的那段,摆桌子要的是原始行——一次算好两处都用。
654
738
  let staleRows = []
655
739
  let free = null
656
- if (devModeSync(root)) {
740
+ {
657
741
  try {
658
742
  const { events: evs } = await readEvents(root)
659
- if (!dreamOn(evs, model.day).length) {
743
+ // 2026-11-06(issue #42):「兴趣」关掉时不再摆旧句子。
744
+ // 原来 freeWakeFor 只要 tableText 非空就判成 table,而 tableText 根本不看 interest 开关——
745
+ // 于是「关掉摆东西」之后,每 6 小时还是会因为旧句子空醒一次,而且那一轮既不想一想、
746
+ // 也不给规则申请、也不给「今天到这里」:14 天里 14 次全是旧句子。
747
+ // 旧句子属于「桌上那几样东西」里的一样,跟着兴趣开关走才自洽。
748
+ const staleOn = config?.interest?.enabled !== false
749
+ if (staleOn && !dreamOn(evs, model.day).length) {
660
750
  const shownToday = evs.some((e) => e?.type === 'meta' && String(e.text ?? '').startsWith(`table:${dk}`))
661
751
  const [{ outings }, { possessions }] = await Promise.all([readOutings(root), readPossessions(root)])
662
752
  const tripSessions = new Set(
@@ -693,7 +783,15 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
693
783
  // 顺序是刻意的:先看,再想。如果先出事件提示再塞素材,
694
784
  // 她就会先按模板说话、再拿素材硬套——那是模板,不是她。
695
785
  // 反过来(先看见世界的动静,再问她怎么想)才像一个人醒来的样子。
696
- const look = await lookBeforeWake(root, model, config, dk, deps, log)
786
+ //
787
+ // 2026-11-05(用户 m01128):摆料那一轮不摆世界。这一轮只该有一张桌子——
788
+ // 旧句子 + 「这一轮没有待办」。B 站动静与惦记清单都会替她提供「最像活的那件事」,
789
+ // 18:44 真机她就是挑了旧句子里唯一像活的一条去核对,没往梦里想。
790
+ // 手动「让她去刷 B 站」(forceBrowse)本来就指定了这一轮做什么,不跟着空醒一起收。
791
+ // 2026-11-05(用户 m02562):刷站不再挂在唤醒上。所有醒来都只有桌子与世界之外的事,
792
+ // 想上网由她自己调工具(bili_*),不再由唤醒替她决定看什么。
793
+ const showWorld = false
794
+ const look = showWorld ? await lookBeforeWake(root, model, config, dk, deps, log) : { text: '', intro: '', picks: [] }
697
795
  if (look.text) {
698
796
  parts.push('', look.intro, '', look.text)
699
797
  // 带链接的清单(2026-10-23 用户提):她能说出"有一条是一个人跑不起来了",
@@ -723,25 +821,33 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
723
821
  // 交给 deskFor 并成一段,配额与去重都在那个纯函数里;回音是另一件事(他有没有来过),
724
822
  // 仍然单独一句话,不并进桌子。
725
823
  // 只摆**事实**:不派活、不写「你可以去想想」、不给任何数字——派活了就又变成任务清单。
726
- // 和这一版的其余实验一样先只在 .dev 里生效;公开版等劲儿那一版接通后再开。
824
+ // 2026-11-05(用户 m02634):「兴趣」有开关。关掉 = 桌子那几行(好奇/旧句子/回音)
825
+ // 一概不摆;缺工具那句仍留着,它不是活、是她世界里的常量。
727
826
  // 读失败绝不拖垮一次唤醒:她醒着这件事比桌上的东西重要。
728
- if (devModeSync(root)) {
827
+ if (config?.interest?.enabled !== false) {
729
828
  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)
829
+ // 摆料那一轮不摆桌子:这一轮只留旧句子(用户 m01128)。
830
+ {
831
+ const { events: evc } = await readEvents(root)
832
+ // 兴趣(2026-11-05,用户 m02031):反复出现的那件事排进同一张桌子,
833
+ // 不另起一段——「一张桌子」是 L 文档定下的约定。
834
+ const favItem = interestOf(evc, now.getTime())[0]
835
+ const fav = favItem ? topicLabel(favItem.text) : ''
836
+ const desk = deskFor({
837
+ gaps: curiosityOf(evc, now),
838
+ // 摆料那一轮 tableText 已经把旧句子摆出来了,桌子别再摆第二遍
839
+ // (2026-11-05 真机 18:44:同三句原话出现了两次)。
840
+ stale: staleForDesk(tableText, staleRows),
841
+ free: !!free,
842
+ favorite: fav,
843
+ at: now,
844
+ })
845
+ if (desk) parts.push('', desk)
846
+ const echo = renderEcho(evc, now)
847
+ if (echo) parts.push('', echo)
848
+ }
744
849
  // 缺工具就开口要:每次醒来都摆这一句,不派活,只给路径。
850
+ // 摆料那一轮也留着——它不是活,是她世界里的常量(用户 m01128)。
745
851
  parts.push('', TOOL_ASK_NOTE)
746
852
  } catch (err) {
747
853
  log('curiosity', err?.message ?? err)
@@ -936,13 +1042,14 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
936
1042
  }
937
1043
 
938
1044
  // 刷站那几十秒里她可能刚好开始了别的一轮(有人找她)。等那一轮说完再挂处境、再叫:
939
- // watcher 认的是会话不是这一轮,挂早了会被那一轮的 turn/end 收走。
1045
+ // 挂早了会被那一轮的 turn/end 收走(issue #36 之前 watcher 只认会话不认回合,
1046
+ // 现在它至少要见到我们自己这一轮的 turn/start 才算数,双保险)。
940
1047
  if (typeof deps.waitIdle === 'function') await deps.waitIdle(sessionId)
941
1048
 
942
1049
  // 这一轮她写的话不算他来过:唤醒时间窗开到 turn/end,不是开到入队(见 wakewin.js)。
943
1050
  // turn/end 不来时跟处境一起由下面的兜底关;没送进去就在 catch 里关。
944
1051
  // 处境代次(2026-11-04,用户 m01466):见 maybeProactive 里的同一处注释。
945
- // watcher 认的是会话不是这一轮,所以清的时候要带着自己挂上去时拿到的号。
1052
+ // 清的时候要带着自己挂上去时拿到的号,别把别人后来挂的处境一起抹了。
946
1053
  let gen = 0
947
1054
  const releaseWake = holdWake()
948
1055
  const watcher = watchTurnEnd(ctx, sessionId, () => { releaseWake(); void writeReport(); clearWakeSection(gen) })
@@ -1159,14 +1266,9 @@ export async function wakeNow(ctx, deps = {}) {
1159
1266
  const model = deriveSelf(events, { growthDays: config.growthDays })
1160
1267
  const dk = dateKey()
1161
1268
  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)) })
1269
+ // 2026-11-05(用户 m02562):「让她去刷 B 站」这个按钮砍掉了,
1270
+ // 手动叫醒一律走 plan(),和定时那条一样。
1271
+ let decision = plan(model, config, counts, events, { squeeze: gapSqueeze(gapSinceLastWake(events)) })
1170
1272
  const log = deps.log ?? (() => {})
1171
1273
  // 2026-11-04(用户 m01466):手动叫醒也要认最小间隔。
1172
1274
  //
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,9 +26,9 @@ 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
- 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'
30
+ import { personaLine, toolBrief, RECALL_BRIEF, SPEAK_BRIEF, LETTER_BRIEF, NOTE_BRIEF, ASK_BRIEF, proactiveBrief, DREAM_BRIEF, SCHEDULE_BRIEF, DIARY_BRIEF, SLEEP_BRIEF } from './persona.js'
31
+ import { proactiveConfig } from './proactive.js'
32
32
  import { LOOK_BRIEF, WATCH_RULE } from './browse.js'
33
33
  import { activeCarry } from './home.js'
34
34
  import { activeGrants } from './unlock.js'
@@ -121,7 +121,7 @@ export async function continuityOf(root = rootDir(), now = Date.now()) {
121
121
  * @param {object} model deriveSelf 的结果
122
122
  * @returns {string}
123
123
  */
124
- export function renderSelfDoc(model, { budget, carry, continuity, browse = true, speak = false, grants = [], shown, diary = true, sleepEveryDays = 0, bansMuted = false, extraTools = [], dream = false, schedule = false, proactive = false } = {}) {
124
+ export function renderSelfDoc(model, { sendTools = [], budget, carry, continuity, browse = true, speak = false, grants = [], shown, diary = true, sleepEveryDays = 0, bansMuted = false, extraTools = [], dream = false, schedule = false, proactive = false } = {}) {
125
125
  const head = [
126
126
  // 这里**故意不写**「由 dsh-fish 自动生成」这类话。
127
127
  // 这个文件会被宿主原样渲染进她的会话——她能看见每一行。
@@ -167,7 +167,7 @@ export function renderSelfDoc(model, { budget, carry, continuity, browse = true,
167
167
  ...(dream ? [DREAM_BRIEF, ''] : []),
168
168
  // 主动说话(2026-10-30,用户 m04610):her_say 只在 .dev 下注册,
169
169
  // 说明书也只在同一个判据下挂——公开版不能让「她」以为有这个工具。
170
- ...(proactive ? [PROACTIVE_BRIEF, ''] : []),
170
+ ...(proactive ? [proactiveBrief(sendTools), ''] : []),
171
171
  // 定时提醒(2026-10-30,用户 m04335 / m04504):默认不放,只有用户把
172
172
  // schedule_create 加进白名单才挂——不然就是告诉她「你有这个工具」,伸手却没有。
173
173
  ...(schedule ? [SCHEDULE_BRIEF, ''] : []),
@@ -263,11 +263,10 @@ export async function writeSelfDoc(root = rootDir(), { log, exec, tenant } = {})
263
263
  // 2026-10-27(用户 m01371):白名单里额外放行的工具要念给她听,
264
264
  // 否则「能不能在 QQ 上说话」她只能回一句不知道。
265
265
  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),
266
+ // 梦(2026-10-30 m02837)与主动说话(m04610):0.9.0 起公开,两处都恒为真。
267
+ dream: true,
268
+ proactive: true,
269
+ sendTools: proactiveConfig(config).sendTools,
271
270
  // 定时工具默认不放;用户在白名单里加了 schedule_create 才告诉他这条路通。
272
271
  schedule: (config?.tools?.allow ?? []).some((n) => String(n).trim() === 'schedule_create'),
273
272
  })