@ssamurias/dsh-fish 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +60 -4
  2. package/CHANGELOG.md +85 -1
  3. package/README.md +13 -6
  4. package/RELEASE-NOTES-0.9.1.md +296 -0
  5. package/RELEASE-NOTES-0.9.2.md +46 -0
  6. package/RELEASE.md +152 -0
  7. package/lib/absence.js +41 -27
  8. package/lib/ask.js +91 -30
  9. package/lib/askinchat.js +83 -1
  10. package/lib/browse.js +1 -0
  11. package/lib/capture.js +35 -2
  12. package/lib/cardanswer.js +84 -0
  13. package/lib/client.js +116 -19
  14. package/lib/comment.js +111 -2
  15. package/lib/compact.js +45 -1
  16. package/lib/curiosity.js +6 -2
  17. package/lib/deliver.js +46 -0
  18. package/lib/derive.js +25 -13
  19. package/lib/desk.js +3 -1
  20. package/lib/dreamtable.js +13 -2
  21. package/lib/drive.js +14 -2
  22. package/lib/events.js +25 -1
  23. package/lib/freewake.js +4 -1
  24. package/lib/generator.js +2 -1
  25. package/lib/grant.js +18 -2
  26. package/lib/home.js +142 -29
  27. package/lib/identity.js +111 -14
  28. package/lib/index.js +50 -10
  29. package/lib/interest.js +3 -1
  30. package/lib/keep.js +21 -9
  31. package/lib/keepsake.js +20 -13
  32. package/lib/look.js +23 -3
  33. package/lib/note.js +255 -20
  34. package/lib/observe.js +14 -4
  35. package/lib/owner.js +5 -2
  36. package/lib/persona.js +26 -9
  37. package/lib/proactive.js +67 -12
  38. package/lib/say.js +73 -13
  39. package/lib/scheduler.js +186 -56
  40. package/lib/schema.js +18 -0
  41. package/lib/selfdoc.js +5 -3
  42. package/lib/selfupdate.js +8 -1
  43. package/lib/settings.js +5 -0
  44. package/lib/status.js +56 -6
  45. package/lib/store.js +14 -2
  46. package/lib/tenant.js +12 -0
  47. package/lib/turns.js +47 -2
  48. package/lib/upgrade.js +4 -1
  49. package/package.json +2 -1
  50. package/scripts/fix-legacy-link.mjs +197 -90
package/lib/scheduler.js CHANGED
@@ -5,7 +5,8 @@
5
5
  * (身份由 identity.js 以 system prompt 段承载、每轮现读,不拼进唤醒消息)。
6
6
  *
7
7
  * 三条安全线(B §7):
8
- * 1. 定时器只是**提议**。任何一次唤醒失败都不重试、不阻塞下一分钟。
8
+ * 1. 定时器只是**提议**。唤醒失败不算今天的这一次,之后还会再试,但会隔开再试,同一天
9
+ * 最多试 WAKE_FAIL_MAX 次(wakeRetryBlocked):事件流只能追加,不能每分钟写一行。
9
10
  * 2. 一天之内同一条事件模板不重复触发(once 靠 fired 标记,非 once 靠 daily 配额)。
10
11
  * 3. 停用插件 = dispose 定时器,不再有任何后台活动(不靠"记得关")。
11
12
  *
@@ -16,32 +17,33 @@ import { join } from 'node:path'
16
17
  import { deriveSelf } from './derive.js'
17
18
  import { validateEvent } from './schema.js'
18
19
  import { readEvents, appendEvent, rootDir, ensureHer, paths, readJson } from './store.js'
19
- import { loadConfig, dateKey, inWindow, pickWakeTime } from './settings.js'
20
+ import { loadConfig, dateKey, inWindow, pickWakeTime, updateConfig } from './settings.js'
20
21
  import { plan } from './generator.js'
21
22
  import { findTemplate } from './events.js'
22
23
  import { offerPrompt, SLEEP_TODAY } from './persona.js'
23
24
  import { offerDueDay, pendingOffer, grantableRules } from './unlock.js'
24
25
  import { holdWake } from './wakewin.js'
25
- import { detectAbsence } from './absence.js'
26
+ import { detectAbsence, lastAbsenceEvent } from './absence.js'
26
27
  import { createClient } from './bili.js'
27
28
  import { gather, browseCountToday, recordBrowse } from './browse.js'
28
29
  import { seenOn } from './whatseen.js'
29
30
  import { renderWatchList } from './history.js'
30
31
  import { fetchCover, coverLines } from './cover.js'
31
32
  import { riskState, browsePlan, noteRisk, isRiskCode } from './risk.js'
32
- import { scanReplies, renderReplies } from './comment.js'
33
+ import { scanReplies, renderReplies, repliesScanDue, markRepliesSeen, REPLY_SHOW_MAX } from './comment.js'
33
34
  import { flushReport } from './report.js'
34
- import { talkSessionId } from './home.js'
35
+ import { talkSessionId, homeStatus } from './home.js'
35
36
  import { refreshSelfDoc } from './selfdoc.js'
36
- import { setWakeSection, clearWakeSection, getWakeSection } from './identity.js'
37
+ import { setWakeSection, clearWakeSection, getWakeSection, liveAgentOf } from './identity.js'
38
+ import { queueNote, deliverQueued } from './sessionnote.js'
37
39
  import { awayNow, awayLine } from './outing.js'
38
40
  import { askInChat, picked } from './askinchat.js'
39
41
  import { restedToday, settleStaleAsks } from './ask.js'
40
- import { proactiveState, proactiveSection, recentMaterial, looseEnds, TOOL_ASK_NOTE } from './proactive.js'
41
- import { deliverWake, ensureSessionLive, WAKE_PING } from './deliver.js'
42
- import { followWake, planWakeCompaction, compactWake, looksLikeNothingSaid } from './compact.js'
42
+ import { proactiveConfig, proactiveState, proactiveSection, recentMaterial, looseEnds, TOOL_ASK_NOTE } from './proactive.js'
43
+ import { deliverWake, ensureSessionLive, reviveIfBlank, WAKE_PING } from './deliver.js'
44
+ import { followWake, planWakeCompaction, compactWake, looksLikeNothingSaid, looksLikeStreamFailure } from './compact.js'
43
45
  import { logLine } from './log.js'
44
- import { recordGrant } from './grant.js'
46
+ import { recordGrant, GRANT_YES, GRANT_NO } from './grant.js'
45
47
  import { readNotes, renderNotes } from './note.js'
46
48
  import { curiosityOf, renderEcho } from './curiosity.js'
47
49
  import { deskFor } from './desk.js'
@@ -61,6 +63,12 @@ const WAKE_TIMEOUT_MS = 5 * 60_000
61
63
  const REPORT_WAIT_MS = 90_000
62
64
  // 一天的第二次及以后的叫醒,离上一次至少隔这么久(见 nextWakeAt)
63
65
  const WAKE_MIN_GAP_MS = 45 * 60_000
66
+ /** 一天最多试几次失败的叫醒。之后当天不再定时叫,手动叫不受影响。 */
67
+ const WAKE_FAIL_MAX = 3
68
+ /** 失败之后隔多久再试:第一次很快,时段快结束时只失败一次也不至于丢掉今天这一次。 */
69
+ const WAKE_FAIL_GAPS_MS = [5 * 60_000, WAKE_MIN_GAP_MS]
70
+ /** 手动叫醒失败只留记录,不算进定时的账:有人点了一下按钮,不该用掉今天定时的机会。 */
71
+ const WAKE_FAIL_MANUAL = '手动 '
64
72
  // 她正在某一轮里时先等她说完,最多等这么久
65
73
  const IDLE_WAIT_MS = 10 * 60_000
66
74
  // turn/start 之后迟迟等不到 turn/end(宿主重启、事件丢了),超过这么久就不再当她在忙
@@ -95,18 +103,45 @@ const DAY_END_CUE = '(今天到这里了。这一刻是我自己的。)'
95
103
 
96
104
 
97
105
  /**
98
- * 等这一次唤醒的回合真正结束。
106
+ * 今天失败的叫醒是不是已经够多,或者刚失败过。账记在事件流里,重启也算数。
107
+ * @param {Array} events
108
+ * @param {string} dk 今天的日期键
109
+ * @param {number} now
110
+ */
111
+ export function wakeRetryBlocked(events = [], dk = dateKey(), now = Date.now()) {
112
+ let n = 0
113
+ let last = 0
114
+ for (const e of events) {
115
+ if (e?.type !== 'meta' || !String(e.text ?? '').startsWith(`wakefail:${dk}`)) continue
116
+ if (String(e.text).startsWith(`wakefail:${dk} ${WAKE_FAIL_MANUAL}`)) continue
117
+ n += 1
118
+ last = Math.max(last, Date.parse(e.ts || '') || 0)
119
+ }
120
+ return n >= WAKE_FAIL_MAX || (last > 0 && now - last < WAKE_FAIL_GAPS_MS[Math.min(n, WAKE_FAIL_GAPS_MS.length) - 1])
121
+ }
122
+
123
+ /**
124
+ * 等这一轮真说完:认准「我们自己排进去的那一轮」的 turn/end。
125
+ *
126
+ * 为什么要有这一步(真机抓到):`sessionController.prompt()` 在把消息**排进队列**时就
127
+ * resolve 了,模型还在后面慢慢想。之前紧接着就 flushReport,报告在她开口前 8 秒就写完,
128
+ * 于是 out/YYYY-MM-DD.md 永远显示「今天她什么都没说」——她其实说了,只是晚了。
99
129
  *
100
- * 坑(真机抓到):`sessionController.prompt()` 在把消息**排进队列**时就 resolve 了,
101
- * 模型还在后面慢慢想。之前紧接着就 flushReport,报告在她开口前 8 秒就写完,
102
- * 于是 out/YYYY-MM-DD.md 永远显示"今天她什么都没说"——她其实说了,只是晚了。
130
+ * 坑(issue #36):以前只认会话不认回合——同一会话里任何一个 turn/end 都算数。
131
+ * 手动「叫她一次」时她往往正开在别的一轮里(比如他刚发过话),宿主的 `agent.followup`
132
+ * 会把我们这条排进下一轮,于是**当前那一轮**先 turn/end,处境段(fish:wake)当场被清,
133
+ * 真正醒来那一轮反而什么都没有。
103
134
  *
104
- * 宿主的信号是 `session/event`,事件里 `type === 'turn/end'`。
105
- * 拿不到事件总线(单测/老宿主)时退回"等一会儿再写一次",
106
- * 反正 flushReport 是幂等的重写,多写一次不会错。
135
+ * 现在的判据:监听挂上之后,必须先见到本条会话的 `turn/start`,之后的 `turn/end`
136
+ * 才算「我们这一轮说完了」;turn/start 之前来的 turn/end 属于上一轮,忽略。
137
+ * 拿不到事件总线(单测/老宿主)时退回原来的即时行为,反正就绪后写一次就够了,
138
+ * 而且 flushReport 是幂等的重写,多写一次不会错。
107
139
  */
108
140
  function watchTurnEnd(ctx, sessionId, done) {
109
141
  let fired = false
142
+ // 宿主没给事件总线:没有回合可认,退回「等一会儿就动手」的老行为。
143
+ const noBus = typeof ctx.on !== 'function'
144
+ let started = noBus
110
145
  const once = () => {
111
146
  if (fired) return
112
147
  fired = true
@@ -115,7 +150,8 @@ function watchTurnEnd(ctx, sessionId, done) {
115
150
  const off = ctx.on?.('session/event', (session, event) => {
116
151
  const id = session?.header?.id ?? session?.id
117
152
  if (id !== sessionId) return
118
- if (event?.type === 'turn/end') once()
153
+ if (event?.type === 'turn/start') { started = true; return }
154
+ if (event?.type === 'turn/end' && started) once()
119
155
  })
120
156
  return { off, once, get fired() { return fired } }
121
157
  }
@@ -128,6 +164,7 @@ export function startScheduler(ctx, deps = {}) {
128
164
  let lastTick = 0
129
165
  let running = false
130
166
  let disposed = false
167
+ let lastAbsenceKey = null
131
168
  let timer = null
132
169
  let wakeTimer = null
133
170
  // skip 日志去重(2026-10-28):同一小时、同一原因只记一次。
@@ -161,6 +198,17 @@ export function startScheduler(ctx, deps = {}) {
161
198
  const t = busy.get(id)
162
199
  return t !== undefined && Date.now() - t < BUSY_STALE_MS
163
200
  }
201
+ // 别的插件知道她在忙(比如在打一局游戏),这个调度器不知道。它们可以
202
+ // `ctx.on('dsh-fish/busy', (sessionId) => true)`,不用知道这个包叫什么、装在哪。
203
+ // 监听要同步返回,只有 true 算忙。抛错、返回 Promise 都按不忙算;Promise 的拒绝要接住,
204
+ // 宿主把未处理的拒绝当致命错误,别人的一个监听不该把整个进程带走。
205
+ const busyElsewhere = (id) => {
206
+ try {
207
+ const r = ctx.bail?.('dsh-fish/busy', id)
208
+ if (typeof r?.then === 'function') r.then(undefined, (e) => log('dsh-fish/busy 监听拒绝', e?.message ?? e))
209
+ return r === true
210
+ } catch { return false }
211
+ }
164
212
  const waitIdle = async (id) => {
165
213
  const until = Date.now() + IDLE_WAIT_MS
166
214
  while (!disposed && isBusy(id) && Date.now() < until) await new Promise((r) => setTimeout(r, 2000))
@@ -187,8 +235,9 @@ export function startScheduler(ctx, deps = {}) {
187
235
  // 2026-11-04(用户 m01466):把她的成长档案交给判定函数。
188
236
  // 之前这里是 `proactiveState({ config, events, now })` —— 它收不到 model,
189
237
  // 于是第 3 天和第 27 天算出来的概率一模一样(「自己开口的出口不该是恒温的」)。
190
- // deriveSelf 在下面本来就要算一次,只是提前到判定之前。
191
- const model = deriveSelf(events, { growthDays: config.growthDays })
238
+ // 关着的时候判定用不上 model,不算:tick 每分钟一次,deriveSelf 是同步的,
239
+ // 事件流长了会卡住主线程,而这个开关默认是关的。开着时和原来一样,先算再判定。
240
+ const model = proactiveConfig(config).enabled ? deriveSelf(events, { growthDays: config.growthDays }) : null
192
241
  const st = proactiveState({ config, events, now, model })
193
242
  if (!st.ok) {
194
243
  if (st.why && st.why !== proactiveSkip) {
@@ -215,7 +264,8 @@ export function startScheduler(ctx, deps = {}) {
215
264
  const seen = lastSeenAt(events)
216
265
  const echoBack = !!(leave && seen && seen >= leave && now - leave <= 72 * 3600_000)
217
266
  const driveDesk = { gaps: gaps.length, stale: 0, echo: echoBack, favorite: interest.length > 0 }
218
- const drv = driveOf({ events, now, stage: model.stage, desk: driveDesk })
267
+ const pc = proactiveConfig(config)
268
+ const drv = driveOf({ events, now, stage: model.stage, desk: driveDesk, seenCountsAsHeard: pc.idleCountsSeen })
219
269
  // 空桌不醒(2026-11-05,用户 m02937):桌上什么都没有时,劲儿再满也不起——
220
270
  // 否则她只能回一句「没什么要说的」,既占额度又在对话里留一条噪音。
221
271
  if (!deskHasMaterial(driveDesk)) {
@@ -237,10 +287,19 @@ export function startScheduler(ctx, deps = {}) {
237
287
  if (!sc) return false
238
288
  const sessionId = await talkSessionId(ctx, { root, log })
239
289
  if (!sessionId) return false
290
+ // 她正忙时不投:处境段会挂在正在跑的那一轮上,那一轮结束又把它清掉,
291
+ // 真正的这一轮就只收到一个空格。过 5 分钟再看(定时叫醒那条路一直有这个判断)。
292
+ const elsewhere = busyElsewhere(sessionId)
293
+ if (elsewhere || isBusy(sessionId)) {
294
+ lastProbe = now - 25 * 60_000
295
+ const why = elsewhere ? '别的插件说她在忙(dsh-fish/busy)' : '她正忙'
296
+ if (proactiveSkip !== why) { proactiveSkip = why; log('proactive skip', why) }
297
+ return false
298
+ }
240
299
 
241
300
  const material = recentMaterial(events, { now })
242
301
  const loose = looseEnds(events, { now })
243
- const text = proactiveSection({ now, state: st, material, loose })
302
+ const text = proactiveSection({ now, state: st, material, loose, sendTools: pc.sendTools, countSeen: pc.idleCountsSeen })
244
303
 
245
304
  const requestId = `proactive-${now}`
246
305
  // 这一轮她写的话不算他来过:唤醒时间窗开到 turn/end(投递只等入队,见 wakewin.js),
@@ -253,7 +312,7 @@ export function startScheduler(ctx, deps = {}) {
253
312
  let gen = 0
254
313
  const watcher = watchTurnEnd(ctx, sessionId, () => {
255
314
  releaseWake()
256
- clearWakeSection(gen)
315
+ clearWakeSection(gen, sessionId)
257
316
  })
258
317
  try {
259
318
  // 2026-11-05(用户 m03265):原来这里是 `sc.create({ sessionId, cwd })`,
@@ -262,14 +321,16 @@ export function startScheduler(ctx, deps = {}) {
262
321
  // 现在先探活;探不通才退回建(老宿主没有 resolveAgent 时也走这条)。
263
322
  const live = await ensureSessionLive(sc, sessionId)
264
323
  if (!live.live) {
265
- log('proactive: 会话探活没通过,退回新建一条', live.why ?? '')
266
- await sc.create({ sessionId, cwd: await ensureHer(root) }).catch(() => {})
324
+ log('proactive: 会话探活没通过,看看能不能按原 id 唤回来', live.why ?? '')
325
+ // 2026-11-06(换房报告):跑过 turn 的会话给不出原 id,裸 create 只会
326
+ // 另开一条空壳 —— 交给 reviveIfBlank 判,见 lib/deliver.js。
327
+ await reviveIfBlank(sc, sessionId, await ensureHer(root), (msg) => log(`proactive: ${msg}`)).catch(() => {})
267
328
  }
268
- gen = setWakeSection(text)
329
+ gen = setWakeSection(text, sessionId)
269
330
 
270
331
  // 投递之前先把这一轮盯起来(2026-11-05,用户 m00803)。认领信号是 rpcId:
271
332
  // 每次投递一个唯一值,命中之前一律当「还没开始」,所以早挂不会认错轮。
272
- const follow = followWake(ctx, { rpcId: requestId, log })
333
+ const follow = followWake(ctx, { rpcId: requestId, log, sendTools: pc.sendTools })
273
334
 
274
335
  // 2026-11-04(用户 m00428):投递换成正解 —— 自建 `source.kind='fish-wake'`
275
336
  // 的消息交给 `agent.followup()`。宿主把它画成一张默认收起的折叠条,
@@ -289,7 +350,7 @@ export function startScheduler(ctx, deps = {}) {
289
350
  // 没送进去就没有这一轮:处境和窗口当场收掉,监听也摘掉,
290
351
  // 不然要等到别的哪一轮结束才清,那一轮开头还挂着这次的处境(跟 wake() 失败时一样)
291
352
  try { follow.off() } catch { /* ignore */ }
292
- clearWakeSection(gen)
353
+ clearWakeSection(gen, sessionId)
293
354
 
294
355
  releaseWake()
295
356
  try { watcher.off?.() } catch { /* ignore */ }
@@ -314,7 +375,12 @@ export function startScheduler(ctx, deps = {}) {
314
375
  const meta = { day: model.day, stage: model.stage, source: 'timer' }
315
376
  // 「没什么要说的」不是开口(2026-11-05,用户 m02937):她写一句元话语
316
377
  // 交代「这轮没话说」,在文本界面上几乎不可避免;按静默记、折掉,别当一次开口。
317
- const quiet = out.spoke !== true || looksLikeNothingSaid(out.said)
378
+ // 配了发送工具时正文到不了人,只认工具;没配时还是看正文
379
+ // 上游断流写成的那行红字也不是她开口(2026-11-06 真机看到的 `❌ [ACP] stream error`)
380
+ const broken = out.spoke === true && looksLikeStreamFailure(out.said)
381
+ const quiet = pc.sendTools.length > 0
382
+ ? out.toolSpoke !== true
383
+ : (out.spoke !== true || broken || looksLikeNothingSaid(out.said))
318
384
  if (!quiet) {
319
385
  await appendEvent({ type: 'meta', text: `proactive-turn:${dk}:${now}` }, meta, root).catch(() => {})
320
386
  log('proactive', `开了一次空手回合,她开口了(劲儿 ${drv.value.toFixed(2)})`)
@@ -322,13 +388,17 @@ export function startScheduler(ctx, deps = {}) {
322
388
  }
323
389
  await appendEvent({ type: 'meta', text: `proactive-silent:${dk}:${now}` }, meta, root).catch(() => {})
324
390
  // 折叠只在拿到闭合区间时做:超时、或没认出那条 ping,out.endSeq 都是 -1。
325
- const plan = out.endSeq >= 0
391
+ const plan = out.endSeq >= 0 && pc.foldSilent
326
392
  ? planWakeCompaction(out.events, { startSeq: out.startSeq, endSeq: out.endSeq, rpcId: requestId })
327
393
  : null
328
394
  // 一轮可能被宿主的投影切成几段:各段单独换,边界节点原样留着。
329
395
  let folded = 0
330
396
  if (plan) for (const run of plan.runs) if (await compactWake(out.session, run, { log })) folded += 1
331
- const tail = out.spoke === true ? ',只说了句没什么要说的' : ''
397
+ const tail = broken ? ',这一轮上游断流了(那行报错不算她开口)'
398
+ : out.spoke !== true ? ''
399
+ // 配了发送工具时,只写正文是「没发出去」,不见得是没话说,这句可以写得很长。
400
+ // 也可能调了工具但失败了,所以不说「没调」
401
+ : pc.sendTools.length > 0 ? ',只写了正文,没发出去' : ',只说了句没什么要说的'
332
402
  log('proactive', folded > 0
333
403
  ? `醒了一次,没有开口${tail}(这一轮折成 ${folded} 行,盖住 ${plan.shadowed} 个节点)`
334
404
  : `醒了一次,没有开口${tail}(${out.ok ? '这轮没什么可折' : out.why})`)
@@ -340,7 +410,7 @@ export function startScheduler(ctx, deps = {}) {
340
410
  } catch (err) {
341
411
  // 没送进去就没有这一轮:处境和窗口当场收掉,监听也摘掉,
342
412
  // 不然要等到别的哪一轮结束才清,那一轮开头还挂着这次的处境(跟 wake() 失败时一样)
343
- clearWakeSection(gen)
413
+ clearWakeSection(gen, sessionId)
344
414
  releaseWake()
345
415
  try { watcher.off?.() } catch { /* ignore */ }
346
416
  throw err
@@ -383,11 +453,19 @@ export function startScheduler(ctx, deps = {}) {
383
453
  // 放在 tick 最前面,因为 wake 的提示词要读 absenceDays;
384
454
  // 晚一步的话今天这次唤醒就用的是昨天的冷落结论。
385
455
  const absence = await detectAbsence(root)
456
+ const { events } = await readEvents(root)
386
457
  // 冷落条数变了 = 她对自己的看法变了,身份文件要跟着重写。
387
458
  // 不写的话那份 system prompt 段会一直停在上一次的值上(她"不知道自己被晾过")。
388
- if (absence?.written) await refreshSelfDoc(root, { log })
459
+ // 他回来了、天数涨了也要重写:不然她一边跟他聊,身份里还写着「他有 2 天没出现」。
460
+ // 身份里的天数从 absence 写下那一刻起算,和 detectAbsence 的不在同一时刻跳(absence
461
+ // 写得晚的时候),两个都要看,不然会把旧数重写一遍,身份比叫醒的提示词晚一天。
462
+ // 初值是 null,因为启动时已经写过一次,第一拍不用再写。
463
+ const lastAbsence = lastAbsenceEvent(events)
464
+ const daysSinceWritten = lastAbsence ? Math.floor((now - Date.parse(String(lastAbsence.ts ?? 0))) / 86_400_000) : 0
465
+ const absenceKey = `${absence?.why ?? ''}:${absence?.days ?? 0}:${daysSinceWritten}`
466
+ if (absence?.written || (lastAbsenceKey !== null && absenceKey !== lastAbsenceKey)) await refreshSelfDoc(root, { log })
467
+ lastAbsenceKey = absenceKey
389
468
 
390
- const { events } = await readEvents(root)
391
469
  const { config } = await loadConfig(root)
392
470
  const dk = dateKey()
393
471
 
@@ -405,11 +483,44 @@ export function startScheduler(ctx, deps = {}) {
405
483
  return
406
484
  }
407
485
 
486
+ // ---- 有人回她了吗(2026-11-06,用户 m01641)----
487
+ // 原来这里只有 wake() 拼正文时顺带扫一次:没被叫醒的那天就完全看不到回复,
488
+ // 而设置页那个「多久看一次有没有人回复」一直是个空旋钮(scanEveryMin
489
+ // 全仓没有消费者)。现在按那个间隔真去问一次,问到了就把那几句排队投给她。
490
+ // 投递用 queueNote:她醒着就立刻补进当前这条会话,没开着就等她下次打开。
491
+ try {
492
+ if (repliesScanDue(config?.comment, now)) {
493
+ const waiting = await scanReplies({ root, events, dateKey: dk }).catch(() => [])
494
+ // 无论有没有人回都记一次:不记的话每 30 秒就会重新问一遍 B 站(-412 就是这么撞出来的)。
495
+ await updateConfig({ comment: { lastScanAt: new Date(now).toISOString() } }, root).catch(() => {})
496
+ if (waiting.length) {
497
+ // 只递、只标前几条:标 seen 的是「真摆到她眼前的」,不是「扫到的」。
498
+ // 标多了等于把没看见的悄悄吞掉;不标就会一直重复投(2026-11-06 用户报的 bug)。
499
+ const shown = waiting.slice(0, REPLY_SHOW_MAX)
500
+ const txt = renderReplies(shown)
501
+ if (txt) {
502
+ await queueNote(root, txt).catch(() => null)
503
+ const h = await homeStatus(root).catch(() => null)
504
+ const agent = h?.sessionId ? liveAgentOf(ctx, h.sessionId) : null
505
+ if (agent) await deliverQueued(agent, root, { home: true }).catch(() => null)
506
+ await markRepliesSeen(root, shown, { dateKey: dk }).catch(() => 0)
507
+ log('comment', `有人回她了(${waiting.length} 条),已经递过去。`)
508
+ }
509
+ }
510
+ }
511
+ } catch (err) {
512
+ log('comment', `扫回复失败:${err?.message ?? err}`)
513
+ }
514
+
408
515
  // ---- 主动说话(0.8.1 本机实验,2026-10-30 用户 m04610)----
409
516
  // 它不是唤醒:不占 wakesPerDay,也不推成长事件。只在 .dev 下生效。
410
517
  // 开了就 return——这一趟由她自己决定说还是不说,不再叠一次醒一醒。
518
+ //
519
+ // 2026-11-06(0.9.2 反馈):主动性也要受「每天几点醒」约束。
520
+ // 她要是能在窗口外自己醒过来找他,这个设置就等于没设——
521
+ // 用户关掉的是「这个时段别来打扰」,不是「别按日程醒来但可以主动开口」。
411
522
  try {
412
- if (await maybeProactive({ events, config, dk, now })) return
523
+ if (inWindow(config, new Date(now)) && await maybeProactive({ events, config, dk, now })) return
413
524
  } catch (err) {
414
525
  log('proactive failed', err?.message ?? String(err))
415
526
  }
@@ -423,6 +534,9 @@ export function startScheduler(ctx, deps = {}) {
423
534
  const counts = await todayCounts(events, dk)
424
535
  if (now < scheduled.at) return // 还没到今天的落点
425
536
 
537
+ // 叫醒一直失败时(会话没了、宿主起不来)别每分钟再试一次,每次都往事件流写一行 wakefail。
538
+ if (wakeRetryBlocked(events, dk, now)) return
539
+
426
540
  const model = deriveSelf(events, { growthDays: config.growthDays })
427
541
  // 空档补偿只影响这一次挑选的冷却判断(见 gapSqueeze)
428
542
  const decision = plan(model, config, counts, events, { squeeze: gapSqueeze(gapSinceLastWake(events)) })
@@ -470,8 +584,9 @@ export function startScheduler(ctx, deps = {}) {
470
584
  // 轮换之后这里查的是旧会话忙不忙,新会话正说着话也照样插进去。
471
585
  const sid = (await readJson(join(paths(root).root, 'home.json'), null).catch(() => null))?.sessionId
472
586
  ?? deps.sessionId
473
- if (sid && isBusy(sid)) {
474
- const why = '她正在说话,等这一轮结束再叫'
587
+ const elsewhere = sid ? busyElsewhere(sid) : false
588
+ if (sid && (elsewhere || isBusy(sid))) {
589
+ const why = elsewhere ? '别的插件说她在忙(dsh-fish/busy),等一等再叫' : '她正在说话,等这一轮结束再叫'
475
590
  const next = shouldLogSkip(skipLogged, dk, now, why)
476
591
  if (next) { skipLogged = next; log('skip', why) }
477
592
  return
@@ -675,7 +790,13 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
675
790
  {
676
791
  try {
677
792
  const { events: evs } = await readEvents(root)
678
- if (!dreamOn(evs, model.day).length) {
793
+ // 2026-11-06(issue #42):「兴趣」关掉时不再摆旧句子。
794
+ // 原来 freeWakeFor 只要 tableText 非空就判成 table,而 tableText 根本不看 interest 开关——
795
+ // 于是「关掉摆东西」之后,每 6 小时还是会因为旧句子空醒一次,而且那一轮既不想一想、
796
+ // 也不给规则申请、也不给「今天到这里」:14 天里 14 次全是旧句子。
797
+ // 旧句子属于「桌上那几样东西」里的一样,跟着兴趣开关走才自洽。
798
+ const staleOn = config?.interest?.enabled !== false
799
+ if (staleOn && !dreamOn(evs, model.day).length) {
679
800
  const shownToday = evs.some((e) => e?.type === 'meta' && String(e.text ?? '').startsWith(`table:${dk}`))
680
801
  const [{ outings }, { possessions }] = await Promise.all([readOutings(root), readPossessions(root)])
681
802
  const tripSessions = new Set(
@@ -796,8 +917,13 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
796
917
  const events = (await readEvents(root).catch(() => ({ events: [] }))).events
797
918
  const waiting = await scanReplies({ root, events, dateKey: dk }).catch(() => [])
798
919
  if (waiting.length) {
799
- const txt = renderReplies(waiting)
800
- if (txt) parts.push('', txt)
920
+ const shown = waiting.slice(0, REPLY_SHOW_MAX)
921
+ const txt = renderReplies(shown)
922
+ if (txt) {
923
+ parts.push('', txt)
924
+ // 摆进叫醒消息就算提醒过了:不标的话,她没回就会每次叫醒都再投一遍。
925
+ await markRepliesSeen(root, shown, { dateKey: dk }).catch(() => 0)
926
+ }
801
927
  }
802
928
  // 能说话这件事的说明在身份文档的「在网上说话」一节(开关打开时 selfdoc 才写),
803
929
  // 不在这儿重复。
@@ -937,10 +1063,9 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
937
1063
  // 先探活,探不通才退回建一条。
938
1064
  const live = await ensureSessionLive(sc, sessionId)
939
1065
  if (!live.live) {
940
- log('wake: 会话探活没通过,退回新建一条', live.why ?? '')
941
- try {
942
- await sc.create({ sessionId, cwd: herDir })
943
- } catch { /* 已存在,继续用 */ }
1066
+ log('wake: 会话探活没通过,看看能不能按原 id 唤回来', live.why ?? '')
1067
+ // 同上(2026-11-06 换房报告):只有宿主确实给不出这一条时才建。
1068
+ await reviveIfBlank(sc, sessionId, herDir, (msg) => log(`wake: ${msg}`)).catch(() => {})
944
1069
  }
945
1070
 
946
1071
  // 2026-11-04:`AbortController` 与那个 5 分钟 kill 定时器一起删掉了。
@@ -971,19 +1096,22 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
971
1096
  }
972
1097
 
973
1098
  // 刷站那几十秒里她可能刚好开始了别的一轮(有人找她)。等那一轮说完再挂处境、再叫:
974
- // watcher 认的是会话不是这一轮,挂早了会被那一轮的 turn/end 收走。
1099
+ // 挂早了会被那一轮的 turn/end 收走(issue #36 之前 watcher 只认会话不认回合,
1100
+ // 现在它至少要见到我们自己这一轮的 turn/start 才算数,双保险)。
975
1101
  if (typeof deps.waitIdle === 'function') await deps.waitIdle(sessionId)
1102
+ if (deps.isDisposed?.()) return
976
1103
 
977
1104
  // 这一轮她写的话不算他来过:唤醒时间窗开到 turn/end,不是开到入队(见 wakewin.js)。
978
1105
  // turn/end 不来时跟处境一起由下面的兜底关;没送进去就在 catch 里关。
979
1106
  // 处境代次(2026-11-04,用户 m01466):见 maybeProactive 里的同一处注释。
980
- // watcher 认的是会话不是这一轮,所以清的时候要带着自己挂上去时拿到的号。
1107
+ // 清的时候要带着自己挂上去时拿到的号,别把别人后来挂的处境一起抹了。
981
1108
  let gen = 0
1109
+ // 先挂监听再开窗:ctx 已停用时 ctx.on 会抛,开了窗就再也关不上
1110
+ const watcher = watchTurnEnd(ctx, sessionId, () => { releaseWake(); void writeReport(); clearWakeSection(gen, sessionId) })
982
1111
  const releaseWake = holdWake()
983
- const watcher = watchTurnEnd(ctx, sessionId, () => { releaseWake(); void writeReport(); clearWakeSection(gen) })
984
1112
 
985
1113
  // 处境走 system prompt 段;对话里只留一个空投递(她看到的是「有人来了」,不是剧本)。
986
- gen = setWakeSection(wakeText)
1114
+ gen = setWakeSection(wakeText, sessionId)
987
1115
  // 2026-11-04(用户 m01466):投递前的最后一道护栏。
988
1116
  //
989
1117
  // 真机事故里出现过「消息投出去了、system prompt 里一个字处境都没有」的一轮:
@@ -993,8 +1121,8 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
993
1121
  //
994
1122
  // 治本是上面的最小间隔(不该有两次挨这么近的唤醒);这里只是兜底:
995
1123
  // 万一处境真的空了,宁可不叫,也别投一条空壳让她自己找台阶下。
996
- if (!getWakeSection().trim()) {
997
- clearWakeSection(gen)
1124
+ if (!getWakeSection(sessionId).trim()) {
1125
+ clearWakeSection(gen, sessionId)
998
1126
  releaseWake()
999
1127
  try { watcher.off?.() } catch { /* ignore */ }
1000
1128
  log('wake skipped: 处境是空的')
@@ -1079,7 +1207,7 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
1079
1207
  // 上一个调度器留下的窗口没关,下一轮 turn/end 之后 inWake() 仍是 true。
1080
1208
  if (deps.isDisposed?.()) {
1081
1209
  try { watcher.off?.() } catch { /* ignore */ }
1082
- clearWakeSection(gen)
1210
+ clearWakeSection(gen, sessionId)
1083
1211
  releaseWake()
1084
1212
  return
1085
1213
  }
@@ -1088,16 +1216,16 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
1088
1216
  return
1089
1217
  }
1090
1218
  try { watcher.off?.() } catch { /* ignore */ }
1091
- if (!watcher.fired) clearWakeSection(gen)
1219
+ if (!watcher.fired) clearWakeSection(gen, sessionId)
1092
1220
  releaseWake()
1093
1221
  }
1094
1222
  setTimeout(fallback, REPORT_WAIT_MS).unref?.()
1095
1223
  } catch (err) {
1096
1224
  // 这一次没起来,处境要立刻清掉,唤醒时间窗也关上。
1097
- clearWakeSection(gen)
1225
+ clearWakeSection(gen, sessionId)
1098
1226
  releaseWake()
1099
1227
  await appendEvent(
1100
- { type: 'meta', text: `wakefail:${dk} ${String(err?.message ?? err).slice(0, 120)}` },
1228
+ { type: 'meta', text: `wakefail:${dk} ${deps.manual ? WAKE_FAIL_MANUAL : ''}${String(err?.message ?? err).slice(0, 120)}` },
1101
1229
  { day: model.day, stage: model.stage, source: 'timer' },
1102
1230
  root,
1103
1231
  ).catch(() => {})
@@ -1125,12 +1253,14 @@ async function askOfferInChat(ctx, root, { sessionId, offer, log = () => {} } =
1125
1253
  try {
1126
1254
  const r = await askInChat(ctx, {
1127
1255
  sessionId,
1256
+ root,
1257
+ cardId: `grant:${offer.id}`,
1128
1258
  questions: [
1129
1259
  {
1130
1260
  id: 'grant',
1131
1261
  header: '大肥鱼想申请一件事',
1132
1262
  question: `她想试试「${offer.title}」。不是现在就要,是想问问你。`,
1133
- options: [{ label: '可以' }, { label: '这次不行' }],
1263
+ options: [{ label: GRANT_YES }, { label: GRANT_NO }],
1134
1264
  },
1135
1265
  ],
1136
1266
  })
@@ -1139,7 +1269,7 @@ async function askOfferInChat(ctx, root, { sessionId, offer, log = () => {} } =
1139
1269
  return
1140
1270
  }
1141
1271
  const said = picked(r.answers, 'grant')
1142
- const verdict = said === '可以' ? 'approve' : said === '这次不行' ? 'reject' : ''
1272
+ const verdict = said === GRANT_YES ? 'approve' : said === GRANT_NO ? 'reject' : ''
1143
1273
  if (!verdict) return
1144
1274
  const w = await recordGrant(root, { offerId: offer.id, rule: offer.rule, verdict })
1145
1275
  if (!w.ok) log('offer grant failed', w.error)
package/lib/schema.js CHANGED
@@ -167,6 +167,24 @@ export function tsMs(ev) {
167
167
  return Number.isFinite(t) ? t : 0
168
168
  }
169
169
 
170
+ /**
171
+ * 这条事件算不算「他来过」的证据(2026-11-06,issue #49 的时钟盲点)。
172
+ *
173
+ * `meta seen:` 是主证据(turns.js 结账时写),`heard` 是兜底(侧栏聊天不写
174
+ * seen 的那段时间只有它)。放在 schema 里是为了让 absence.js 与 derive.js
175
+ * 共用同一份判定,别再各抄一遍——两处口径一旦分叉,就会出现「absence 认为
176
+ * 他回来了、persona 还在念 N 天」那种自相矛盾(issue #24 的老毛病)。
177
+ *
178
+ * 不认 `utterance`:定时唤醒也会让她写 utterance,那不算他来过。
179
+ */
180
+ export function isContactEvent(e) {
181
+ if (e?.type === 'heard') return Number.isFinite(Date.parse(String(e.ts ?? '')))
182
+ if (e?.type !== 'meta') return false
183
+ const t = String(e.text ?? '')
184
+ if (!t.startsWith('seen:')) return false
185
+ return Number.isFinite(Date.parse(t.slice(5).trim()))
186
+ }
187
+
170
188
  /** 从 id 反解时间(观测台排序用,不依赖 ts 字段) */
171
189
  export function idTime(id) {
172
190
  const body = String(id).replace(/^ev_/, '').slice(0, 10)
package/lib/selfdoc.js CHANGED
@@ -27,7 +27,8 @@ 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
29
  import { deriveSelf } from './derive.js'
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'
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'
31
32
  import { LOOK_BRIEF, WATCH_RULE } from './browse.js'
32
33
  import { activeCarry } from './home.js'
33
34
  import { activeGrants } from './unlock.js'
@@ -120,7 +121,7 @@ export async function continuityOf(root = rootDir(), now = Date.now()) {
120
121
  * @param {object} model deriveSelf 的结果
121
122
  * @returns {string}
122
123
  */
123
- 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 } = {}) {
124
125
  const head = [
125
126
  // 这里**故意不写**「由 dsh-fish 自动生成」这类话。
126
127
  // 这个文件会被宿主原样渲染进她的会话——她能看见每一行。
@@ -166,7 +167,7 @@ export function renderSelfDoc(model, { budget, carry, continuity, browse = true,
166
167
  ...(dream ? [DREAM_BRIEF, ''] : []),
167
168
  // 主动说话(2026-10-30,用户 m04610):her_say 只在 .dev 下注册,
168
169
  // 说明书也只在同一个判据下挂——公开版不能让「她」以为有这个工具。
169
- ...(proactive ? [PROACTIVE_BRIEF, ''] : []),
170
+ ...(proactive ? [proactiveBrief(sendTools), ''] : []),
170
171
  // 定时提醒(2026-10-30,用户 m04335 / m04504):默认不放,只有用户把
171
172
  // schedule_create 加进白名单才挂——不然就是告诉她「你有这个工具」,伸手却没有。
172
173
  ...(schedule ? [SCHEDULE_BRIEF, ''] : []),
@@ -265,6 +266,7 @@ export async function writeSelfDoc(root = rootDir(), { log, exec, tenant } = {})
265
266
  // 梦(2026-10-30 m02837)与主动说话(m04610):0.9.0 起公开,两处都恒为真。
266
267
  dream: true,
267
268
  proactive: true,
269
+ sendTools: proactiveConfig(config).sendTools,
268
270
  // 定时工具默认不放;用户在白名单里加了 schedule_create 才告诉他这条路通。
269
271
  schedule: (config?.tools?.allow ?? []).some((n) => String(n).trim() === 'schedule_create'),
270
272
  })
package/lib/selfupdate.js CHANGED
@@ -137,7 +137,14 @@ export function profileDirOf(moduleUrl) {
137
137
  // 两种都要认得,否则从 0.8.9 之前装上来的用户会被当成"没有 profile → 开发版"而永远升不了级。
138
138
  for (const marker of [`/node_modules/${PKG}/`, `/node_modules/${LEGACY_PKG}/`]) {
139
139
  const i = file.lastIndexOf(marker)
140
- if (i >= 0) return file.slice(0, i)
140
+ if (i < 0) continue
141
+ const base = file.slice(0, i)
142
+ // pnpm 的 isolated 布局(issue #43):真身常在
143
+ // `<profile>/node_modules/.pnpm/@ssamurias+dsh-fish@0.9.1/node_modules/@ssamurias/dsh-fish/lib/...`,
144
+ // 直接截到 marker 会得到 `.pnpm/.../node_modules`,那里没有 package.json,
145
+ // 于是 kind 判成 missing、managed 变 false,下载按钮永远不出现。往上退到 profile 根。
146
+ const pnpm = base.indexOf('/node_modules/.pnpm/')
147
+ return pnpm >= 0 ? base.slice(0, pnpm) : base
141
148
  }
142
149
  return ''
143
150
  }
package/lib/settings.js CHANGED
@@ -70,6 +70,11 @@ export const DEFAULTS = {
70
70
  gainPerHour: 0.05, // 超过 minIdleHours 之后,每多一小时加多少
71
71
  maxChance: 0.6, // 概率上限
72
72
  cooldownHours: 4, // 两次之间至少隔多久
73
+ // 下面三项给「对话要靠工具才发得出去」的部署(比如宿主里另有插件把多个聊天接进这条会话,
74
+ // 再给她一个发送工具)。默认值和原来的行为一样。
75
+ sendTools: [], // 这些工具调成功才算她开了口;空 = 只认正文
76
+ foldSilent: true, // 没开口的一轮折成一行;一条会话用很久、上下文很大时可以关
77
+ idleCountsSeen: false, // 「他来过」(seen:)也算打断安静;她的对话不进事件流时要开
73
78
  },
74
79
 
75
80
  // 她"不想看"的东西(可追加,用户说以后还会加)