@ssamurias/dsh-fish 0.9.1 → 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.
- package/AGENTS.md +27 -2
- package/CHANGELOG.md +39 -0
- package/README.md +4 -2
- package/RELEASE-NOTES-0.9.2.md +46 -0
- package/RELEASE.md +6 -5
- package/lib/absence.js +41 -27
- package/lib/ask.js +73 -25
- package/lib/askinchat.js +83 -1
- package/lib/capture.js +19 -1
- package/lib/cardanswer.js +84 -0
- package/lib/client.js +66 -8
- package/lib/comment.js +37 -2
- package/lib/deliver.js +46 -0
- package/lib/derive.js +14 -11
- package/lib/events.js +25 -1
- package/lib/generator.js +2 -1
- package/lib/grant.js +18 -2
- package/lib/home.js +55 -7
- package/lib/identity.js +111 -14
- package/lib/index.js +49 -9
- package/lib/keep.js +21 -9
- package/lib/keepsake.js +20 -13
- package/lib/note.js +255 -20
- package/lib/observe.js +5 -4
- package/lib/persona.js +10 -7
- package/lib/say.js +73 -13
- package/lib/scheduler.js +90 -32
- package/lib/schema.js +18 -0
- package/lib/status.js +25 -3
- package/lib/turns.js +42 -1
- package/package.json +1 -1
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
|
*
|
|
@@ -22,14 +23,14 @@ 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, repliesScanDue } from './comment.js'
|
|
33
|
+
import { scanReplies, renderReplies, repliesScanDue, markRepliesSeen, REPLY_SHOW_MAX } from './comment.js'
|
|
33
34
|
import { flushReport } from './report.js'
|
|
34
35
|
import { talkSessionId, homeStatus } from './home.js'
|
|
35
36
|
import { refreshSelfDoc } from './selfdoc.js'
|
|
@@ -39,10 +40,10 @@ import { awayNow, awayLine } from './outing.js'
|
|
|
39
40
|
import { askInChat, picked } from './askinchat.js'
|
|
40
41
|
import { restedToday, settleStaleAsks } from './ask.js'
|
|
41
42
|
import { proactiveConfig, proactiveState, proactiveSection, recentMaterial, looseEnds, TOOL_ASK_NOTE } from './proactive.js'
|
|
42
|
-
import { deliverWake, ensureSessionLive, WAKE_PING } from './deliver.js'
|
|
43
|
+
import { deliverWake, ensureSessionLive, reviveIfBlank, WAKE_PING } from './deliver.js'
|
|
43
44
|
import { followWake, planWakeCompaction, compactWake, looksLikeNothingSaid, looksLikeStreamFailure } from './compact.js'
|
|
44
45
|
import { logLine } from './log.js'
|
|
45
|
-
import { recordGrant } from './grant.js'
|
|
46
|
+
import { recordGrant, GRANT_YES, GRANT_NO } from './grant.js'
|
|
46
47
|
import { readNotes, renderNotes } from './note.js'
|
|
47
48
|
import { curiosityOf, renderEcho } from './curiosity.js'
|
|
48
49
|
import { deskFor } from './desk.js'
|
|
@@ -62,6 +63,12 @@ const WAKE_TIMEOUT_MS = 5 * 60_000
|
|
|
62
63
|
const REPORT_WAIT_MS = 90_000
|
|
63
64
|
// 一天的第二次及以后的叫醒,离上一次至少隔这么久(见 nextWakeAt)
|
|
64
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 = '手动 '
|
|
65
72
|
// 她正在某一轮里时先等她说完,最多等这么久
|
|
66
73
|
const IDLE_WAIT_MS = 10 * 60_000
|
|
67
74
|
// turn/start 之后迟迟等不到 turn/end(宿主重启、事件丢了),超过这么久就不再当她在忙
|
|
@@ -95,6 +102,24 @@ const DAY_END_CUE = '(今天到这里了。这一刻是我自己的。)'
|
|
|
95
102
|
|
|
96
103
|
|
|
97
104
|
|
|
105
|
+
/**
|
|
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
|
+
|
|
98
123
|
/**
|
|
99
124
|
* 等这一轮真说完:认准「我们自己排进去的那一轮」的 turn/end。
|
|
100
125
|
*
|
|
@@ -139,6 +164,7 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
139
164
|
let lastTick = 0
|
|
140
165
|
let running = false
|
|
141
166
|
let disposed = false
|
|
167
|
+
let lastAbsenceKey = null
|
|
142
168
|
let timer = null
|
|
143
169
|
let wakeTimer = null
|
|
144
170
|
// skip 日志去重(2026-10-28):同一小时、同一原因只记一次。
|
|
@@ -286,7 +312,7 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
286
312
|
let gen = 0
|
|
287
313
|
const watcher = watchTurnEnd(ctx, sessionId, () => {
|
|
288
314
|
releaseWake()
|
|
289
|
-
clearWakeSection(gen)
|
|
315
|
+
clearWakeSection(gen, sessionId)
|
|
290
316
|
})
|
|
291
317
|
try {
|
|
292
318
|
// 2026-11-05(用户 m03265):原来这里是 `sc.create({ sessionId, cwd })`,
|
|
@@ -295,10 +321,12 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
295
321
|
// 现在先探活;探不通才退回建(老宿主没有 resolveAgent 时也走这条)。
|
|
296
322
|
const live = await ensureSessionLive(sc, sessionId)
|
|
297
323
|
if (!live.live) {
|
|
298
|
-
log('proactive:
|
|
299
|
-
|
|
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(() => {})
|
|
300
328
|
}
|
|
301
|
-
gen = setWakeSection(text)
|
|
329
|
+
gen = setWakeSection(text, sessionId)
|
|
302
330
|
|
|
303
331
|
// 投递之前先把这一轮盯起来(2026-11-05,用户 m00803)。认领信号是 rpcId:
|
|
304
332
|
// 每次投递一个唯一值,命中之前一律当「还没开始」,所以早挂不会认错轮。
|
|
@@ -322,7 +350,7 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
322
350
|
// 没送进去就没有这一轮:处境和窗口当场收掉,监听也摘掉,
|
|
323
351
|
// 不然要等到别的哪一轮结束才清,那一轮开头还挂着这次的处境(跟 wake() 失败时一样)
|
|
324
352
|
try { follow.off() } catch { /* ignore */ }
|
|
325
|
-
clearWakeSection(gen)
|
|
353
|
+
clearWakeSection(gen, sessionId)
|
|
326
354
|
|
|
327
355
|
releaseWake()
|
|
328
356
|
try { watcher.off?.() } catch { /* ignore */ }
|
|
@@ -367,7 +395,10 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
367
395
|
let folded = 0
|
|
368
396
|
if (plan) for (const run of plan.runs) if (await compactWake(out.session, run, { log })) folded += 1
|
|
369
397
|
const tail = broken ? ',这一轮上游断流了(那行报错不算她开口)'
|
|
370
|
-
: out.spoke
|
|
398
|
+
: out.spoke !== true ? ''
|
|
399
|
+
// 配了发送工具时,只写正文是「没发出去」,不见得是没话说,这句可以写得很长。
|
|
400
|
+
// 也可能调了工具但失败了,所以不说「没调」
|
|
401
|
+
: pc.sendTools.length > 0 ? ',只写了正文,没发出去' : ',只说了句没什么要说的'
|
|
371
402
|
log('proactive', folded > 0
|
|
372
403
|
? `醒了一次,没有开口${tail}(这一轮折成 ${folded} 行,盖住 ${plan.shadowed} 个节点)`
|
|
373
404
|
: `醒了一次,没有开口${tail}(${out.ok ? '这轮没什么可折' : out.why})`)
|
|
@@ -379,7 +410,7 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
379
410
|
} catch (err) {
|
|
380
411
|
// 没送进去就没有这一轮:处境和窗口当场收掉,监听也摘掉,
|
|
381
412
|
// 不然要等到别的哪一轮结束才清,那一轮开头还挂着这次的处境(跟 wake() 失败时一样)
|
|
382
|
-
clearWakeSection(gen)
|
|
413
|
+
clearWakeSection(gen, sessionId)
|
|
383
414
|
releaseWake()
|
|
384
415
|
try { watcher.off?.() } catch { /* ignore */ }
|
|
385
416
|
throw err
|
|
@@ -422,11 +453,19 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
422
453
|
// 放在 tick 最前面,因为 wake 的提示词要读 absenceDays;
|
|
423
454
|
// 晚一步的话今天这次唤醒就用的是昨天的冷落结论。
|
|
424
455
|
const absence = await detectAbsence(root)
|
|
456
|
+
const { events } = await readEvents(root)
|
|
425
457
|
// 冷落条数变了 = 她对自己的看法变了,身份文件要跟着重写。
|
|
426
458
|
// 不写的话那份 system prompt 段会一直停在上一次的值上(她"不知道自己被晾过")。
|
|
427
|
-
|
|
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
|
|
428
468
|
|
|
429
|
-
const { events } = await readEvents(root)
|
|
430
469
|
const { config } = await loadConfig(root)
|
|
431
470
|
const dk = dateKey()
|
|
432
471
|
|
|
@@ -455,12 +494,16 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
455
494
|
// 无论有没有人回都记一次:不记的话每 30 秒就会重新问一遍 B 站(-412 就是这么撞出来的)。
|
|
456
495
|
await updateConfig({ comment: { lastScanAt: new Date(now).toISOString() } }, root).catch(() => {})
|
|
457
496
|
if (waiting.length) {
|
|
458
|
-
|
|
497
|
+
// 只递、只标前几条:标 seen 的是「真摆到她眼前的」,不是「扫到的」。
|
|
498
|
+
// 标多了等于把没看见的悄悄吞掉;不标就会一直重复投(2026-11-06 用户报的 bug)。
|
|
499
|
+
const shown = waiting.slice(0, REPLY_SHOW_MAX)
|
|
500
|
+
const txt = renderReplies(shown)
|
|
459
501
|
if (txt) {
|
|
460
502
|
await queueNote(root, txt).catch(() => null)
|
|
461
503
|
const h = await homeStatus(root).catch(() => null)
|
|
462
504
|
const agent = h?.sessionId ? liveAgentOf(ctx, h.sessionId) : null
|
|
463
505
|
if (agent) await deliverQueued(agent, root, { home: true }).catch(() => null)
|
|
506
|
+
await markRepliesSeen(root, shown, { dateKey: dk }).catch(() => 0)
|
|
464
507
|
log('comment', `有人回她了(${waiting.length} 条),已经递过去。`)
|
|
465
508
|
}
|
|
466
509
|
}
|
|
@@ -472,8 +515,12 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
472
515
|
// ---- 主动说话(0.8.1 本机实验,2026-10-30 用户 m04610)----
|
|
473
516
|
// 它不是唤醒:不占 wakesPerDay,也不推成长事件。只在 .dev 下生效。
|
|
474
517
|
// 开了就 return——这一趟由她自己决定说还是不说,不再叠一次醒一醒。
|
|
518
|
+
//
|
|
519
|
+
// 2026-11-06(0.9.2 反馈):主动性也要受「每天几点醒」约束。
|
|
520
|
+
// 她要是能在窗口外自己醒过来找他,这个设置就等于没设——
|
|
521
|
+
// 用户关掉的是「这个时段别来打扰」,不是「别按日程醒来但可以主动开口」。
|
|
475
522
|
try {
|
|
476
|
-
if (await maybeProactive({ events, config, dk, now })) return
|
|
523
|
+
if (inWindow(config, new Date(now)) && await maybeProactive({ events, config, dk, now })) return
|
|
477
524
|
} catch (err) {
|
|
478
525
|
log('proactive failed', err?.message ?? String(err))
|
|
479
526
|
}
|
|
@@ -487,6 +534,9 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
487
534
|
const counts = await todayCounts(events, dk)
|
|
488
535
|
if (now < scheduled.at) return // 还没到今天的落点
|
|
489
536
|
|
|
537
|
+
// 叫醒一直失败时(会话没了、宿主起不来)别每分钟再试一次,每次都往事件流写一行 wakefail。
|
|
538
|
+
if (wakeRetryBlocked(events, dk, now)) return
|
|
539
|
+
|
|
490
540
|
const model = deriveSelf(events, { growthDays: config.growthDays })
|
|
491
541
|
// 空档补偿只影响这一次挑选的冷却判断(见 gapSqueeze)
|
|
492
542
|
const decision = plan(model, config, counts, events, { squeeze: gapSqueeze(gapSinceLastWake(events)) })
|
|
@@ -867,8 +917,13 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
|
|
|
867
917
|
const events = (await readEvents(root).catch(() => ({ events: [] }))).events
|
|
868
918
|
const waiting = await scanReplies({ root, events, dateKey: dk }).catch(() => [])
|
|
869
919
|
if (waiting.length) {
|
|
870
|
-
const
|
|
871
|
-
|
|
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
|
+
}
|
|
872
927
|
}
|
|
873
928
|
// 能说话这件事的说明在身份文档的「在网上说话」一节(开关打开时 selfdoc 才写),
|
|
874
929
|
// 不在这儿重复。
|
|
@@ -1008,10 +1063,9 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
|
|
|
1008
1063
|
// 先探活,探不通才退回建一条。
|
|
1009
1064
|
const live = await ensureSessionLive(sc, sessionId)
|
|
1010
1065
|
if (!live.live) {
|
|
1011
|
-
log('wake:
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
} catch { /* 已存在,继续用 */ }
|
|
1066
|
+
log('wake: 会话探活没通过,看看能不能按原 id 唤回来', live.why ?? '')
|
|
1067
|
+
// 同上(2026-11-06 换房报告):只有宿主确实给不出这一条时才建。
|
|
1068
|
+
await reviveIfBlank(sc, sessionId, herDir, (msg) => log(`wake: ${msg}`)).catch(() => {})
|
|
1015
1069
|
}
|
|
1016
1070
|
|
|
1017
1071
|
// 2026-11-04:`AbortController` 与那个 5 分钟 kill 定时器一起删掉了。
|
|
@@ -1045,17 +1099,19 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
|
|
|
1045
1099
|
// 挂早了会被那一轮的 turn/end 收走(issue #36 之前 watcher 只认会话不认回合,
|
|
1046
1100
|
// 现在它至少要见到我们自己这一轮的 turn/start 才算数,双保险)。
|
|
1047
1101
|
if (typeof deps.waitIdle === 'function') await deps.waitIdle(sessionId)
|
|
1102
|
+
if (deps.isDisposed?.()) return
|
|
1048
1103
|
|
|
1049
1104
|
// 这一轮她写的话不算他来过:唤醒时间窗开到 turn/end,不是开到入队(见 wakewin.js)。
|
|
1050
1105
|
// turn/end 不来时跟处境一起由下面的兜底关;没送进去就在 catch 里关。
|
|
1051
1106
|
// 处境代次(2026-11-04,用户 m01466):见 maybeProactive 里的同一处注释。
|
|
1052
1107
|
// 清的时候要带着自己挂上去时拿到的号,别把别人后来挂的处境一起抹了。
|
|
1053
1108
|
let gen = 0
|
|
1109
|
+
// 先挂监听再开窗:ctx 已停用时 ctx.on 会抛,开了窗就再也关不上
|
|
1110
|
+
const watcher = watchTurnEnd(ctx, sessionId, () => { releaseWake(); void writeReport(); clearWakeSection(gen, sessionId) })
|
|
1054
1111
|
const releaseWake = holdWake()
|
|
1055
|
-
const watcher = watchTurnEnd(ctx, sessionId, () => { releaseWake(); void writeReport(); clearWakeSection(gen) })
|
|
1056
1112
|
|
|
1057
1113
|
// 处境走 system prompt 段;对话里只留一个空投递(她看到的是「有人来了」,不是剧本)。
|
|
1058
|
-
gen = setWakeSection(wakeText)
|
|
1114
|
+
gen = setWakeSection(wakeText, sessionId)
|
|
1059
1115
|
// 2026-11-04(用户 m01466):投递前的最后一道护栏。
|
|
1060
1116
|
//
|
|
1061
1117
|
// 真机事故里出现过「消息投出去了、system prompt 里一个字处境都没有」的一轮:
|
|
@@ -1065,8 +1121,8 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
|
|
|
1065
1121
|
//
|
|
1066
1122
|
// 治本是上面的最小间隔(不该有两次挨这么近的唤醒);这里只是兜底:
|
|
1067
1123
|
// 万一处境真的空了,宁可不叫,也别投一条空壳让她自己找台阶下。
|
|
1068
|
-
if (!getWakeSection().trim()) {
|
|
1069
|
-
clearWakeSection(gen)
|
|
1124
|
+
if (!getWakeSection(sessionId).trim()) {
|
|
1125
|
+
clearWakeSection(gen, sessionId)
|
|
1070
1126
|
releaseWake()
|
|
1071
1127
|
try { watcher.off?.() } catch { /* ignore */ }
|
|
1072
1128
|
log('wake skipped: 处境是空的')
|
|
@@ -1151,7 +1207,7 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
|
|
|
1151
1207
|
// 上一个调度器留下的窗口没关,下一轮 turn/end 之后 inWake() 仍是 true。
|
|
1152
1208
|
if (deps.isDisposed?.()) {
|
|
1153
1209
|
try { watcher.off?.() } catch { /* ignore */ }
|
|
1154
|
-
clearWakeSection(gen)
|
|
1210
|
+
clearWakeSection(gen, sessionId)
|
|
1155
1211
|
releaseWake()
|
|
1156
1212
|
return
|
|
1157
1213
|
}
|
|
@@ -1160,16 +1216,16 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
|
|
|
1160
1216
|
return
|
|
1161
1217
|
}
|
|
1162
1218
|
try { watcher.off?.() } catch { /* ignore */ }
|
|
1163
|
-
if (!watcher.fired) clearWakeSection(gen)
|
|
1219
|
+
if (!watcher.fired) clearWakeSection(gen, sessionId)
|
|
1164
1220
|
releaseWake()
|
|
1165
1221
|
}
|
|
1166
1222
|
setTimeout(fallback, REPORT_WAIT_MS).unref?.()
|
|
1167
1223
|
} catch (err) {
|
|
1168
1224
|
// 这一次没起来,处境要立刻清掉,唤醒时间窗也关上。
|
|
1169
|
-
clearWakeSection(gen)
|
|
1225
|
+
clearWakeSection(gen, sessionId)
|
|
1170
1226
|
releaseWake()
|
|
1171
1227
|
await appendEvent(
|
|
1172
|
-
{ 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)}` },
|
|
1173
1229
|
{ day: model.day, stage: model.stage, source: 'timer' },
|
|
1174
1230
|
root,
|
|
1175
1231
|
).catch(() => {})
|
|
@@ -1197,12 +1253,14 @@ async function askOfferInChat(ctx, root, { sessionId, offer, log = () => {} } =
|
|
|
1197
1253
|
try {
|
|
1198
1254
|
const r = await askInChat(ctx, {
|
|
1199
1255
|
sessionId,
|
|
1256
|
+
root,
|
|
1257
|
+
cardId: `grant:${offer.id}`,
|
|
1200
1258
|
questions: [
|
|
1201
1259
|
{
|
|
1202
1260
|
id: 'grant',
|
|
1203
1261
|
header: '大肥鱼想申请一件事',
|
|
1204
1262
|
question: `她想试试「${offer.title}」。不是现在就要,是想问问你。`,
|
|
1205
|
-
options: [{ label:
|
|
1263
|
+
options: [{ label: GRANT_YES }, { label: GRANT_NO }],
|
|
1206
1264
|
},
|
|
1207
1265
|
],
|
|
1208
1266
|
})
|
|
@@ -1211,7 +1269,7 @@ async function askOfferInChat(ctx, root, { sessionId, offer, log = () => {} } =
|
|
|
1211
1269
|
return
|
|
1212
1270
|
}
|
|
1213
1271
|
const said = picked(r.answers, 'grant')
|
|
1214
|
-
const verdict = said ===
|
|
1272
|
+
const verdict = said === GRANT_YES ? 'approve' : said === GRANT_NO ? 'reject' : ''
|
|
1215
1273
|
if (!verdict) return
|
|
1216
1274
|
const w = await recordGrant(root, { offerId: offer.id, rule: offer.rule, verdict })
|
|
1217
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/status.js
CHANGED
|
@@ -71,6 +71,11 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
71
71
|
if (Number.isFinite(at)) plannedWakeAt = at
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// 2026-11-06(0.9.1 反馈):`quiet:` 是「落点到了,但没有可出的事件」,
|
|
75
|
+
// 不是「今天不会醒了」——quiet 之后冷却到期还会真醒(11-05 就是这样)。
|
|
76
|
+
// 所以它能解释「为什么没醒」,但不能用「今天」这种封死的说法。
|
|
77
|
+
const quietToday = today.some(({ e }) => e?.type === 'meta' && String(textOf(e) ?? '').startsWith(`quiet:${dk}`))
|
|
78
|
+
|
|
74
79
|
// ---- 把今天切成"醒来块":每个 wake: 是起点,动作挂到它下面 ----
|
|
75
80
|
const blocks = []
|
|
76
81
|
// chat(issue #32):对话里她说的那些行。它们**不是**她在这一轮里主动做的事——
|
|
@@ -123,7 +128,10 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
123
128
|
continue
|
|
124
129
|
}
|
|
125
130
|
if (dayMarker(text, 'quiet', dk) !== null) {
|
|
126
|
-
|
|
131
|
+
// 没有 wake: 就没有「醒」这回事。旧文案「醒了,但今天没有该问的事」
|
|
132
|
+
// 会在时间线上凭空多出一次醒来,还和状态卡「还没醒」互相打脸。
|
|
133
|
+
// 「暂时」也不用「今天」:quiet 不封当天,冷却到期后她照样会醒。
|
|
134
|
+
push(t, 'quiet', '到点了,暂时没有可出的事件')
|
|
127
135
|
continue
|
|
128
136
|
}
|
|
129
137
|
if (dayMarker(text, 'unrecorded', dk) !== null) continue
|
|
@@ -170,7 +178,9 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
170
178
|
const done = b.actions.filter((a) => a.kind !== 'quiet' && a.kind !== 'fail' && a.kind !== 'chat')
|
|
171
179
|
b.idle = done.length === 0
|
|
172
180
|
}
|
|
173
|
-
|
|
181
|
+
// 只有真醒过的块才算「醒着但没做」:没有 wake: 的那块(quiet / 他说话时插的几句)
|
|
182
|
+
// 根本不是一次醒来。以前它被算进 idleWakes,于是 wakes=0 而 idleWakes=1。
|
|
183
|
+
counts.idleWakes = blocks.filter((b) => b.wake !== null && b.idle).length
|
|
174
184
|
|
|
175
185
|
// ---- 当前状态 ----
|
|
176
186
|
const lastAct = today.length ? today[today.length - 1] : null
|
|
@@ -219,7 +229,9 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
219
229
|
const mins = plannedWakeAt ? Math.round((plannedWakeAt - nowMs) / 60_000) : null
|
|
220
230
|
detail = plannedWakeAt && plannedWakeAt > nowMs
|
|
221
231
|
? `约 ${new Date(plannedWakeAt).toTimeString().slice(0, 5)} 会睡醒${mins !== null && mins >= 0 ? `(还有 ${mins} 分钟)` : ''}`
|
|
222
|
-
:
|
|
232
|
+
: (quietToday
|
|
233
|
+
? '到点了,暂时没有可出的事件,还没醒'
|
|
234
|
+
: '今天已经到点,但还没醒')
|
|
223
235
|
} else {
|
|
224
236
|
phase = 'asleep'; label = '沉在水底'
|
|
225
237
|
detail = plannedWakeAt && plannedWakeAt > nowMs
|
|
@@ -241,6 +253,16 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
241
253
|
const timeline = []
|
|
242
254
|
for (const b of blocks) {
|
|
243
255
|
const hhmm = new Date(b.at).toTimeString().slice(0, 5)
|
|
256
|
+
// 没有 wake: 的块不属于任何一次醒来(orphan)。以前拿当天第一条事件的时间
|
|
257
|
+
// 当块头,于是一天没醒过也会冒出「06:15 醒了,在发呆」(0.9.1 反馈)。
|
|
258
|
+
// 这些动作照原样列出来就好,别替她补一句「醒了」。
|
|
259
|
+
if (b.wake === null) {
|
|
260
|
+
for (const a of b.actions) {
|
|
261
|
+
const h2 = new Date(a.at).toTimeString().slice(0, 5)
|
|
262
|
+
timeline.push({ at: new Date(a.at).toISOString(), kind: a.kind, text: `${h2} ${a.text}` })
|
|
263
|
+
}
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
244
266
|
if (b.idle) {
|
|
245
267
|
timeline.push({ at: new Date(b.at).toISOString(), kind: 'idle', text: `${hhmm} 醒了,在发呆`, idle: true })
|
|
246
268
|
// 「醒来失败」「没有该问的事」正是这一块为什么什么都没做的原因,必须留下来,
|
package/lib/turns.js
CHANGED
|
@@ -54,6 +54,7 @@ import { refreshSelfDoc } from './selfdoc.js'
|
|
|
54
54
|
import { growthDay, stageOf } from './derive.js'
|
|
55
55
|
import { hearTurn } from './hear.js'
|
|
56
56
|
import { stripToolMarkup, hasToolMarkup } from './markup.js'
|
|
57
|
+
import { clearWakeSectionFor } from './identity.js'
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
60
|
* 我们自己 prompt 进去的消息,rpcId 会带这些前缀。
|
|
@@ -240,6 +241,9 @@ function cut(s) {
|
|
|
240
241
|
return t.length > TURN_TEXT_MAX ? t.slice(0, TURN_TEXT_MAX) + '…' : t
|
|
241
242
|
}
|
|
242
243
|
|
|
244
|
+
/** 已经挂过观察者的宿主上下文(同一进程只挂一次,见 registerTurns) */
|
|
245
|
+
const TURNS_BOUND = new WeakSet()
|
|
246
|
+
|
|
243
247
|
/**
|
|
244
248
|
* 注册"侧栏轮数"观察者。
|
|
245
249
|
*
|
|
@@ -252,6 +256,30 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
252
256
|
const log = deps.log ?? (() => {})
|
|
253
257
|
if (typeof ctx?.on !== 'function') return () => {}
|
|
254
258
|
|
|
259
|
+
/**
|
|
260
|
+
* 2026-11-06(外部日志:被占用的实例照样往共享事件流里写 `seen:`)。
|
|
261
|
+
*
|
|
262
|
+
* 观测者以前是**无条件**写盘的:它看到「他来过」就 appendEvent,从不过问这个
|
|
263
|
+
* 家是不是自己的。于是同一个数据目录被两个实例开着时,只读那一侧也在写——
|
|
264
|
+
* 日志里那三条同一毫秒的 `seen:` 就是两个写者各写一遍。
|
|
265
|
+
*
|
|
266
|
+
* 这里和工具侧用同一道闸(tenant.js 的 guardWrite):房主身份 ok===false 时
|
|
267
|
+
* 一句都不写。tenant 是**活引用**(占用权丢了会原地改写成 ok:false),
|
|
268
|
+
* 所以必须在每次写之前现查,不能在注册时查一次就完。
|
|
269
|
+
*/
|
|
270
|
+
const tenant = deps.tenant ?? null
|
|
271
|
+
const writable = () => !(tenant && tenant.ok === false)
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 同一份宿主上下文只挂一次。
|
|
275
|
+
*
|
|
276
|
+
* 2026-11-06:插件被同一个进程加载两遍时(两个入口 / bundles 里重复),
|
|
277
|
+
* 两个观察者各有各的节流记忆,同一条人话会写出两条同一毫秒的 `seen:`——
|
|
278
|
+
* 记账行一重复,事件流就再也不能当证据用。第二次注册直接交白卷。
|
|
279
|
+
*/
|
|
280
|
+
if (TURNS_BOUND.has(ctx)) return () => {}
|
|
281
|
+
TURNS_BOUND.add(ctx)
|
|
282
|
+
|
|
255
283
|
/** 正在等 turn/end 的那一轮:{ sessionId, msgs:[], sinceId, at } */
|
|
256
284
|
let pending = null
|
|
257
285
|
/** 正在异步认来源的那条人话(见 takeTurnEnd:事件跟得太紧时要等它) */
|
|
@@ -289,6 +317,8 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
289
317
|
* 非 HER_TYPES 的类型,写成 tool 会被 schema 悄悄拒掉。
|
|
290
318
|
*/
|
|
291
319
|
async function markSeen() {
|
|
320
|
+
// 2026-11-06:写盘之前先过所有权闸门(被占用的实例一条都不许写)。
|
|
321
|
+
if (!writable()) return
|
|
292
322
|
lastSeenAt = Date.now()
|
|
293
323
|
await appendEvent(
|
|
294
324
|
{ type: 'meta', text: `seen:${new Date(lastSeenAt).toISOString()}` },
|
|
@@ -301,6 +331,16 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
301
331
|
|
|
302
332
|
/** 收到真人消息:只认"她住的那个会话",并把这一轮开始前的事件 id 记下来 */
|
|
303
333
|
async function onHuman(sessionId, data) {
|
|
334
|
+
if (!writable()) return
|
|
335
|
+
// 2026-11-06(用户报「叫醒之后她就不说话了」):他一开口,上一次叫醒留下的
|
|
336
|
+
// 「处境」当场作废。
|
|
337
|
+
//
|
|
338
|
+
// 处境原本只该出现在**她自己醒的那一轮**里,靠 turn/end 撤。但 turn/end 不是
|
|
339
|
+
// 一定能认到(监听按会话挂、重启会丢、两个渠道挨得近时也会错号),那一段
|
|
340
|
+
// 「这一轮是你自己的,不用回应任何人;不想说就什么都别写」就留在 system prompt
|
|
341
|
+
// 里,接下来他说的每一句都带着它——她就只思考、不开口,而且连续多轮都这样。
|
|
342
|
+
// 人真的说话了,这段就不该还在;清得比 turn/end 早一步也不会出错。
|
|
343
|
+
try { clearWakeSectionFor(sessionId) } catch { /* ignore */ }
|
|
304
344
|
const home = await homeStatus(root)
|
|
305
345
|
if (!home.sessionId) return
|
|
306
346
|
if (home.sessionId !== sessionId) {
|
|
@@ -385,7 +425,7 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
385
425
|
|
|
386
426
|
/** turn/end:结账 */
|
|
387
427
|
async function settle(p) {
|
|
388
|
-
if (stopped) return
|
|
428
|
+
if (stopped || !writable()) return
|
|
389
429
|
if (busy) {
|
|
390
430
|
log('turns · 上一轮还没结完,这次不记')
|
|
391
431
|
return
|
|
@@ -580,6 +620,7 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
580
620
|
return () => {
|
|
581
621
|
stopped = true
|
|
582
622
|
pending = null
|
|
623
|
+
TURNS_BOUND.delete(ctx)
|
|
583
624
|
try {
|
|
584
625
|
if (typeof off === 'function') off()
|
|
585
626
|
} catch {
|