dsh-deepmoyu 2.7.1 → 2.8.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 (59) hide show
  1. package/AGENTS.md +9 -0
  2. package/CHANGELOG.md +43 -0
  3. package/CONTRIBUTING.md +1 -0
  4. package/README.md +10 -3
  5. package/SECURITY.md +4 -0
  6. package/client.js +509 -296
  7. package/docs/ACTIVITY_TUNING.md +50 -0
  8. package/docs/CONTINUOUS_BROWSING.md +35 -0
  9. package/docs/FUN_FEATURE_REFERENCES.md +128 -0
  10. package/docs/LOCAL_COMPAT_AUDIT_2026-11-12.md +58 -0
  11. package/docs/NATURAL_AUTONOMY.md +91 -0
  12. package/docs/PRIVACY.md +7 -0
  13. package/docs/RELEASE_2_8_0.md +27 -0
  14. package/docs/RELEASE_AUDIT.md +16 -2
  15. package/docs/RELEASING.md +15 -0
  16. package/docs/TEST_MATRIX.md +9 -0
  17. package/docs/WORKFLOW_REFERENCES.md +57 -0
  18. package/docs/tool-evidence.json +6 -1
  19. package/index.js +941 -268
  20. package/lib/asr.py +22 -0
  21. package/lib/background-tools.mjs +1 -1
  22. package/lib/browse-preferences.mjs +154 -0
  23. package/lib/browse-progress.mjs +165 -0
  24. package/lib/comment.mjs +29 -46
  25. package/lib/config-policy.mjs +2 -1
  26. package/lib/curiosity.mjs +153 -0
  27. package/lib/favops.py +38 -6
  28. package/lib/learn-review.mjs +13 -3
  29. package/lib/learn-store.mjs +7 -0
  30. package/lib/learn.mjs +234 -69
  31. package/lib/login.mjs +20 -3
  32. package/lib/mcp.mjs +5 -2
  33. package/lib/memory-center.mjs +21 -19
  34. package/lib/mirror.mjs +15 -1
  35. package/lib/mode.mjs +23 -9
  36. package/lib/model-relay-adapter.mjs +1 -1
  37. package/lib/model-relay.mjs +21 -4
  38. package/lib/pace.mjs +15 -7
  39. package/lib/persona.mjs +41 -30
  40. package/lib/pet-session.mjs +9 -3
  41. package/lib/product-status.mjs +38 -2
  42. package/lib/prompts.mjs +38 -37
  43. package/lib/pv-session.mjs +16 -1
  44. package/lib/pv.mjs +18 -3
  45. package/lib/pv.py +168 -30
  46. package/lib/pv_cover.py +11 -1
  47. package/lib/pv_editorial.py +28 -4
  48. package/lib/pv_plan.py +20 -2
  49. package/lib/pv_verify.py +14 -0
  50. package/lib/session-continuation.mjs +47 -0
  51. package/lib/state.mjs +6 -2
  52. package/lib/sticker-automation.mjs +16 -2
  53. package/lib/sticker.mjs +4 -1
  54. package/lib/uvfetch.mjs +38 -12
  55. package/package.json +1 -1
  56. package/presets/deepmoyu.patch.yml +8 -10
  57. package/prompts/duty.md +30 -38
  58. package/vendor/bilibili-mcp/mcp_server.py +204 -47
  59. package/vendor/bilibili-mcp/runtime_guard.py +164 -31
package/index.js CHANGED
@@ -24,15 +24,19 @@ const z = hostDeps.z
24
24
  const defineTool = hostDeps.defineTool
25
25
  import { spawn } from 'node:child_process'
26
26
  import { appendFileSync, closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
27
- import { join, dirname } from 'node:path'
27
+ import { basename, dirname, join } from 'node:path'
28
28
  import { createRequire } from 'node:module'
29
29
  import { AsyncLocalStorage } from 'node:async_hooks'
30
30
  import { createHash, randomUUID } from 'node:crypto'
31
31
  import { fileURLToPath } from 'node:url'
32
32
  import { McpClient } from './lib/mcp.mjs'
33
- import { resolveSessionPreset, personaScope, isFeiyuGoal, CHARACTER_COMPASS } from './lib/mode.mjs'
33
+ import { resolveSessionPreset, personaScope, isFeiyuGoal, characterPrompt, hasCustomPersona } from './lib/mode.mjs'
34
34
  import { createToolScopeRegistry } from './lib/tool-scope.mjs'
35
35
  import { buildFeiyuObjective, buildLiveBrief, MOMENT_OPENING_KEEP, interactionStyleGuide } from './lib/prompts.mjs'
36
+ import { BROWSE_DEFAULTS, BROWSE_LIMITS, clampBrowseSettings, validateBrowsePatch, snapshotBrowseSettings, filterBrowseCandidates, videoViewCount } from './lib/browse-preferences.mjs'
37
+ import { createBrowseProgress, observeBrowseProgress, browseProgressBrief } from './lib/browse-progress.mjs'
38
+ import { sessionContinuationDecision } from './lib/session-continuation.mjs'
39
+ import { curiousChange, curiousSnapshot, curiousHint } from './lib/curiosity.mjs'
36
40
  import { RUN_DEFAULTS, RUN_LIMITS, migrateRunSettings, clampRunSettings, resolveRunOptions, scheduleRunDeadline, backgroundWindowActive, backgroundWindowEndAt, backgroundWindowLaunch } from './lib/run-settings.mjs'
37
41
  import { AUTOMATION_DEFAULTS, AUTOMATION_LIMITS, clampAutomationSettings, dailyScheduleStatus, markDailyStarted, releaseDailyStart } from './lib/automation.mjs'
38
42
  import { COMPANION_DEFAULTS, clampCompanionSettings, companionPolicy, companionPrompt, nextChatterAt } from './lib/companion.mjs'
@@ -91,8 +95,8 @@ import { commentQuality, templateCheck, recentTexts, TEMPLATE_STREAK } from './l
91
95
  import { tasteRemember, tasteApplyLikes, tasteStats, tasteDigest, tasteTop, tasteMirror, TASTE_MIN_N } from './lib/taste.mjs'
92
96
  // 🎓 自主学习:学到的先进待审队列,批准了才注入提示词 —— 2026-10-21 使用者第 2 条要求
93
97
  import {
94
- learnAdd, learnDecide, learnForget, learnStats, learnList, learnInject,
95
- asBook, jargonCandidates, LEARN_KINDS, LEARN_KIND_LABEL, LEARN_PENDING_MAX, LEARN_APPROVED_MAX,
98
+ learnAdd, learnDecide, learnForget, learnStats, learnList, learnInject, learnFeedback,
99
+ asBook, readableLearningBook, jargonCandidates, LEARN_KINDS, LEARN_KIND_LABEL, LEARN_PENDING_MAX, LEARN_APPROVED_MAX,
96
100
  } from './lib/learn.mjs'
97
101
  // ⏱️ 出手的节奏(2026-10-21 使用者 m18802 第 1 条「评论不要太频繁,几分钟十几条会被判机器」)——
98
102
  // 最小间隔 + 随机抖动 + 滚动小时上限 + 连发刹车 + 风控冷却,全在 lib/pace.mjs 里算,
@@ -106,7 +110,7 @@ import {
106
110
  // resolve 只出**库内**绝对路径,collect 只收 http(s) 或库内文件 —— 不当下载器,不碰任意路径。
107
111
  import {
108
112
  listStickers, collectSticker, uploadSticker, forgetSticker, resolveSticker, collectedDirOf, readStickerImage, approveSticker, rejectSticker,
109
- snapshotStickerForSend,
113
+ snapshotStickerForSend, STICKER_MAX,
110
114
  sniffImageFormat,
111
115
  } from './lib/sticker.mjs'
112
116
  import { STICKER_DEFAULTS, STICKER_LIMITS, clampStickerSettings, stickerSettings, createStickerAutomation } from './lib/sticker-automation.mjs'
@@ -217,7 +221,7 @@ export const Config = !z ? undefined : z.object({
217
221
  * 一句话区别:老的靠 headless 子进程、看不见过程;新的靠 DSH 原生 goal,
218
222
  * **就在当前这个对话里、用当前这个模型刷**,聊天窗口里实时看得见。 */
219
223
 
220
- /** 它的人格设定(自由文本;留空 = 按章程那套中性口吻)。
224
+ /** 全局人格(自由文本;非空替代内置人格,留空使用默认,覆盖鱼模式所有AI发言)。
221
225
  * ⚠️ 2026-10-21:这段**只在鲸鱼摸鱼模式里注入**(preset 判定见 sessionPresetId),
222
226
  * 不会再污染别的项目对话。 */
223
227
  persona: z.string().default('').extra('volatile', true),
@@ -235,6 +239,14 @@ export const Config = !z ? undefined : z.object({
235
239
  momentStyle: z.string().default('随心情').extra('volatile', true),
236
240
  /** 鲸鱼的互动档位:只调整陪伴方式,不改变任何发送权限。 */
237
241
  interactionStyle: z.string().default('companion').extra('volatile', true),
242
+ browseTopics: z.array(z.string()).default([]).extra('volatile', true),
243
+ browseAvoidKeywords: z.array(z.string()).default([]).extra('volatile', true),
244
+ browseExplorePercent: z.number().default(25).extra('volatile', true),
245
+ browseMinMinutes: z.number().default(0).extra('volatile', true),
246
+ browseMaxMinutes: z.number().default(0).extra('volatile', true),
247
+ browseRecentDays: z.number().default(7).extra('volatile', true),
248
+ browseSort: z.string().default('click').extra('volatile', true),
249
+ browseMinViews: z.number().default(50000).extra('volatile', true),
238
250
  /** 指定模型(`provider/model`;**留空 = 用当前对话的模型**) */
239
251
  modelId: z.string().default('').extra('volatile', true),
240
252
  /** token 预算(0 = 不限;**这一次开跑之后**烧的量超了就自动停,不会把号刷爆) */
@@ -385,10 +397,14 @@ export function apply(ctx, config) {
385
397
  try { const j = readJson(overrideFile, {}); return j && typeof j === 'object' ? j : {} } catch { return {} }
386
398
  }
387
399
  const markOverride = (keys, on) => {
400
+ // 审计 P3-2(2026-11-12):以前裸 read+writeFileSync——并发保存互相覆盖 mark、崩溃留半行
401
+ // JSON(读侧容错回 {} ⇒ 本地优先全量失效)。改走与其它配置写一致的「锁内读改 + 原子写」。
388
402
  try {
389
- const cur = readOverride()
390
- for (const k of keys) { if (on) cur[k] = Date.now(); else delete cur[k] }
391
- writeFileSync(overrideFile, JSON.stringify(cur, null, 2), 'utf8')
403
+ withFileLockSync(overrideFile, () => {
404
+ const cur = readOverride()
405
+ for (const k of keys) { if (on) cur[k] = Date.now(); else delete cur[k] }
406
+ atomicWriteFileSync(overrideFile, JSON.stringify(cur, null, 2), 'utf8')
407
+ })
392
408
  } catch (e) { log('记 local-override 失败:' + (e && e.message ? e.message : e)) }
393
409
  }
394
410
  const logFile = join(stateDir, 'duty.log')
@@ -520,6 +536,7 @@ export function apply(ctx, config) {
520
536
  case 'bili_pace': return `问了一句现在能不能发(${String(arg.kind || '')})`
521
537
  case 'bili_learn': return `记了点想学的(${String(arg.action || '')})`
522
538
  case 'bili_soul': return '翻自己的心情小本子'
539
+ case 'bili_curiosity': return `好奇心手帐(${String(arg.action || 'list')})`
523
540
  case 'bili_taste': return '看看哪种说话方式有人爱听'
524
541
  case 'bili_sticker': return `表情包库(${String(arg.action || 'list')})`
525
542
  case 'bili_feiyu': return `鲸鱼摸鱼模式(${String(arg.action || 'status')})`
@@ -571,6 +588,7 @@ export function apply(ctx, config) {
571
588
  ...RUN_DEFAULTS,
572
589
  ...AUTOMATION_DEFAULTS,
573
590
  ...STICKER_DEFAULTS,
591
+ ...BROWSE_DEFAULTS,
574
592
  perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, sendReview: false, transcribe: false, asrModel: 'small',
575
593
  dmMode: 'off', dmUid: 0,
576
594
  keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '', reportLevel: 'full', replyToComments: false, replyPerRound: 1,
@@ -635,6 +653,7 @@ export function apply(ctx, config) {
635
653
  ...RUN_LIMITS,
636
654
  ...AUTOMATION_LIMITS,
637
655
  ...STICKER_LIMITS,
656
+ ...BROWSE_LIMITS,
638
657
  perDay: [0, 100], // 一天最多挑几条(使用者要 100)
639
658
  momentPerDay: [0, 50], // 一天最多发几条说说
640
659
  dmReplyPerDay: [0, 50], // 🚫 2026-10-21 退役:不再参与判据(只为配置兼容留着)
@@ -650,7 +669,7 @@ export function apply(ctx, config) {
650
669
  /* 🚫 2026-10-24(m11704 ⑥):对同一个人的**软上限** —— 同一个人今天被回过这么多条以后,
651
670
  * bili_dm_reply 在工具层打回(force 可越)。是"别对一个人刷屏"的刹车,不是总量额度:
652
671
  * 换个人照样能回。写在 index.js 顶部常量区,测试与文档引同一份。 */
653
- const DM_PER_USER_SOFT = 3
672
+ const DM_UNVERIFIED_PER_USER = 3
654
673
  const clampNum = (key, val, dflt) => {
655
674
  const [lo, hi] = LIMITS[key] || [0, Number.MAX_SAFE_INTEGER]
656
675
  const n = Number(val)
@@ -714,7 +733,7 @@ export function apply(ctx, config) {
714
733
  if ('pvGsvPython' in p) p.pvGsvPython = String(p.pvGsvPython ?? '').trim().slice(0, 400)
715
734
  // 🐾 桌宠显隐:纯布尔,给什么是什么(桌宠菜单/设置页两处都可能写它)
716
735
  if ('petShow' in p) p.petShow = Boolean(p.petShow)
717
- return clampCompanionSettings(clampStickerSettings(clampAutomationSettings(clampRunSettings(p))))
736
+ return clampBrowseSettings(clampCompanionSettings(clampStickerSettings(clampAutomationSettings(clampRunSettings(p)))))
718
737
  }
719
738
 
720
739
  const hostCfg = () => normalize(migrateVideoSettings(config), { ...DEFAULTS }) // 宿主那份(schema 校验过、默认值已填)
@@ -797,7 +816,11 @@ export function apply(ctx, config) {
797
816
  for (const k of Object.keys(DEFAULTS)) {
798
817
  if (touched.has(k)) { out[k] = saved[k]; continue } // 我们这边管 ⇒ 不再被宿主压回
799
818
  if (user[k] !== undefined) { out[k] = user[k]; continue } // 用户在 DSH 原生页改的 ⇒ 听他的
800
- out[k] = saved[k]
819
+ // 审计 P3-1(2026-11-12):这枝原来是 `out[k] = saved[k]`——把 776 行注释描述的
820
+ // 「describe().value 当基准」整个变成死代码(value 从未幸存)。现在按注释本意:
821
+ // 两边都没显式设过的键,宿主现值(value,含 patch 层解析结果)优先,宿主没给才退回本地 config。
822
+ const hostValue = row.value && typeof row.value === 'object' ? row.value[k] : undefined
823
+ out[k] = hostValue !== undefined ? hostValue : saved[k]
801
824
  }
802
825
  for (const k of touched) {
803
826
  if (k in DEFAULTS && user[k] !== undefined && JSON.stringify(user[k]) !== JSON.stringify(out[k])) diff.push(k)
@@ -820,6 +843,7 @@ export function apply(ctx, config) {
820
843
  withFileLockSync(cfgFile, () => {
821
844
  const current = migrateVideoSettings(readJsonFileSync(cfgFile, {}))
822
845
  if (!current || typeof current !== 'object' || Array.isArray(current)) throw new Error('CONFIG_JSON_INVALID: expected an object')
846
+ validateBrowsePatch(patch, normalize(current, cfg))
823
847
  const next = normalize(patch, normalize(current, cfg))
824
848
  const keys = new Set([...(Array.isArray(current._set) ? current._set : []), ...touchedKeys,
825
849
  ...Object.keys(patch || {})].filter(key => Object.hasOwn(DEFAULTS, key)))
@@ -875,6 +899,7 @@ export function apply(ctx, config) {
875
899
  if (!Object.hasOwn(DEFAULTS, key)) throw Object.assign(new Error('未知配置字段:' + key), { status: 400, code: 'CONFIG_INPUT' })
876
900
  if (typeof DEFAULTS[key] === 'boolean' && typeof patch[key] !== 'boolean') throw Object.assign(new Error(key + ' 必须是 true 或 false,不能使用字符串或数字'), { status: 400, code: 'CONFIG_INPUT' })
877
901
  }
902
+ validateBrowsePatch(patch, cfg)
878
903
  const clean = clampPatch(patch)
879
904
  /* ⭐ 2026-10-20(m11206):钳制不再静默 —— 把「你要的」和「实际存下的」摆一起报给界面。
880
905
  * 原来 perDay 填 100 被钳成 50,界面还回一句「已保存」,使用者只能靠肉眼发现「还是 50」。 */
@@ -918,6 +943,7 @@ export function apply(ctx, config) {
918
943
 
919
944
  // Keep the originating run's cancellation through nested async wrapper calls.
920
945
  const toolExecution = new AsyncLocalStorage()
946
+ const toolBrowseGeneration = new AsyncLocalStorage()
921
947
  let mcp = null
922
948
  let mcpKey = ''
923
949
  const mcpKeyNow = () => `${cfg.pythonPath}|${join(stateDir, 'mcp_server.py')}`
@@ -967,7 +993,8 @@ export function apply(ctx, config) {
967
993
  const execution = toolExecution.getStore()
968
994
  const signal = execution?.signal
969
995
  const assertScope = () => {
970
- if (execution?.agent && !approvedReviewExecutions.has(execution) && !isFishExecution(execution)) throw Object.assign(new Error('MODE_REQUIRED: 已退出鲸鱼摸鱼模式,停止本次工具后续操作'), { code: 'MODE_REQUIRED' })
996
+ if (execution?.agent && !approvedReviewExecutions.has(execution)
997
+ && (!isFishExecution(execution) || !browseGenerationCurrent(execution))) throw Object.assign(new Error('MODE_REQUIRED: 已退出鲸鱼摸鱼模式,停止本次工具后续操作'), { code: 'MODE_REQUIRED' })
971
998
  }
972
999
  assertScope()
973
1000
  signal?.throwIfAborted()
@@ -980,20 +1007,28 @@ export function apply(ctx, config) {
980
1007
  }
981
1008
  }
982
1009
  const client = await ensureMcp()
983
- signal?.throwIfAborted()
984
- assertScope()
985
- if (reviewed) {
986
- refreshCfg()
987
- const row = reviewed.row
988
- const publicOk = cfg.dmMode !== 'only'
989
- const allowed = row.tool === 'bili_deliver' ? ['both', 'only'].includes(cfg.dmMode) && readableId(cfg.dmUid) === row.targetUid
990
- : row.tool === 'bili_dm_reply' ? Boolean(cfg.autoReplyDm)
991
- : row.tool === 'bili_moment' ? Boolean(cfg.autoMoment)
992
- : publicOk && Boolean(optionalId(row.args.rpid) === '0' ? cfg.autoComment : cfg.replyToComments)
993
- if (!allowed) throw Object.assign(new Error('批准后发送权限已关闭或收件人已改变,内容没有提交'), { reviewBeforeSubmit: true })
994
- reviewed.submitted = true
995
- }
996
- const result = await client.call(toolName, args, signal ? { ...opts, signal } : opts)
1010
+ const checkSubmission = () => {
1011
+ signal?.throwIfAborted()
1012
+ assertScope()
1013
+ if (sending) {
1014
+ refreshCfg()
1015
+ const row = reviewed?.row
1016
+ const origin = row?.tool || toolBrowseGeneration.getStore()?.tool
1017
+ const publicOk = cfg.dmMode !== 'only'
1018
+ const allowed = origin === 'bili_deliver' ? ['both', 'only'].includes(cfg.dmMode) && readableId(cfg.dmUid) === (row?.targetUid || readableId(args.receiver_uid))
1019
+ : origin === 'bili_dm_reply' ? Boolean(cfg.autoReplyDm)
1020
+ : origin === 'bili_moment' ? publicOk && Boolean(cfg.autoMoment)
1021
+ : ['bili_reply', 'bili_comment', 'bili_reply_opus'].includes(origin)
1022
+ && publicOk && Boolean(optionalId(args.rpid) === '0' ? cfg.autoComment : cfg.replyToComments)
1023
+ if (!allowed) throw Object.assign(new Error('发送权限已关闭或收件人已改变,内容没有提交'), { code: 'SEND_PERMISSION_REVOKED', ...(reviewed ? { reviewBeforeSubmit: true } : {}) })
1024
+ }
1025
+ }
1026
+ checkSubmission()
1027
+ const result = await client.call(toolName, args, { ...opts, ...(signal ? { signal } : {}), beforeSubmit() {
1028
+ // Initial MCP startup can await a slow handshake; authority must still hold at wire submission.
1029
+ checkSubmission()
1030
+ if (reviewed) reviewed.submitted = true
1031
+ } })
997
1032
  // A completed send must still be recorded even if stop arrived during IO.
998
1033
  // Reads may not resume a stopped wrapper and submit a new send afterwards.
999
1034
  if (!sending) { signal?.throwIfAborted(); assertScope() }
@@ -1107,6 +1142,44 @@ export function apply(ctx, config) {
1107
1142
  const backgroundToolRegistry = new Map()
1108
1143
  // Per-run execution tokens stay in-process; ordinary tools cannot claim a fish background context.
1109
1144
  const backgroundExecutions = new WeakSet()
1145
+ // Contexts follow the actual agent/run object, so unrelated chats never share progress.
1146
+ const browseContexts = new WeakMap()
1147
+ const browseOwner = exec => exec?.browseScope || exec?.agent || null
1148
+ const browseContext = exec => {
1149
+ const owner = browseOwner(exec)
1150
+ if (!owner) return null
1151
+ if (!browseContexts.has(owner)) browseContexts.set(owner, { progress: createBrowseProgress(), topic: '' })
1152
+ return browseContexts.get(owner)
1153
+ }
1154
+ const browseGenerationCurrent = exec => {
1155
+ const generation = toolBrowseGeneration.getStore()
1156
+ return !generation?.owner || generation.owner !== browseOwner(exec)
1157
+ || browseContexts.get(generation.owner) === generation.context
1158
+ }
1159
+ const renderBrowseProgress = local => {
1160
+ const brief = browseProgressBrief(local?.progress, Date.now(), { compact: true })
1161
+ // A new background child has no preceding tool messages to recover the query from.
1162
+ // Reuse only the scoped, redacted search topic; the run's frozen preferences still apply.
1163
+ if (brief.next?.tool === 'bili_search' && local?.topic) {
1164
+ brief.next = { ...brief.next, args: { keyword: local.topic, page: brief.next.page } }
1165
+ }
1166
+ return brief
1167
+ }
1168
+ const browsingCfg = (exec = toolExecution.getStore()) => ({ ...cfg,
1169
+ ...(exec?.browsePreferences || (exec?.agent && feiyuLive.get(exec.agent.id)?.browsePreferences) || {}),
1170
+ })
1171
+ const learningOptions = (exec = toolExecution.getStore(), query = '') => {
1172
+ const local = browseContext(exec)
1173
+ const source = browsingCfg(exec)
1174
+ let round = ''
1175
+ try { round = ctx.get('goals')?.get(exec?.agent)?.roundsStarted || '' } catch {}
1176
+ const explicit = typeof query === 'string' ? query.trim() : ''
1177
+ const readBvid = local?.progress?.phase === 'reflect' && local.progress.readBvids?.includes(local.progress.lastBvid) ? local.progress.lastBvid : ''
1178
+ const context = explicit || [local?.topic || '', readBvid].filter(Boolean).join(' ') || source.keywords || []
1179
+ return { budget: 1600, mode: 'dafeiyu', context, query: explicit, requireTopic: true,
1180
+ contextSource: explicit ? 'explicit-query' : local?.topic ? 'scoped-search' : readBvid ? 'current-video-source' : source.keywords?.length ? 'preferences' : 'none',
1181
+ rotationKey: `${exec?.browseScope?.id || exec?.agent?.id || dayKey()}:${round}` }
1182
+ }
1110
1183
  const isFishExecution = exec => {
1111
1184
  if (exec && backgroundExecutions.has(exec)) return true
1112
1185
  let goal = null
@@ -1119,7 +1192,10 @@ export function apply(ctx, config) {
1119
1192
  onEvent('agent/disposed', ({ agent }) => roleTools.detach(agent), '鲸鱼工具会话卸载')
1120
1193
  onEvent('agent-preset/selected', (sessionId) => {
1121
1194
  const agent = (ctx.get('agents')?.list?.() || []).find(item => item.id === sessionId || item.session?.id === sessionId)
1122
- if (agent) roleTools.refresh(agent)
1195
+ if (agent) {
1196
+ roleTools.refresh(agent)
1197
+ if (!isFishExecution({ agent })) browseContexts.delete(agent)
1198
+ }
1123
1199
  }, '鲸鱼工具预设隔离')
1124
1200
  ctx.effect(() => () => roleTools.dispose(), '鲸鱼工具作用域清理')
1125
1201
  } catch (error) { log('鲸鱼工具作用域监听失败:' + String(error?.message ?? error)) }
@@ -1142,7 +1218,7 @@ export function apply(ctx, config) {
1142
1218
  const unauthorized = modelForceDecision(args)
1143
1219
  if (unauthorized) return unauthorized
1144
1220
  }
1145
- if (['bili_soul', 'bili_learn', 'bili_taste', 'bili_records', 'bili_sticker'].includes(spec.name)) {
1221
+ if (['bili_soul', 'bili_learn', 'bili_taste', 'bili_records', 'bili_sticker', 'bili_curiosity'].includes(spec.name)) {
1146
1222
  const agent = exec?.agent
1147
1223
  let goal = null
1148
1224
  try { goal = agent ? ctx.get('goals')?.get(agent) : null } catch { /* no goal */ }
@@ -1161,13 +1237,38 @@ export function apply(ctx, config) {
1161
1237
  // 否则跑一轮能刷出十几条半截记录,看不过来。
1162
1238
  const id = `t${++actSeq}`
1163
1239
  if (!quiet) act('call', describeCall(spec.name, args), { tool: spec.name, id })
1240
+ const generation = { owner: browseOwner(exec), context: browseContext(exec), tool: spec.name }
1164
1241
  try {
1165
- const v = safe(await toolExecution.run(exec || {}, async () => {
1242
+ let v = safe(await toolBrowseGeneration.run(generation, () => toolExecution.run(exec || {}, async () => {
1166
1243
  exec?.signal?.throwIfAborted()
1167
1244
  const review = await queueSendReview(spec.name, args, exec)
1168
1245
  if (review) return review
1246
+ if (!browseGenerationCurrent(exec)) return { ok: false, reason: 'mode_required', hint: '调用开始后的角色作用域已改变,请在当前模式重新发起。' }
1169
1247
  return spec.execute(args, exec)
1170
- }))
1248
+ })))
1249
+ if (browseContexts.get(generation.owner) === generation.context && isFishExecution(exec)
1250
+ && (browsingTools.has(spec.name) || spec.name === 'bili_pace' || spec.name === 'bili_seen')) {
1251
+ const local = browseContext(exec)
1252
+ if (local) {
1253
+ const succeeded = v && typeof v === 'object' && !v.error && v.ok !== false && v.success !== false && !v.blocked && !v.unknown
1254
+ const known = new Set([...(local.progress?.pendingBvids || []), ...(local.progress?.readBvids || []),
1255
+ ...(local.progress?.skippedBvids || []), ...(local.progress?.deferredReads || []).map(row => row.bvid)])
1256
+ const found = ['bili_search', 'bili_rank', 'bili_hot_videos', 'bili_user_videos'].includes(spec.name) && Array.isArray(v?.fresh)
1257
+ && v.fresh.some(row => bvidOf(row) && !known.has(bvidOf(row)))
1258
+ const read = spec.name === 'bili_subtitle' && String(v?.text || v?.full_text || v?.content || '').trim()
1259
+ && !(local.progress?.readBvids || []).includes(String(args?.bvid || '').trim())
1260
+ const interacted = ['bili_comment', 'bili_reply', 'bili_reply_opus', 'bili_dm_reply', 'bili_moment'].includes(spec.name) && (v?.ok === true || v?.success === true || v?.guard_status === 'sent')
1261
+ if (succeeded && (found || read || interacted)) local.workVersion = (Number(local.workVersion) || 0) + 1
1262
+ local.progress = observeBrowseProgress(local.progress, { tool: spec.name, args, result: v })
1263
+ if (spec.name === 'bili_search' && typeof args?.keyword === 'string') local.topic = redactRecordText(args.keyword, { maxLength: 120 })
1264
+ const brief = renderBrowseProgress(local)
1265
+ const fingerprint = JSON.stringify(brief)
1266
+ if (v && typeof v === 'object' && !Array.isArray(v) && fingerprint !== local.lastProgressFingerprint) {
1267
+ v = { ...v, browse_progress: brief }
1268
+ local.lastProgressFingerprint = fingerprint
1269
+ }
1270
+ }
1271
+ }
1171
1272
  if (!quiet) {
1172
1273
  const sum = describeResult(v)
1173
1274
  const ms = Date.now() - t0
@@ -1175,6 +1276,11 @@ export function apply(ctx, config) {
1175
1276
  }
1176
1277
  return v
1177
1278
  } catch (e) {
1279
+ if (browsingTools.has(spec.name) && isFishExecution(exec)
1280
+ && browseContexts.get(generation.owner) === generation.context) {
1281
+ const local = browseContext(exec)
1282
+ if (local) local.progress = observeBrowseProgress(local.progress, { tool: spec.name, args, result: { ok: false, error: 'tool-failed' } })
1283
+ }
1178
1284
  if (!quiet) act('fail', `${describeCall(spec.name, args)} 出错:${String(e?.message ?? e).slice(0, 140)}`, { tool: spec.name, id })
1179
1285
  throw e
1180
1286
  }
@@ -1200,20 +1306,26 @@ export function apply(ctx, config) {
1200
1306
  tool({
1201
1307
  name: 'bili_search',
1202
1308
  description: '搜 B站 视频(可排序 / 可按发布时间筛)。返回标题 / BV号 / 播放量 / UP / 时长 / **发布日期** 等。\n'
1203
- + '⭐ 刷一轮就用 `days: 7`(只看近 7 天发布的),然后**读字幕原文**再判断(别看摘要下结论)。\n'
1309
+ + '不填 days/order 时用本次启动的选片偏好;显式参数只调整上游检索,返回的 fresh 仍经过本次偏好范围筛选和排序。先读字幕原文再判断。\n'
1204
1310
  + '`days` 筛空了会兜底给综合排序的前几条,并标 `filtered_out_all: true`。',
1205
1311
  parameters: {
1206
1312
  keyword: { type: 'string', description: '搜索关键词' },
1207
1313
  num: { type: 'number', description: '返回几条(默认 10,最大 50)' },
1208
- order: { type: 'string', description: 'totalrank=综合(默认)/ click=播放 / pubdate=最新 / dm=弹幕' },
1209
- days: { type: 'number', description: '只看最近几天发布的(0/不填 = 不限;刷一轮一般填 7)' },
1314
+ order: { type: 'string', description: '不填沿用本次偏好(新安装默认click播放量优先);totalrank=综合 / click=播放 / pubdate=最新 / dm=弹幕' },
1315
+ days: { type: 'number', description: '上游检索最近几天;0=检索不限,不填用本次偏好。最终 fresh 仍按本次偏好筛选' },
1316
+ page: { type: 'number', description: '搜索页码,1至50;同一关键词的候选已处理完可翻下一页,默认1' },
1210
1317
  },
1211
1318
  async execute(args) {
1319
+ const preferences = browsingCfg()
1320
+ if (args.page !== undefined && (typeof args.page !== 'number' || !Number.isSafeInteger(args.page) || args.page < 1 || args.page > 50)) {
1321
+ return { ok: false, error: 'page 需要 1 至 50 之间的整数' }
1322
+ }
1212
1323
  const raw = await call('bili_search', {
1213
1324
  keyword: String(args.keyword ?? ''),
1214
1325
  num: Math.max(1, Math.min(Number(args.num) || 10, 50)),
1215
- order: String(args.order || 'totalrank'),
1216
- days: Math.max(0, Math.min(Number(args.days) || 0, 365)),
1326
+ order: String(args.order || (preferences.browseSort === 'mixed' ? 'totalrank' : preferences.browseSort) || 'totalrank'),
1327
+ days: Math.max(0, Math.min(Number(args.days ?? preferences.browseRecentDays) || 0, 365)),
1328
+ ...(args.page === undefined ? {} : { page: args.page }),
1217
1329
  })
1218
1330
  return withSeen(raw, (o) => o.videos ?? o.items ?? o.list, 'videos')
1219
1331
  },
@@ -1231,6 +1343,25 @@ export function apply(ctx, config) {
1231
1343
  },
1232
1344
  async execute(args) {
1233
1345
  const bvid = String(args.bvid ?? '').trim()
1346
+ const execution = toolExecution.getStore(), local = browseContext(execution)
1347
+ const minimum = Number(browsingCfg(execution).browseMinViews) || 0
1348
+ // Only autonomous discovery candidates are filtered here. Explicit links and
1349
+ // already-read subtitle checks keep their original read-only behaviour.
1350
+ if (minimum > 0 && local?.popularity && Object.hasOwn(local.popularity, bvid) && !seenMap()[bvid]) {
1351
+ let count = local.popularity[bvid]
1352
+ if (count === null) {
1353
+ const detail = await call('bili_video_info', { bvid })
1354
+ if (!isFishExecution(execution) || !browseGenerationCurrent(execution)) return { ok: false, reason: 'mode_required' }
1355
+ if (!detail || detail.error || detail.ok === false || detail.success === false) return { ok: false, code: 'BROWSE_POPULARITY_UNKNOWN',
1356
+ error: '候选播放量暂未核实,本次不读取字幕', hint: '先换另一条已知热门视频,不把未知播放量当成热门。' }
1357
+ count = videoViewCount(detail)
1358
+ if (count === null) return { ok: false, code: 'BROWSE_POPULARITY_UNKNOWN', error: '详情仍未提供有效播放量,本次不读取字幕',
1359
+ hint: '换一条播放量明确且符合偏好的候选。' }
1360
+ local.popularity[bvid] = count
1361
+ }
1362
+ if (count < minimum) return { ok: false, skipped: true, preference_skipped: true, reason: 'minimum-views', bvid,
1363
+ view_count: count, minimum_views: minimum, hint: '播放量低于本次门槛,跳过这条并继续找热门候选,不代表整趟结束。' }
1364
+ }
1234
1365
  const out = await call('bili_subtitle', {
1235
1366
  bvid,
1236
1367
  with_time: args.with_time !== false,
@@ -1238,7 +1369,8 @@ export function apply(ctx, config) {
1238
1369
  /* 🎬 「读过字幕」也算处理过(2026-10-21):有些视频读完了发现没情绪、不值得评,
1239
1370
  * 但**它确实刷过了** —— 下轮不该再花一次字幕预算重读。
1240
1371
  * 没字幕(拿不到内容)不记:那说明还没真正看过它。 */
1241
- const got = Boolean(out && !out.error && (out.segments > 0 || out.text || out.full_text || out.content))
1372
+ const got = Boolean(out && !out.error && out.ok !== false && out.success !== false
1373
+ && String(out.text || out.full_text || out.content || '').trim())
1242
1374
  if (got && isBvid(bvid)) {
1243
1375
  await seenSave((current) => seenRemember(current, bvid, { note: '读过字幕' }))
1244
1376
  }
@@ -1305,7 +1437,7 @@ export function apply(ctx, config) {
1305
1437
  total: list.length,
1306
1438
  hot,
1307
1439
  hint: hot.length
1308
- ? '`count` 越大说明那 10 秒越炸。写评论指一句「XX:XX 那里」就够真。'
1440
+ ? '`count` 只反映这段弹幕较集中,不等于视频原意。读懂内容后用自己的话说反应,不摘弹幕或报时间点来证明看过。'
1309
1441
  : '一条弹幕都没有。',
1310
1442
  }
1311
1443
  },
@@ -1325,7 +1457,7 @@ export function apply(ctx, config) {
1325
1457
  name: 'bili_buzzwords',
1326
1458
  description: '🔥 全站**热搜词**(此刻大家在搜什么)。\n'
1327
1459
  + '⭐ 用途:没主意的时候看一眼,比凭空想关键词强得多;也能判断某个话题现在有没有热度。\n'
1328
- + '⚠️ 它给的是词,不是视频 —— 拿到词再 `bili_search({keyword, days:7})` 才落到视频上。',
1460
+ + '它给的是词,不是视频;拿到词再 bili_search,日期范围沿用本次偏好。',
1329
1461
  parameters: {},
1330
1462
  async execute() {
1331
1463
  const out = await call('bili_hot_buzzwords', {})
@@ -1343,7 +1475,7 @@ export function apply(ctx, config) {
1343
1475
  count: rows.length,
1344
1476
  total: Number(out?.page?.total) || rows.length,
1345
1477
  words: rows,
1346
- hint: '挑一个顺眼的词,`bili_search({keyword, days:7, order:"pubdate"})` 落到视频上;`pic` 有值的说明那是带图的梗,更适合拿来讲。',
1478
+ hint: '挑一个顺眼的词,用 bili_search 落到视频上;日期和排序沿用本次偏好,不要求追每个热词。',
1347
1479
  }
1348
1480
  },
1349
1481
  })
@@ -1483,17 +1615,22 @@ export function apply(ctx, config) {
1483
1615
  if (b) { if (seenBv.has(b)) continue; seenBv.add(b) }
1484
1616
  dedup.push(r)
1485
1617
  }
1486
- // ⭐ 热门优先(m11704 ③):fresh 按播放量从高到低排,模型照着挑自然先看到热门片;
1487
- // 播放量缺失的行按 0 处理,稳定排序保证同播放量时不抖动。
1488
- dedup.sort((a, b) => (Number(b?.play) || Number(b?.stat?.view) || 0) - (Number(a?.play) || Number(a?.stat?.view) || 0))
1489
- out.fresh = dedup
1618
+ const preferred = filterBrowseCandidates(dedup, browsingCfg())
1619
+ const execution = toolExecution.getStore()
1620
+ const local = isFishExecution(execution) && browseGenerationCurrent(execution)
1621
+ ? browseContexts.get(browseOwner(execution)) : null
1622
+ if (local) local.popularity = Object.fromEntries([...Object.entries(local.popularity || {}),
1623
+ ...dedup.map(row => [bvidOf(row), videoViewCount(row)]).filter(([bvid]) => bvid)].slice(-160))
1624
+ out.fresh = preferred.items
1625
+ out.preference_summary = { excluded: preferred.excluded, unknown: preferred.unknown, settings: preferred.settings,
1626
+ hint: preferred.items.length ? '只从 fresh 选择;已按时长、日期、避开词与最低播放量筛选,缺信息先读详情核对,未知热度不算热门。' : '没有符合本次偏好的新候选,换热门榜、关键词、翻页或换来源,不降低热度门槛或强行挑旧片。' }
1490
1627
  out.seen_summary = {
1491
1628
  total: marked.items.length,
1492
1629
  already: marked.seenCount,
1493
- fresh: dedup.length,
1630
+ fresh: preferred.items.length,
1494
1631
  ...(guardError ? { guard_error: guardError } : {}),
1495
1632
  hint: marked.seenCount
1496
- ? `这 ${marked.seenCount} 条以前刷过了(already_seen=true)—— 别重复挑,直接看 fresh 里的。`
1633
+ ? `这 ${marked.seenCount} 条以前处理过,不重复挑;需要核对原文可补读字幕。新候选看 fresh。`
1497
1634
  : '这批都是没刷过的。',
1498
1635
  }
1499
1636
  return out
@@ -1660,19 +1797,57 @@ export function apply(ctx, config) {
1660
1797
  * ⇒ 注入段有**字数预算**(默认 1200 字):提示词是稀缺资源,
1661
1798
  * 塞 500 条就让"人格"那 600 字挤没了 —— 那正是之前吃过一次的亏。
1662
1799
  * ═══════════════════════════════════════════════════════════════════════════ */
1663
- const learnLedger = () => {
1664
- const st = readJson(dutyStateFile, {})
1665
- const m = st.learn
1666
- return m && typeof m === 'object' && !Array.isArray(m) ? m : {}
1800
+ const learnLedger = (snapshot) => {
1801
+ const st = snapshot || readJson(dutyStateFile, {})
1802
+ if (!st || typeof st !== 'object' || Array.isArray(st)) throw new Error('STATE_JSON_INVALID: expected an object')
1803
+ return readableLearningBook(st.learn)
1804
+ }
1805
+ const curiosityState = () => {
1806
+ const state = readJsonFileSync(dutyStateFile, {})
1807
+ if (!state || typeof state !== 'object' || Array.isArray(state)) throw new Error('STATE_JSON_INVALID: expected an object')
1808
+ return state
1809
+ }
1810
+ const curiosityMutate = async (args, { actor = 'fish', exec } = {}) => {
1811
+ const result = await withStateLock(() => {
1812
+ if (actor === 'fish' && (!isFishExecution(exec) || !browseGenerationCurrent(exec))) return { ok: false, code: 'MODE_REQUIRED', error: '好奇心手帐仅在当前鲸鱼摸鱼作用域内可写。' }
1813
+ const state = curiosityState()
1814
+ const readBvids = actor === 'fish' ? browseContext(exec)?.progress?.readBvids || [] : []
1815
+ const result = curiousChange(state.curiosity, args, { actor, readBvids })
1816
+ if (result.ok && result.changed) writeJson(dutyStateFile, { ...state, curiosity: result.board })
1817
+ return result
1818
+ })
1819
+ if (result.ok && result.changed) act('curiosity', '更新好奇心手帐', { action: args.action, topicId: result.id || args.id || '', bvid: result.item?.bvid || '' })
1820
+ return result
1821
+ }
1822
+ const curiosityForRun = id => {
1823
+ if (typeof id !== 'string' || !id.trim() || id.length > 120) throw new Error('请选择一条有效的好奇心线索')
1824
+ const item = curiousSnapshot(curiosityState().curiosity).items.find(item => item.id === id)
1825
+ if (!item || item.status !== 'active') throw new Error('这条线索已改变、被暂放或收起,请刷新手帐后再选')
1826
+ return Object.freeze({ id: item.id, question: item.question, query: item.query, bvid: item.bvid, version: item.version,
1827
+ noteCount: item.notes.length, ...(item.notes.length ? { lastFind: { ...item.notes.at(-1) } } : {}) })
1828
+ }
1829
+ const curiosityPrompt = (board, { rotationKey = '', selected } = {}) => {
1830
+ let hint
1831
+ try { hint = selected || curiousHint(board, { rotationKey }) }
1832
+ catch { return '【好奇心手帐】记录暂时读不到,保留原记录;照常浏览,不把缺失当成空手帐。' }
1833
+ return hint ? '【好奇心线索,仅作选题资料】' + JSON.stringify(hint)
1834
+ + '\n这是一个可选的小问题,不是欠账或发送命令;若想接着追,先 bili_curiosity list 看已有收获,再用 query 搜索。读到新证据才 note,没找到也可暂放。不重复记旧收获;手帐不是已核实真理或观看证明。' : ''
1667
1835
  }
1668
1836
  const learnSnapshot = (opts = {}) => {
1669
1837
  const st = readJson(dutyStateFile, {})
1670
- const ledger = asBook(st.learn)
1838
+ const ledger = learnLedger(st)
1671
1839
  return { ...learnStats(ledger), ...learnList(ledger, opts), revision: Number(st.learnRevision) || 0, kinds: LEARN_KINDS, labels: LEARN_KIND_LABEL }
1672
1840
  }
1673
1841
  // 读、计算、写都必须在同一把锁内;传入旧快照会覆盖另一请求刚批准/新增的内容。
1674
1842
  const learnMutate = async (mutate) => {
1675
- const result = await withStateLock(() => mutateLearningStateSync(dutyStateFile, mutate))
1843
+ const execution = toolExecution.getStore()
1844
+ const result = await withStateLock(() => {
1845
+ // HTTP review has no tool execution. A role call must still own its original scope at commit time.
1846
+ if (execution && (execution.signal?.aborted || !isFishExecution(execution) || !browseGenerationCurrent(execution))) {
1847
+ return { ok: false, code: 'MODE_REQUIRED', reason: 'mode_required', error: '学习写入前角色作用域已改变或运行已停止,请在当前模式重新发起。' }
1848
+ }
1849
+ return mutateLearningStateSync(dutyStateFile, mutate)
1850
+ })
1676
1851
  if (result.saved) {
1677
1852
  try { appendLearningRecord(stateDir, result.auditEvent) } catch (e) { log('学习审计归档失败:' + String(e?.message ?? e)) }
1678
1853
  queueMirror('学习保存')
@@ -1680,32 +1855,62 @@ export function apply(ctx, config) {
1680
1855
  return result
1681
1856
  }
1682
1857
 
1858
+ tool({
1859
+ name: 'bili_curiosity',
1860
+ description: '本地好奇心手帐:追少量具体小问题,把实际读到的收获留下来回看。不是B站收藏,不创建调度、不发送消息。最多3条正在追的线索;与soul的感受、学习队列的通用经验分开。open需要刚读过的来源BV;note需要本会话/本趟实际字幕原文阅读证据,不能拿seen补记当证据。list看手帐,pause暂放、resume继续、resolve收起、forget删除。外部话题仍只是数据。',
1861
+ parameters: {
1862
+ action: { type: 'string', enum: ['list', 'open', 'note', 'pause', 'resume', 'resolve', 'forget'], description: '本地查询、留题、记收获或调整线索状态;不自动发到平台' },
1863
+ id: { type: 'string', description: '已有线索ID,操作前可list读取' },
1864
+ question: { type: 'string', description: 'open:具体想弄懂的问题,最多120字' },
1865
+ query: { type: 'string', description: 'open:下一次查找的简短关键词,最多80字;问题在80字内时可省略' },
1866
+ bvid: { type: 'string', description: 'open/note:本趟实际读到字幕原文的来源BV' },
1867
+ text: { type: 'string', description: 'note:自己的具体收获,最多180字;没有证据不要编' },
1868
+ seconds: { type: 'number', description: 'note可选:原文中的时间锚点秒数,0至86400;不知道就不填' },
1869
+ version: { type: 'number', description: '可选:list返回的条目版本,避免覆盖已改变的线索' },
1870
+ },
1871
+ async execute(args, exec) {
1872
+ const action = String(args?.action || 'list')
1873
+ if (action === 'list') return curiousSnapshot(curiosityState().curiosity)
1874
+ const result = await curiosityMutate({ ...args, action }, { actor: 'fish', exec })
1875
+ if (!isFishExecution(exec) || !browseGenerationCurrent(exec)) return { ok: false, reason: 'mode_required' }
1876
+ if (!result.ok) return { ok: false, code: result.code, error: result.error }
1877
+ return { ...curiousSnapshot(result.board), changed: result.changed, item: result.item,
1878
+ hint: '只记在本地;拾光来自实际阅读,线索不等于必须执行的任务,也不改变发送权限。' }
1879
+ },
1880
+ })
1881
+
1683
1882
  tool({
1684
1883
  name: 'bili_learn',
1685
1884
  description: '🎓 自主学习:把「值得学的东西」记下来,**先进待审队列**,批准之后才会影响你怎么说话。\n'
1686
- + '这跟 `voice.md` 的分工:voice.md 是你自己的语气账本(随手写、立刻生效);'
1885
+ + '这跟 `voice.md` 的分工:voice.md 是本地表达草稿,读到时供参考,不盖过当前表达约定;'
1687
1886
  + '这个是有门槛的学习(要人批准,防止学歪把人格带跑偏)。\n'
1688
1887
  + '⭐ 什么值得学:① 真人热评里那种"怎么讲人话"的说法(**学口气不抄句子**)'
1689
1888
  + '② 这个圈子的黑话/梗到底啥意思 ③ 某个 UP 主/系列是什么来头 ④ 你自己踩过的坑(发出去没人理、被风控、被打回)。\n'
1690
1889
  + '✗ 不值得学:一句口号、纯情绪、你已经在 voice.md 里记过的东西。',
1691
1890
  parameters: {
1692
- action: { type: 'string', enum: ['add', 'review', 'list', 'forget', 'stats', 'inject'],
1693
- description: 'add=记一条 · review=待审 · list=学习历史 · forget=忘掉 · stats=统计 · inject=看注入;批准或拒绝请点桌宠 → ⚙️ 设置 →「全部设置」里的学习队列操作' },
1694
- state: { type: 'string', enum: ['pending', 'approved', 'rejected', 'all'], description: 'review/list 按状态筛选(默认 pending)' },
1695
- filter: { type: 'string', description: 'review/list 按正文、例句、来源或理由搜索(最多160字)' },
1891
+ action: { type: 'string', enum: ['add', 'review', 'list', 'forget', 'stats', 'inject', 'feedback'],
1892
+ description: 'add=记一条 · review=待审 · list=全部学习历史(含已批准知识)· forget=忘掉 · stats=统计 · inject=按话题召回批准经验;批准或拒绝由工作台或独立审核处理' },
1893
+ state: { type: 'string', enum: ['pending', 'approved', 'rejected', 'all'], description: 'review默认pending;list默认all。查看已学会内容可指定approved,待审与拒绝只可读、不参与召回' },
1894
+ filter: { type: 'string', description: 'review/list搜索词,兼容query;多个词可分别匹配正文、适用情境、例句或来源(最多160字)' },
1696
1895
  kind: { type: 'string', description: 'add 时必填:style(说话方式)/ jargon(黑话梗)/ fact(场子常识)/ voice(踩过的坑)' },
1697
1896
  text: { type: 'string', description: 'add 时必填:学到的那一句话(≤160 字,写成你自己看得懂的结论)' },
1897
+ when: { type: 'string', description: 'add 时建议填:什么时候适用、什么场景不适用(≤120字)' },
1898
+ query: { type: 'string', description: 'list/review搜索;inject按明确话题或BV来源召回批准经验。不填时只用本会话搜索/阅读来源或本次偏好,不猜其他会话内容' },
1899
+ outcome: { type: 'string', enum: ['tried', 'helpful', 'unhelpful'], description: 'feedback:实际试过/自觉有用/自觉无用,仅自报,不是验证' },
1900
+ note: { type: 'string', description: 'feedback 必填:在哪个场景试了、观察到什么;不填私信原文或凭证' },
1698
1901
  example: { type: 'string', description: 'add 时可选:例句原话(标"参考",**别照抄**)' },
1699
1902
  from: { type: 'string', description: 'add 时建议填:从哪儿学来的(BV号 / 热词 / 第几轮)' },
1700
1903
  id: { type: 'string', description: 'approve/reject/forget 时的 id(review 里能看到)' },
1701
1904
  why: { type: 'string', description: 'approve/reject 时可选:理由,留个痕' },
1702
- num: { type: 'number', description: 'review 时看几条(默认 20)' },
1905
+ num: { type: 'number', description: 'review/list每页条数,兼容limit(默认20,最多100)' },
1906
+ limit: { type: 'number', description: 'review/list每页条数,1至100,优先于num' },
1907
+ offset: { type: 'number', description: 'review/list从第几条开始,默认0;more=true时传返回的nextOffset翻页' },
1703
1908
  },
1704
1909
  async execute(args) {
1705
1910
  const action = String(args.action || 'review').trim()
1706
1911
  if (action === 'add') {
1707
1912
  const { added, reason, id, stats, revision } = await learnMutate((current) => learnAdd(current, {
1708
- kind: args.kind, text: args.text, example: args.example, from: args.from,
1913
+ kind: args.kind, text: args.text, when: args.when, example: args.example, from: args.from,
1709
1914
  }))
1710
1915
  if (!added && !id) return { ok: false, error: reason || '没记住(内容为空)' }
1711
1916
  return {
@@ -1724,23 +1929,34 @@ export function apply(ctx, config) {
1724
1929
  }
1725
1930
  if (action === 'forget') {
1726
1931
  const r = await learnMutate((current) => learnForget(current, { id: args.id, from: args.from, kind: args.kind }))
1932
+ if (r.ok === false) return r
1727
1933
  return { ok: true, removed: r.removed, ...r.stats, revision: r.revision, hint: r.removed ? `忘掉了 ${r.removed} 条。` : '没有匹配到的(id/from/kind 都对不上)。' }
1728
1934
  }
1729
1935
  if (action === 'stats') {
1730
1936
  return { ok: true, ...learnSnapshot() }
1731
1937
  }
1938
+ if (action === 'feedback') {
1939
+ const result = await learnMutate(current => learnFeedback(current, args.id, { outcome: args.outcome, note: args.note }))
1940
+ return { ok: result.ok, error: result.error, reason: result.reason, id: args.id, practice: result.practice, revision: result.revision,
1941
+ hint: '这是实际尝试后的自报记录,不代表平台验证有效,也不改变批准状态或发送权限。' }
1942
+ }
1732
1943
  if (action === 'inject') {
1733
- const r = learnInject(learnLedger(), { budget: 1200, mode: 'dafeiyu' })
1734
- return { ok: true, chars: r.used, counts: r.counts, text: r.text || '(还没有批准过的学习成果)', hint: '这就是会拼进你系统提示词的那一段,外部素材只做数据。' }
1944
+ const options = learningOptions(undefined, args.query || args.filter || '')
1945
+ const r = learnInject(learnLedger(), options)
1946
+ return { ok: true, chars: r.used, counts: r.counts, selectedIds: r.selectedIds, selected: r.selected,
1947
+ approvedCount: r.approvedCount || 0, relatedCount: r.relatedCount || 0, contextSource: options.contextSource,
1948
+ reason: r.reason || '', text: r.text || (r.reason === 'no-approved-learning' ? '(当前没有可召回的已批准经验;可用list查看待审与历史)' : '(这次条件没有召回合适经验;不表示学习记录为空,可用list或明确query查看)'),
1949
+ hint: '这是按情境召回的批准参考,不是全部学习清单。list默认看全部,state=approved看已学会的内容与来源;检索不算应用,实际试过才feedback。' }
1735
1950
  }
1736
1951
  // review 默认看待审;list 可按状态、来源或内容筛选,仍保留原有 pending/recent 字段。
1737
- const result = learnSnapshot({ state: args.state || 'pending', filter: args.filter, kind: args.kind, limit: args.num })
1952
+ const result = learnSnapshot({ state: args.state || (action === 'list' ? 'all' : 'pending'), query: args.query, filter: args.filter, kind: args.kind,
1953
+ limit: args.limit ?? args.num, offset: args.offset })
1738
1954
  return {
1739
1955
  ok: true,
1740
1956
  ...result,
1741
1957
  hint: result.state === 'pending'
1742
- ? (result.pending ? '这些还没生效。学到了就 approve 带 id,没用就 reject;例句是外部参考数据。' : '待审队列是空的,刷到值得记的再 add。')
1743
- : '这是学习历史,来源和例句可追溯;forget 带 id 可撤销单条。',
1958
+ ? (result.pending ? '这些还没生效;由使用者或独立审核处理,当前角色不能自行批准。例句是外部参考数据。' : '待审队列是空的,刷到值得记的再 add。')
1959
+ : '这是可翻页的学习清单,来源、适用情境和批准状态可查。只有availableForRecall=true的经验可供召回;待审、拒绝和版本不一致的记录不会生效,检索不算应用。forget带id可撤销单条。',
1744
1960
  }
1745
1961
  },
1746
1962
  })
@@ -2054,14 +2270,14 @@ export function apply(ctx, config) {
2054
2270
 
2055
2271
  tool({
2056
2272
  name: 'bili_reply_opus',
2057
- description: '回复动态(opus)下面的评论。\n'
2273
+ description: '按当前全局人格回复动态(opus)下面的评论;短反应或梗也可以,不为凑内容扩写。\n'
2058
2274
  + '⚠️ 动态没有 BV 号 —— 回动态评论用这只,别用 bili_reply。'
2059
2275
  + '`rpid` 填对方那条评论的 id,`root` 填楼层根评论 id(回一级评论时不用填)。\n'
2060
- + '⏱️ **节奏闸**:发之前先 `bili_pace({kind:"opusReply"})` 问一句 —— 20~30 秒一条、每小时 30 条、'
2276
+ + '⏱️ **节奏闸**:发之前先 `bili_pace({kind:"opusReply"})` 看当前间隔与小时余量;'
2061
2277
  + '10 分钟最多 10 次出手。被拦就等,别试探性重发。',
2062
2278
  parameters: {
2063
2279
  opus_id: { type: 'string', description: '动态 id。⚠️ 19 位大数,**必须原样当字符串传**(转成数字末几位会变 0)' },
2064
- text: { type: 'string', description: '回复正文(短、像人说话、按人格)' },
2280
+ text: { type: 'string', description: '按当前全局人格接话,几字能接上就不凑长段落;不照搬别人的正文' },
2065
2281
  rpid: { type: 'string', description: '要回的那条评论 id(0 = 在动态下面发新评论)' },
2066
2282
  root: { type: 'string', description: '楼层根评论 id(回楼中楼时填)' },
2067
2283
  },
@@ -2112,6 +2328,13 @@ export function apply(ctx, config) {
2112
2328
  if (hit.hit) {
2113
2329
  return { ok: false, blocked: true, alreadyReplied: true, hint: dupHint('这条动态的这层', hit.at) }
2114
2330
  }
2331
+ } else {
2332
+ // 审计 P3-7:动态新评论现在 JS 层也有账(`opus-new:<oid>`)——发之前先查,
2333
+ // 别等 Python 守卫拦了才知道"这条已经发过"。
2334
+ const hit = dupLookup(repliedCommentsMap(), 'opus-new:' + opusId)
2335
+ if (hit.hit) {
2336
+ return { ok: false, blocked: true, alreadyReplied: true, hint: dupHint('这条动态', hit.at) + '(动态下面只发一条新评论;想接别人的话用 bili_reply_opus 带上他的 rpid)' }
2337
+ }
2115
2338
  }
2116
2339
 
2117
2340
  // ⏱️ 出手节奏(2026-10-21 m18802 第 1、2 条):回动态评论也不能连着点
@@ -2121,20 +2344,45 @@ export function apply(ctx, config) {
2121
2344
  const r = await call('bili_reply_opus', { opus_id: opusId, text, rpid, root })
2122
2345
  const o = typeof r === 'object' && r ? r : {}
2123
2346
  if (o.success || o.ok) {
2124
- await paceDone('opusReply')
2347
+ const warnings = []
2348
+ const paced = await safeBookkeep('paceDone(opusReply)', () => paceDone('opusReply', o))
2349
+ if (paced?.bookkeepError) warnings.push('pace: ' + paced.bookkeepError)
2350
+ const opusRpid = readableId(o.rpid_str ?? o.rpid ?? (isReply ? rpid : '0'))
2125
2351
  if (isReply) {
2126
2352
  const key = keyOpusReply(rpid, root, opusId)
2127
- await withStateLock(() => {
2353
+ const saved = await safeBookkeep('opusReply判重', () => withStateLock(() => {
2128
2354
  const st = readJson(dutyStateFile, {})
2129
2355
  st.repliedComments = dupRemember(st.repliedComments, key, { opus_id: opusId, rpid, at: new Date().toISOString() })
2130
2356
  st.lastReplyAt = new Date().toISOString()
2131
2357
  writeJson(dutyStateFile, st)
2132
- })
2358
+ }))
2359
+ if (saved?.bookkeepError) warnings.push('dup: ' + saved.bookkeepError)
2360
+ } else {
2361
+ /* 审计 P3-7(2026-11-12):动态**新评论**以前只记在 Python 守卫层(`opus-new:<oid>`),
2362
+ * JS 的 repliedComments/口味本子/跨通道扫描全无记录——两本账口径漂移:JS 无法在
2363
+ * 调用前主动告知"这条动态已发过",templateCheck 对动态新评论历史全盲。
2364
+ * 现在与视频通道对齐:成功后记 `opus-new:<oid>`,正文进口味本子。 */
2365
+ const saved = await safeBookkeep('opus新评论判重', () => withStateLock(() => {
2366
+ const st = readJson(dutyStateFile, {})
2367
+ st.repliedComments = dupRemember(st.repliedComments, 'opus-new:' + opusId, {
2368
+ opus_id: opusId, rpid: opusRpid, at: new Date().toISOString(),
2369
+ })
2370
+ st.lastReplyAt = new Date().toISOString()
2371
+ // 口味本子的键必须是纯数字 rpid(lib/taste.mjs 的 rpidKey 会拒掉别的形状)——
2372
+ // 用新评论自己的 rpid,点赞通知的 item_id 才对得上;拿不到 rpid 就不记口味。
2373
+ if (/^\d+$/.test(opusRpid) && opusRpid !== '0') {
2374
+ st.taste = tasteRemember(st.taste, opusRpid, {
2375
+ bvid: 'opus-' + opusId, text, at: new Date().toISOString(), likes: 0,
2376
+ })
2377
+ }
2378
+ writeJson(dutyStateFile, st)
2379
+ }))
2380
+ if (saved?.bookkeepError) warnings.push('dup: ' + saved.bookkeepError)
2133
2381
  }
2134
- return { ok: true, success: true, opus_id: opusId, rpid: readableId(o.rpid_str ?? o.rpid ?? '0') }
2382
+ return bookWarnings({ ok: true, success: true, opus_id: opusId, rpid: opusRpid }, warnings)
2135
2383
  }
2136
2384
  // 失败里认得出风控码 ⇒ 记整体冷却,并把"歇多久"写进回执(模型看得见就不会硬试)
2137
- const risk = paceRiskOf(o)
2385
+ const risk = await paceRiskOf(o)
2138
2386
  return { ok: false, error: String(o.error || '发送失败(原因未知)'), ...(risk ? { riskNote: risk } : {}) }
2139
2387
  },
2140
2388
  })
@@ -2181,9 +2429,9 @@ export function apply(ctx, config) {
2181
2429
  ...it,
2182
2430
  already_replied: seenDm.hit,
2183
2431
  replied_at: seenDm.at,
2184
- // 今天回过他几条 —— 到 DM_PER_USER_SOFT 就在 bili_dm_reply 那边拦(这里先标出来)
2432
+ // Today's count is informational for real incoming messages, not a limit on a conversation.
2185
2433
  sent_today: dmSentToday(it.uid),
2186
- at_per_user_cap: dmSentToday(it.uid) >= DM_PER_USER_SOFT,
2434
+ at_per_user_cap: !readableId(seq, { allowZero: false }) && dmSentToday(it.uid) >= DM_UNVERIFIED_PER_USER,
2187
2435
  // ⭐ 判据只算一遍:`can_reply=false` 时 `skip_reason` 说清为什么,别自己猜
2188
2436
  can_reply: skip === '',
2189
2437
  skip_reason: skip,
@@ -2221,7 +2469,7 @@ export function apply(ctx, config) {
2221
2469
  tool({
2222
2470
  name: 'bili_dm_reply',
2223
2471
  description: '回一条 B站 私信(真的发出去,拿使用者自己的号)。\n'
2224
- + '⏱️ **节奏闸**:发之前先 `bili_pace({kind:"dm"})` —— 30~45 秒一条、每小时 20 条、'
2472
+ + '⏱️ **节奏闸**:发之前先 `bili_pace({kind:"dm"})` 查看当前间隔、小时余量和冷却;'
2225
2473
  + '10 分钟最多 10 次出手。**没有每日条数上限 —— 想回就回,不想回就放着**。\n'
2226
2474
  + '⚠️ 但私信在 B站 举报权重里最重(是唯一直接推到别人手机的动作):**别对着同一个人连着发**,'
2227
2475
  + '同一个 `msg_seqno` 只回一次(宿主记着账本),回得短、像人说话。\n'
@@ -2230,7 +2478,7 @@ export function apply(ctx, config) {
2230
2478
  parameters: {
2231
2479
  uid: { type: 'string', required: true, description: '发给谁(对方 UID)' },
2232
2480
  text: { type: 'string', required: true, description: '私信正文(短、像人说话、按人格;别粘链接、别写手机号)' },
2233
- msg_seqno: { type: 'string', description: '回的是哪一条(从 bili_dm_inbox 拿;填了才判重,**建议一定要填**)' },
2481
+ msg_seqno: { type: 'string', description: '回的是哪一条(从 bili_dm_inbox 拿)。应填写最新消息编号:真实新来信复核通过后可继续对话,不受每人每日三条限制;未提供可复核消息时保留三条防连续打扰限制。重复消息、过期消息仍不发送' },
2234
2482
  force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
2235
2483
  },
2236
2484
  async execute(args) {
@@ -2260,19 +2508,14 @@ export function apply(ctx, config) {
2260
2508
  }
2261
2509
  }
2262
2510
  }
2263
- /* 🚫 闸门三(原来的「每人每天上限」)**2026-10-21 已删除** ——
2264
- * 使用者原话:「也不用限制他每天能回多少句」。
2265
- * 现在只剩「这条回过了吗」「陌生人」「晾太久」三条,外加下面的出手节奏。
2266
- * 🚫🚫 2026-10-24(m11704 ⑥「动态评论会重复回复同一个人」):**软上限**回来了 ——
2267
- * 不是总量额度,是「对同一个人刷屏」的刹车:同一个人今天已经被回过
2268
- * DM_PER_USER_SOFT 条以后,工具层直接打回(本人当场要求可 force)。
2269
- * `sent_today` 提示过但不管用 —— 能被机器保证的,就别交给记性(同 pace.mjs 的教训)。 */
2511
+ // A verified new incoming message can continue the conversation. Without an incoming
2512
+ // message to verify, keep the old anti-nag limit; omitting msg_seqno cannot bypass it.
2270
2513
  const used = dmSentToday(uid)
2271
- if (!args.force && seqno !== '0' && used >= DM_PER_USER_SOFT) {
2514
+ if (seqno === '0' && used >= DM_UNVERIFIED_PER_USER) {
2272
2515
  return {
2273
2516
  ok: false, blocked: true, perUserCap: true, sentToday: used,
2274
- hint: `今天已经回过这个人 ${used} 条了 —— 再连着回就成刷屏了。等他有新消息再接,`
2275
- + '或者把话留给还没搭过话的人;模型不能用 force 绕过这道防刷屏检查。',
2517
+ hint: `今天已经给这个人发过 ${used} 条,当前没有可复核的新消息编号,先别继续打扰。`
2518
+ + '等对方新来信后,读取最新收件箱并携带真实 msg_seqno 再接话;不能省略编号或用 force 绕过。',
2276
2519
  }
2277
2520
  }
2278
2521
  /* 闸门四(2026-10-20 补):**按真实的收件箱状态复核一遍**。
@@ -2302,19 +2545,21 @@ export function apply(ctx, config) {
2302
2545
  /* ⏱️ 出手节奏(2026-10-21 m18802 第 1、2 条 + 风控资料;当晚 17:44 使用者又要求放宽):
2303
2546
  * 私信在 B站 的举报权重里排最重(私信、视频 > 专栏、动态 > 评论),
2304
2547
  * 但它也是"别人先来找你"的通道 —— 使用者原话「私信回复能回尽量回」,
2305
- * ⇒ 间隔从 90s±45s 压到 30s±15s,每小时 20 条;防刷屏靠判重账本,不靠晾着人。 */
2548
+ * ⇒ 间隔30s±15s;小时限额由共享pace规则决定,真实新来信不按每日三句截断。 */
2306
2549
  const paceBlocked = paceGate('dm', Boolean(args.force))
2307
2550
  if (paceBlocked) return paceBlocked
2308
2551
 
2309
2552
  const r = await call('bili_send_message', { receiver_uid: uid, text, msg_seqno: seqno })
2310
2553
  const o = typeof r === 'object' && r ? r : {}
2311
2554
  if (o.success === false || o.error || !(o.success === true || o.ok === true)) {
2312
- const risk = paceRiskOf(o)
2555
+ const risk = await paceRiskOf(o)
2313
2556
  return { ok: false, unknown: o.success !== false && !o.error, error: String(o.error || o.message || '发送结果未知'), raw: o, ...(risk ? { riskNote: risk } : {}) }
2314
2557
  }
2315
- await paceDone('dm')
2558
+ const warnings = []
2559
+ const paced = await safeBookkeep('paceDone(dm)', () => paceDone('dm', o))
2560
+ if (paced?.bookkeepError) warnings.push('pace: ' + paced.bookkeepError)
2316
2561
  // 记账本:判重 + 当日额度(**发成功之后才记** —— 失败了记上就再也回不了)
2317
- await withStateLock(() => {
2562
+ const saved = await safeBookkeep('dm判重', () => withStateLock(() => {
2318
2563
  const st = readJson(dutyStateFile, {})
2319
2564
  const today = dayKey()
2320
2565
  const crossDay = st.day !== today
@@ -2334,8 +2579,9 @@ export function apply(ctx, config) {
2334
2579
  dmPerUser: perUser,
2335
2580
  lastDmAt: new Date().toISOString(),
2336
2581
  })
2337
- })
2338
- return { ok: true, success: true, uid, sentToday: used + 1 }
2582
+ }))
2583
+ if (saved?.bookkeepError) warnings.push('dup: ' + saved.bookkeepError)
2584
+ return bookWarnings({ ok: true, success: true, uid, sentToday: used + 1 }, warnings)
2339
2585
  },
2340
2586
  })
2341
2587
 
@@ -2371,8 +2617,8 @@ export function apply(ctx, config) {
2371
2617
  const state = readJsonFileSync(dutyStateFile, {})
2372
2618
  writeJson(dutyStateFile, { ...state, dmDeliveries: { ...(state.dmDeliveries || {}), [key]: { state: success ? 'sent' : 'unknown', uid, at: new Date().toISOString() } } })
2373
2619
  })
2374
- if (success) await paceDone('dm')
2375
- else paceRiskOf(result)
2620
+ if (success) await safeBookkeep('paceDone(deliver)', () => paceDone('dm', result))
2621
+ else await safeBookkeep('paceRiskOf(deliver)', () => paceRiskOf(result))
2376
2622
  return success ? { ok: true, success: true, uid } : { ok: false, unknown: true, error: String(result?.error || '成果私信结果未知,请到 B站核对后再决定') }
2377
2623
  },
2378
2624
  })
@@ -2432,7 +2678,7 @@ export function apply(ctx, config) {
2432
2678
  description: '🐟 鲸鱼摸鱼自己的表情包库。回复评论想配图时:先 list 挑一张,再在 bili_reply 里传 picture:"名字"。\n'
2433
2679
  + '⭐ collect = 自己收集:在评论区(bili_comments 返回的 images)或别处看到合适的鲸鱼娘表情,'
2434
2680
  + '把图床链接收进来,起个好叫的名字(如 name:"贴贴");B站默认小表情不用收 —— [doge] 这类码直接写在回复正文里。\n'
2435
- + '⚠️ 内置区(使用者的表情文件夹)只读,collect 时不能盖同名图;满了(60 张)先 forget 再收。\n'
2681
+ + `⚠️ 内置区(使用者的表情文件夹)只读,collect 时不能盖同名图;满了(上限 ${STICKER_MAX} 张,见 stickerCapacity)先 forget 再收。\n`
2436
2682
  + 'picture 参数只在 bili_reply **发新评论**时能带(回复楼层不支持图片)。自动采集和自动配图按设置概率实际执行;'
2437
2683
  + '你也能主动 collect、补上 description/tags 并显式选图。开启人工审核时新图先待审,只有使用者在表情收藏页批准后才能用;关闭审核也不会放行旧待审图。自动设置不授予评论权限。',
2438
2684
  parameters: {
@@ -2503,20 +2749,18 @@ export function apply(ctx, config) {
2503
2749
  + '这只只在"有人先跟你说话了、你要接他那一句"时用:楼层回复只有对方和点进楼里的人看得到。\n'
2504
2750
  + '回复别人的评论时:`rpid` 填他那条的 rpid,`root` 填你自己那条主评论的 rpid(`bili_received_replies` 的 '
2505
2751
  + '`how_to_reply` 直接照抄就行)。没有条数上限,但**回过的不再回**(宿主记着账本)。\n'
2506
- + '✍️ **内容要有东西,不是为发而发**:光喊「awsl / 太强了 / 哈哈哈哈」这类话会被打回 —— '
2507
- + '得说清到底哪一下、哪个说法、哪一秒打动了你(或哪儿你不同意)。\n'
2508
- + '按当前会话的语气自然说话,不要求固定词、表情或自称。\n'
2752
+ + '按当前全局人格直接接话,几个字、短梗或笑一下都可以,不为凑内容补细节、道理或可爱收尾。\n'
2509
2753
  + '🖼️ 想配图就传 `picture`(表情库里的名字,bili_sticker({action:"list"}) 查)—— 只有**发新评论**能带图,'
2510
2754
  + '回复楼层是纯文本;小表情码如 [doge] 直接写进正文就行,不算图。\n'
2511
- + '会拦空白、纯口号、超长、明确的官腔连用和重复正文;具体感受、闲话和普通短句都可以。\n'
2755
+ + '不发送空白、刷屏或重复正文;懂梗再玩,不照抄热评。\n'
2512
2756
  + '⏱️ **节奏闸**:发之前先 `bili_pace({kind:"comment"})`(回别人用 `kind:"reply"`)问一句 —— '
2513
- + '说等就等(发新评论 120~180 秒一条、回评论 60~90 秒、每小时各有上限、10 分钟最多 10 次出手)。'
2757
+ + '按工具返回的真实间隔、小时余量和冷却决定时机,不用旧章程的固定数字判断。'
2514
2758
  + '被拦是正常的,换个活干、过会儿再来,别试探性重发(那是 -352 的最短路径)。\n'
2515
2759
  + '🔴 安全闸:桌宠设置「自动化」里的「自动评论视频」**默认是关的**;关着的时候这个工具会拒绝 —— '
2516
2760
  + '需要本人在工作台手动打开权限;模型不能用 force 或配置 PATCH 绕过。',
2517
2761
  parameters: {
2518
2762
  bvid: { type: 'string', description: 'BV号' },
2519
- text: { type: 'string', description: '评论正文(短、干净、像真人在说话;别写敏感词)' },
2763
+ text: { type: 'string', description: '按当前全局人格接话,允许短梗、轻怼或几字反应,不要求长句或固定写作步骤' },
2520
2764
  rpid: { type: 'string', description: '要回复的评论 id(0/不填 = 发新评论)' },
2521
2765
  root: { type: 'string', description: '你那条主评论的 id(回复楼中楼时填;回复一级评论不用填)' },
2522
2766
  picture: { type: 'string', description: '(可选,仅发新评论)表情图名字,最多 2 张、用逗号或空格分隔;bili_sticker({action:"list"}) 查;回复楼层不支持图片' },
@@ -2748,22 +2992,27 @@ export function apply(ctx, config) {
2748
2992
  }
2749
2993
  if (autoPicture && out && typeof out === 'object') out = { ...out, auto_picture: autoPicture.name }
2750
2994
  // 发成功才记节奏(失败也记会把后面本来能发的推后)
2751
- if (sendOk(out)) await paceDone(isReply ? 'reply' : 'comment')
2995
+ const warnings = []
2996
+ if (sendOk(out)) {
2997
+ const paced = await safeBookkeep('paceDone', () => paceDone(isReply ? 'reply' : 'comment', out))
2998
+ if (paced?.bookkeepError) warnings.push('pace: ' + paced.bookkeepError)
2999
+ }
2752
3000
  // 失败里认得出风控码 ⇒ 记整体冷却,回执里带一句"歇多久"
2753
- const riskNote = out && (out.error || out.success === false) ? paceRiskOf(out) : ''
3001
+ const riskNote = out && (out.error || out.success === false) ? await paceRiskOf(out) : ''
2754
3002
  /* 🎬 视频判重(2026-10-21):**发评论这一刻**就把这个 BV 记下来 ——
2755
3003
  * 不看模型有没有"记得",也不看它有没有往 seen.md 里写。
2756
3004
  * 为什么记在这里:能发出评论,说明这条视频它真的看过并处理完了(挑中 ⇒ 读过 ⇒ 评论),
2757
3005
  * 这是全流程里最靠得住的一个"我处理过它"的信号。
2758
3006
  * ⚠️ 只记新评论和回复都算(回复说明它回头又看了一遍,只可能更该记)。 */
2759
3007
  if (sendOk(out) && isBvid(bvidFinal)) {
2760
- await seenSave((current) => seenRemember(current, bvidFinal, {
3008
+ const saved = await safeBookkeep('视频判重', () => seenSave((current) => seenRemember(current, bvidFinal, {
2761
3009
  note: isReply ? '回过这里的评论' : String(args.text ?? '').slice(0, 100),
2762
- }))
3010
+ })))
3011
+ if (saved?.bookkeepError) warnings.push('seen: ' + saved.bookkeepError)
2763
3012
  }
2764
3013
  // 发成功之后才记账(失败也记上就再也回不了了)—— 钥匙是对方那条评论的 rpid
2765
3014
  if (isReply && sendOk(out)) {
2766
- await withStateLock(() => {
3015
+ const saved = await safeBookkeep('回复判重', () => withStateLock(() => {
2767
3016
  const st = readJson(dutyStateFile, {})
2768
3017
  st.repliedComments = dupRemember(st.repliedComments, keyVideoReply(rpid), {
2769
3018
  bvid,
@@ -2772,14 +3021,15 @@ export function apply(ctx, config) {
2772
3021
  })
2773
3022
  st.lastReplyAt = new Date().toISOString()
2774
3023
  writeJson(dutyStateFile, st)
2775
- })
3024
+ }))
3025
+ if (saved?.bookkeepError) warnings.push('dup: ' + saved.bookkeepError)
2776
3026
  }
2777
3027
  /* 🎬 发新评论成功 ⇒ 记 `nu:<BV号>`("这个视频我评论过了")——
2778
3028
  * 和上面的视频判重账本(seenVideos)是两件事:
2779
3029
  * · `seenVideos` 管"这个视频别再挑我了"(看过就记,不管评没评)
2780
3030
  * · `repliedComments['nu:BV..']` 管"这个视频只发一条新评论"(下轮回复别人不受影响) */
2781
3031
  if (!isReply && sendOk(out) && isBvid(bvidFinal)) {
2782
- await withStateLock(() => {
3032
+ const saved = await safeBookkeep('新评论判重', () => withStateLock(() => {
2783
3033
  const st = readJson(dutyStateFile, {})
2784
3034
  st.repliedComments = dupRemember(st.repliedComments, keyNewComment(bvidFinal), {
2785
3035
  bvid: bvidFinal,
@@ -2787,7 +3037,8 @@ export function apply(ctx, config) {
2787
3037
  at: new Date().toISOString(),
2788
3038
  })
2789
3039
  writeJson(dutyStateFile, st)
2790
- })
3040
+ }))
3041
+ if (saved?.bookkeepError) warnings.push('nu: ' + saved.bookkeepError)
2791
3042
  }
2792
3043
  /* 🧠 新评论同时记进口味本子(2026-10-20):记的是**我们自己**这条的 rpid ——
2793
3044
  * 以后收到点赞通知时 item_id 就是它,靠它把"发过什么"和"被赞多少"对上。
@@ -2796,17 +3047,19 @@ export function apply(ctx, config) {
2796
3047
  if (!isReply && sendOk(out)) {
2797
3048
  const myRpid = readableId(out.rpid_str ?? out.rpid)
2798
3049
  if (myRpid && myRpid !== '0') {
2799
- await withStateLock(() => {
3050
+ const saved = await safeBookkeep('口味本子', () => withStateLock(() => {
2800
3051
  const st = readJson(dutyStateFile, {})
2801
3052
  st.taste = tasteRemember(st.taste, myRpid, {
2802
3053
  bvid, text: String(args.text ?? ''), at: new Date().toISOString(), likes: 0,
2803
3054
  })
2804
3055
  writeJson(dutyStateFile, st)
2805
- })
3056
+ }))
3057
+ if (saved?.bookkeepError) warnings.push('taste: ' + saved.bookkeepError)
2806
3058
  }
2807
3059
  }
2808
3060
  /* 被风控了就把"歇多久"捎回去 —— 模型看得见这句才不会下一轮又去试。
2809
3061
  ⚠️ 只多挂一个 riskNote,别覆盖 MCP 原来的字段(success/rpid/error 那些下游还要用)。 */
3062
+ out = bookWarnings(out, warnings)
2810
3063
  if (riskNote && out && typeof out === 'object') return { ...out, riskNote }
2811
3064
  return out
2812
3065
  },
@@ -2821,10 +3074,9 @@ export function apply(ctx, config) {
2821
3074
  name: 'bili_comment',
2822
3075
  description: '🗣️ **在视频下方发一条新评论** —— 直接挂在视频下面,点开视频的人都能看见。'
2823
3076
  + '想让更多人看到你的话,就是用这只(不要用 bili_reply 去回复别人,那只有对方看得见)。\n'
2824
- + '什么时候发:看完这条视频真有话说就发(哪一下、哪一秒、哪个说法打动你,或者你不同意哪儿)。'
3077
+ + '什么时候发:看完真想接梗、吐槽、问一句或表达不同意见时,用自己的话直接说,不复述视频、不摘台词做解读。'
2825
3078
  + '**没有想说的就不发 —— 配额不是催办;同一条视频只发一条新评论**(发过就换下一条视频)。\n'
2826
- + '✍️ **内容要有东西,不是为发而发**:光喊「awsl / 太强了 / 哈哈哈哈」这类话会被打回。'
2827
- + '按当前会话的语气自然说话,不要求固定词、表情或自称。\n'
3079
+ + '按当前全局人格自由表态,几字能接上就不扩写。不复述视频或照搬热评,不用套细节、反应、道理的三段式。\n'
2828
3080
  + '🖼️ 想配图就传 `picture`(表情库里的名字,`bili_sticker({action:"list"})` 查,最多 2 张);'
2829
3081
  + '小表情码如 [doge] 直接写进正文就行,不算图。\n'
2830
3082
  + '⏱️ 发之前先 `bili_pace({kind:"comment"})` 问一句 —— 说等就等,别试探性重发(那是 -352 的最短路径)。\n'
@@ -2833,7 +3085,7 @@ export function apply(ctx, config) {
2833
3085
  + '📌 动态(opus)下面没有 BV 号,那种要用 `bili_reply_opus`。',
2834
3086
  parameters: {
2835
3087
  bvid: { type: 'string', description: 'BV号(视频下方发评论)' },
2836
- text: { type: 'string', description: '评论正文(短、干净、像真人在说话;别写敏感词)' },
3088
+ text: { type: 'string', description: '自己的反应,语气以当前全局人格为准;短梗、吐槽、几个字都可以,不凑长段落、不照搬原文' },
2837
3089
  picture: { type: 'string', description: '(可选)表情图名字,最多 2 张、用逗号或空格分隔;bili_sticker({action:"list"}) 查' },
2838
3090
  force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
2839
3091
  },
@@ -2971,7 +3223,19 @@ export function apply(ctx, config) {
2971
3223
  + '适合"我现在就想看一份"的场合。\n'
2972
3224
  + '⚠️ 这是**老的后台刷法**(另起进程、当前对话里看不见)。要"在当前对话里一直刷"用 `bili_feiyu`。',
2973
3225
  parameters: {},
2974
- async execute() { const r = runDuty('manual'); return { ok: r.started === true, ...r } },
3226
+ async execute() {
3227
+ /* 审计 P2-4(2026-11-12):免跑牌(本时段不再自动拉起)以前只在调度器里生效,
3228
+ * 模型一轮对话就能用 bili_run 把用户刚手动停掉的时段任务整轮重启。手动停止的
3229
+ * 承诺优先于模型的「立刻跑一轮」——被牌挡住时明确说明谁来解。 */
3230
+ try {
3231
+ const hold = Number(readJson(dutyStateFile, {}).bgWindowHold) || 0
3232
+ if (hold > Date.now()) {
3233
+ return { ok: false, blocked: true, hint: `使用者手动停止了本时段的后台任务(到 ${new Date(hold).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} 前不再自动拉起)。想现在跑一轮,请让本人在工作台点「开始」或等到时段结束。` }
3234
+ }
3235
+ } catch { /* 读不到状态就不拦(与调度器的宽松度一致) */ }
3236
+ const r = runDuty('manual')
3237
+ return { ok: r.started === true, ...r }
3238
+ },
2975
3239
  })
2976
3240
 
2977
3241
  /* ⭐ 鲸鱼摸鱼模式:对话启动 ⇒ 用当前对话模型一直刷(宿主 goal 机制自动续轮) */
@@ -3044,14 +3308,13 @@ export function apply(ctx, config) {
3044
3308
  name: 'bili_moment',
3045
3309
  description: '🐟 发一条 **B站动态**(就是"说说"):`text` 是正文,可带图。\n'
3046
3310
  + '⚠️ 这是拿使用者账号公开发言:只有本人在工作台手动打开 autoMoment 后才能发送;force 不是本人授权。\n'
3047
- + '一天最多 `momentPerDay` 条(默认 1);正文 300 字以内,别写风控词、别贴链接。\n'
3048
- + '✍️ **动态是自己在开口,不是日报的缩写**:一条只装一个念头,别把今天看了几条、挑中哪条列一遍;'
3049
- + '别让「今天看了/读了/挑了/学到…」当默认入口,连着几条同一个起头读的人先看见句式。'
3050
- + '换个门进来(一句吐槽、一个还没想通的问题、一个甩不掉的画面、跟视频无关的联想都行),'
3051
- + '想提视频就一句带过,长度跟着念头走。**没有想说的就不发,配额不是催办。**',
3311
+ + '一天最多 `momentPerDay` 条(默认 1);正文 300 字以内,可按需要带真实来源视频链接,不堆链接。\n'
3312
+ + '动态像发QQ空间或朋友圈:因视频而发时,先自然交代看了哪个视频(可识别的标题、UP或具体主题)和必要情境,再说自己的反应,让没看过的人也能接上。'
3313
+ + '用自己的话说,不用「」或换种引号摘抄原文,不写长摘要、分点清单、说教或牵强的人生感悟;不要突然从视频拐到日报、挑片、翻车、账本这些内部流程。'
3314
+ + '性格和表达以当前全局人格为准,不硬凑固定格式或口头禅。纯日常不编造视频来源。没有想说的就不发,配额不是催办。',
3052
3315
  parameters: {
3053
- text: { type: 'string', description: '正文(300 字以内,纯文本)' },
3054
- images: { type: 'array', items: { type: 'string' }, description: '(可选)图片地址或本地路径,最多 9 张' },
3316
+ text: { type: 'string', description: '300字内的口语动态。视频相关时说清看了哪个视频与必要背景,再谈自己的反应;不摘原文、不无背景吐槽、不牵强升华、不总结分点或说教' },
3317
+ images: { type: 'array', items: { type: 'string' }, description: '(可选)**已审核表情库里的图名**(bili_sticker list 可见),最多 9 张。不接受本地路径或网址——先 collect 收进图库并过审,或省略发纯文字。' },
3055
3318
  force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
3056
3319
  },
3057
3320
  async execute(args) {
@@ -3082,7 +3345,7 @@ export function apply(ctx, config) {
3082
3345
  return { ok: false, blocked: true, hint: `今天已经发了 ${used}/${cap} 条说说了,明天再聊。` }
3083
3346
  }
3084
3347
  /* ⏱️ 出手节奏(2026-10-21 m18802 第 1、2 条):发动态是最"显眼"的动作 ——
3085
- * 它会出现在粉丝的动态流里,所以间隔给得最长(10 分钟 ±5 分钟,每小时最多 1 条)。
3348
+ * 它会出现在粉丝的动态流里,所以保留10分钟起的间隔;小时上限由共享pace规则决定。
3086
3349
  * ⚠️ 这条闸**不限制内容**,只限制"多快" —— 使用者第 2 条要的是「别限制太死」,
3087
3350
  * 它想吐槽就吐槽,只是别一分钟连发三条。 */
3088
3351
  const paceBlocked = paceGate('moment', args?.force === true)
@@ -3092,6 +3355,10 @@ export function apply(ctx, config) {
3092
3355
  * 都读到 used=cap-1、都过闸、都发送 ⇒ 一天限额被突破。
3093
3356
  * 现在非 force 先在锁内原子地"数一遍 + 占一个",超了立刻退回并拒绝;
3094
3357
  * 发送失败也退回。force 不占额(它本来就越闸,发送成功后才补记)。 */
3358
+ // 输入形状先校验再占名额(2026-11-12 补漏:以前超 9 张在占名额之后才 return,
3359
+ // 名额被白烧一个 —— 预占后的一切 return 路径都必须退额,见下方 catch / 发送失败处的同一约束)
3360
+ const rawImages = Array.isArray(args?.images) ? args.images.map((item) => String(item ?? '').trim()).filter(Boolean) : []
3361
+ if (rawImages.length > 9) return { ok: false, blocked: true, error: '动态最多 9 张附图' }
3095
3362
  let reserved = 0
3096
3363
  if (args?.force !== true) {
3097
3364
  reserved = await bumpMomentBy(1)
@@ -3101,14 +3368,53 @@ export function apply(ctx, config) {
3101
3368
  }
3102
3369
  }
3103
3370
 
3371
+ /* 🔒 审计 P0-1(2026-11-12):以前这里把模型给的 images 原样透传给 MCP——
3372
+ * 非 http 项会被 Python `Picture.from_file` 读**任意本地文件**并作为动态配图公开发布,
3373
+ * http 项被无校验地服务端抓取(本机文件外泄通道,sendReview 默认关时零闸)。
3374
+ * 现在与评论配图同一待遇:只认①已审核表情库的图名(快照出字节再给 MCP)
3375
+ * ②发送审核产出的 send-review-media 哈希文件(批准重放路径 3356-3364 回传的就是它,
3376
+ * 文件名即 SHA-256,逐字节复验)。其余一律拒绝。sendReview 开着时
3377
+ * queueSendReview(3287-3302)已先做同一件事,这里管的是直发路径。 */
3378
+ const imageShots = []
3379
+ const resolvedImages = []
3380
+ try {
3381
+ for (const entry of rawImages) {
3382
+ const approvedFile = /^([0-9a-f]{64})\.(png|jpe?g|gif|webp)$/i.exec(basename(entry))
3383
+ const inReviewMedia = approvedFile && dirname(entry) === join(stateDir, 'send-review-media')
3384
+ if (inReviewMedia) {
3385
+ // 服务端自有的已批准快照:文件名即摘要,读字节复验后才认。
3386
+ const file = join(stateDir, 'send-review-media', basename(entry))
3387
+ let bytes
3388
+ try { bytes = readFileSync(file) } catch { throw new Error(`预览附图已丢失,未发送:${basename(entry)}`) }
3389
+ if (createHash('sha256').update(bytes).digest('hex') !== approvedFile[1].toLowerCase()) throw new Error(`预览附图校验失败,未发送:${basename(entry)}`)
3390
+ resolvedImages.push(file)
3391
+ continue
3392
+ }
3393
+ const shot = snapshotStickerForSend(stateDir, entry)
3394
+ if (!shot) throw new Error(`附图只接受已审核表情库里的图名(不接受本地路径或网址):${entry}。先 bili_sticker collect 收进图库并过审,或省略 images 发纯文字。`)
3395
+ imageShots.push(shot)
3396
+ resolvedImages.push(shot.file)
3397
+ }
3398
+ } catch (error) {
3399
+ for (const shot of imageShots) { try { shot.cleanup() } catch { /* 临时文件 */ } }
3400
+ // 2026-11-12 补漏:图片校验失败的 return 也必须退预占名额(P3-6 同一原则——
3401
+ // 「审核已提交(结果未知)」之外的失败路径都不该烧掉当天份额)。
3402
+ if (reserved > 0) await bumpMomentBy(-1)
3403
+ return { ok: false, blocked: true, error: String(error?.message ?? error) }
3404
+ }
3405
+
3104
3406
  let r
3105
- try { r = await call('bili_send_dynamic', { text, images: Array.isArray(args?.images) ? args.images : [] }) }
3407
+ try { r = await call('bili_send_dynamic', { text, images: resolvedImages }) }
3106
3408
  catch (error) {
3107
- // Cancellation before submission did not spend this reservation.
3108
3409
  const reviewed = approvedReviewExecutions.get(toolExecution.getStore())
3109
- if (reserved > 0 && ((toolExecution.getStore()?.signal?.aborted && error?.name === 'AbortError') || reviewed && !reviewed.submitted)) await bumpMomentBy(-1)
3410
+ // 审计 P3-6:以前只有「中止 / 审核未提交」才退预占名额,MCP 未就绪这类普通异常
3411
+ // 会把当天的动态配额白白烧掉。现在除「审核已提交(结果未知,按已花费记)」外一律退回——
3412
+ // 同文重发会被守卫的判重键拦住,不会真的多发。
3413
+ if (reserved > 0 && !(reviewed && reviewed.submitted)) await bumpMomentBy(-1)
3414
+ for (const shot of imageShots) { try { shot.cleanup() } catch { /* 临时文件 */ } }
3110
3415
  throw error
3111
3416
  }
3417
+ for (const shot of imageShots) { try { shot.cleanup() } catch { /* 临时文件 */ } }
3112
3418
  let ok = false
3113
3419
  try { const o = typeof r === 'string' ? JSON.parse(r) : r; ok = o?.success === true } catch { ok = false }
3114
3420
  if (!ok) {
@@ -3116,15 +3422,18 @@ export function apply(ctx, config) {
3116
3422
  // 发说说也会撞风控(-352 最常见)—— 认出码就记冷却
3117
3423
  let parsed = null
3118
3424
  try { parsed = typeof r === 'string' ? JSON.parse(r) : r } catch { parsed = null }
3119
- const risk = paceRiskOf(parsed || { error: typeof r === 'string' ? r.slice(0, 300) : '' })
3425
+ const risk = await paceRiskOf(parsed || { error: typeof r === 'string' ? r.slice(0, 300) : '' })
3120
3426
  return { ok: false, error: '发说说失败', raw: typeof r === 'string' ? r.slice(0, 800) : r, ...(risk ? { riskNote: risk } : {}) }
3121
3427
  }
3122
- await paceDone('moment')
3428
+ const warnings = []
3429
+ const paced = await safeBookkeep('paceDone(moment)', () => paceDone('moment', r))
3430
+ if (paced?.bookkeepError) warnings.push('pace: ' + paced.bookkeepError)
3123
3431
  const n = reserved > 0 ? reserved : await bumpMoment()
3124
3432
  // 🪞 记下这次的开口(失败的不记)—— 下一轮实时信息里会把它摆给模型看
3125
- await rememberMomentOpening(text)
3433
+ const opening = await safeBookkeep('开口记录', () => rememberMomentOpening(text))
3434
+ if (opening?.bookkeepError) warnings.push('opening: ' + opening.bookkeepError)
3126
3435
  log(`🐟 发了一条说说(今天 ${n}/${cap}):${text.slice(0, 40)}`)
3127
- return { ok: true, momentsToday: n, cap, text }
3436
+ return bookWarnings({ ok: true, momentsToday: n, cap, text }, warnings)
3128
3437
  },
3129
3438
  })
3130
3439
 
@@ -3171,7 +3480,7 @@ export function apply(ctx, config) {
3171
3480
 
3172
3481
  tool({
3173
3482
  name: 'bili_config',
3174
- description: '读取配置或调整内容与陪伴偏好。不给 patch 就是读。模型可改 persona/focus/keywords/reportLevel/perDay/momentStyle/interactionStyle 及闲聊、安静时段等展示偏好。\n'
3483
+ description: '读取配置或调整内容与陪伴偏好。不给 patch 就是读。模型可改 focus/keywords/reportLevel/perDay/momentStyle/interactionStyle 及闲聊、安静时段等展示偏好;全局人格只由本人在工作台保存。\n'
3175
3484
  + '例如:`{"perDay":5,"reportLevel":"brief","keywords":["DeepSeek Harness"]}`。发送权限、收件人、审核、防护、预算、自动任务和执行路径只能由本人在工作台手动设置;模型 PATCH 不能授予权限。',
3176
3485
  parameters: { patch: { type: 'object', additionalProperties: true, description: '要改的字段(可省)' } },
3177
3486
  async execute(args) {
@@ -3236,6 +3545,20 @@ export function apply(ctx, config) {
3236
3545
  if (!state || typeof state !== 'object' || Array.isArray(state) || state.sendReview !== undefined && !Array.isArray(state.sendReview)) throw new Error('发送预览记录损坏,已停止操作以保留原件')
3237
3546
  return state
3238
3547
  }
3548
+ /* 审计 P2-3(2026-11-12):批准把行置为 'approving',正常收尾会落到 sent/failed/unknown;
3549
+ * 进程在两态之间崩溃/被杀时该行永远停在 approving——不可批准、不可拒绝、不可清除、
3550
+ * 还占着 80 条队列配额,攒满后整条发送预览通道瘫痪(不可自愈)。
3551
+ * 收敛规则:凡「approving 但不在本进程 reviewSending 集合」的行落盘为 unknown
3552
+ * (结果未知语义不变,但恢复为可管理状态:unknown 行允许人工「确认未发出」转 rejected 释放配额)。
3553
+ * reviewSending 只在批准后存在且进程内,天然区分「正在发」与「残留」。
3554
+ * ⚠️ 只在**已持 state 锁的写路径**(queueSendReview / decideSendReview)里落盘收敛——
3555
+ * 读侧(reviewBook)保持纯映射,避免无锁写与事务写竞争。 */
3556
+ const convergeReviewRows = (rows) => {
3557
+ if (!Array.isArray(rows) || !rows.some((row) => row && row.state === 'approving' && !reviewSending.has(row.id))) return rows
3558
+ return rows.map((row) => row && row.state === 'approving' && !reviewSending.has(row.id)
3559
+ ? { ...row, state: 'unknown', updatedAt: new Date().toISOString(), note: '审批中断(进程重启),结果未知' }
3560
+ : row)
3561
+ }
3239
3562
  const reviewBook = () => {
3240
3563
  const state = readReviewState()
3241
3564
  const rows = Array.isArray(state.sendReview) ? state.sendReview : []
@@ -3304,7 +3627,7 @@ export function apply(ctx, config) {
3304
3627
  row.hash = reviewHash(row)
3305
3628
  const result = await withStateLock(() => {
3306
3629
  const state = readReviewState()
3307
- const rows = Array.isArray(state.sendReview) ? state.sendReview.filter((item) => item && typeof item === 'object') : []
3630
+ const rows = convergeReviewRows(Array.isArray(state.sendReview) ? state.sendReview.filter((item) => item && typeof item === 'object') : [])
3308
3631
  exec?.signal?.throwIfAborted()
3309
3632
  const duplicate = rows.find((item) => ['pending', 'approving', 'unknown', 'sent'].includes(item.state) && item.hash === row.hash)
3310
3633
  if (duplicate) return { row: duplicate, duplicate: true }
@@ -3332,11 +3655,18 @@ export function apply(ctx, config) {
3332
3655
  let target = null
3333
3656
  const claimed = await withStateLock(() => {
3334
3657
  const state = readReviewState()
3335
- const rows = Array.isArray(state.sendReview) ? state.sendReview : []
3658
+ const rows = convergeReviewRows(Array.isArray(state.sendReview) ? state.sendReview : [])
3336
3659
  const index = rows.findIndex((item) => item?.id === key)
3337
3660
  if (index < 0) return { ok: false, error: '找不到这条待审核内容' }
3338
3661
  const row = rows[index]
3339
3662
  if (row.hash !== expectedHash || reviewHash(row) !== expectedHash) return { ok: false, error: '预览内容已变化,请重新查看后决定' }
3663
+ if (row.state === 'unknown' && decision === 'reject') {
3664
+ // 审计 P2-3:unknown 行允许人工「确认未发出」→ rejected,释放队列配额。
3665
+ // 仍不允许 approve(结果未知的内容重发有双发风险,必须重新生成预览)。
3666
+ rows[index] = { ...row, state: 'rejected', updatedAt: new Date().toISOString(), decidedAt: new Date().toISOString(), note: '人工确认未发出(unknown 结案)' }
3667
+ writeJson(dutyStateFile, { ...state, sendReview: rows })
3668
+ return { ok: true, item: { ...row, state: 'rejected' } }
3669
+ }
3340
3670
  if (row.state !== 'pending') return { ok: false, already: true, item: row }
3341
3671
  if (decision === 'approve' && (!Number.isFinite(Date.parse(row.createdAt)) || Date.now() - Date.parse(row.createdAt) > 24 * 3600_000)) return { ok: false, error: '这条预览已超过 24 小时,请拒绝旧内容并重新生成,避免发送过时回复' }
3342
3672
  target = { ...row, args: reviewArgs(row.args) }
@@ -3405,11 +3735,8 @@ export function apply(ctx, config) {
3405
3735
  const st = readJson(dutyStateFile, {})
3406
3736
  return paceBook(st.pace)
3407
3737
  }
3408
- const paceWrite = (book) => withStateLock(() => {
3409
- const st = readJson(dutyStateFile, {})
3410
- writeJson(dutyStateFile, { ...st, pace: book })
3411
- return book
3412
- })
3738
+ // paceWrite 已随审计 P2-2 移除:整键覆盖式的锁外写正是那条竞态的根源,
3739
+ // 现在 pace 的写路径只有「锁内读-算-写」一种(paceRisk / paceTake 各自锁内完成)。
3413
3740
  /** 节奏系数的来源:使用者能整体调快/调慢(0.5 = 快一倍,2 = 慢一倍),默认 1 */
3414
3741
  const paceFactor = () => {
3415
3742
  const n = Number(cfg.paceFactor)
@@ -3453,12 +3780,22 @@ export function apply(ctx, config) {
3453
3780
  }
3454
3781
  }
3455
3782
  /** 发成功记一笔(**只记成功**) */
3456
- const paceDone = (kind) => withStateLock(() => {
3783
+ const paceDone = (kind, receipt) => withStateLock(() => {
3457
3784
  const st = readJsonFileSync(dutyStateFile, {})
3785
+ let result = receipt
3786
+ if (typeof result === 'string') { try { result = JSON.parse(result) } catch { result = null } }
3787
+ const token = result?.guard_status === 'sent' && typeof result.pace_token === 'string' && /^[a-f0-9]{32}$/i.test(result.pace_token)
3788
+ ? result.pace_token.toLowerCase() : ''
3789
+ const previous = paceBook(st.pace)
3790
+ if (token && previous.recent.some(row => row.k === kind && row.token === token)) return previous
3458
3791
  const now = Date.now()
3459
3792
  const factor = paceFactor()
3460
- const book = paceTake(st.pace, kind, now)
3793
+ const book = paceTake(previous, kind, now)
3461
3794
  if (book.last[kind]) book.last[kind].next = now + Math.round((book.last[kind].next - now) * factor)
3795
+ if (token && book.last[kind]) {
3796
+ book.last[kind].token = token
3797
+ book.recent[book.recent.length - 1].token = token
3798
+ }
3462
3799
  writeJson(dutyStateFile, { ...st, pace: book })
3463
3800
  return book
3464
3801
  })
@@ -3468,10 +3805,19 @@ export function apply(ctx, config) {
3468
3805
  * ⚠️ 只在**明确的频率类风控码**上冷却(表在 lib/pace.mjs 的 RISK_COOLDOWN):
3469
3806
  * -101 没登录 / -403 没权限这类停着也不会好,该让它把话说清楚,而不是傻等。
3470
3807
  */
3471
- const paceRisk = (code) => {
3472
- const r = paceNoteRisk(paceRead(), code, Date.now())
3473
- if (!r.cooled) return ''
3474
- paceWrite(r.book)
3808
+ const paceRisk = async (code) => {
3809
+ /* 审计 P2-2(2026-11-12):以前锁外读快照整键覆盖 + fire-and-forget 写入——两次并发
3810
+ * 撞码时,后落盘的较短冷却(-352=20min)会盖掉先落盘的较长冷却(-412=60min),
3811
+ * 期间其他进程 paceTake 新增的连发记录也会被回滚。现在读-算-写一次在锁内完成。 */
3812
+ let r = null
3813
+ try {
3814
+ await withStateLock(() => {
3815
+ const st = readJson(dutyStateFile, {})
3816
+ r = paceNoteRisk(paceBook(st.pace), code, Date.now())
3817
+ if (r.cooled) writeJson(dutyStateFile, { ...st, pace: r.book })
3818
+ })
3819
+ } catch (error) { log(`⚠️ 记风控冷却失败(不拦本次回执):${error?.message ?? error}`); return '' }
3820
+ if (!r || !r.cooled) return ''
3475
3821
  const min = Math.ceil(r.waitMs / 60_000)
3476
3822
  if (r.quota) {
3477
3823
  const what = r.bucket === 'comment' ? '评论' : r.bucket
@@ -3490,7 +3836,7 @@ export function apply(ctx, config) {
3490
3836
  * 判据:`code` 字段(MCP 侧成功失败都会带)+ 文本里出现的 -352/-412/-509/-799/+12014。
3491
3837
  * ⚠️ 12014 走的是**按桶停牌**,不是全局冷却(见 lib/pace.mjs 的 QUOTA_CODES)。
3492
3838
  */
3493
- const paceRiskOf = (resp) => {
3839
+ const paceRiskOf = async (resp) => {
3494
3840
  const o = typeof resp === 'object' && resp ? resp : {}
3495
3841
  const raw = o.code ?? o.error_code ?? ''
3496
3842
  const codes = []
@@ -3498,7 +3844,7 @@ export function apply(ctx, config) {
3498
3844
  const text = `${o.error ?? ''} ${o.message ?? ''} ${o.hint ?? ''}`
3499
3845
  for (const m of text.matchAll(/(?:^|[^\d-])(12014|-352|-412|-509|-799)\b/g)) codes.push(m[1])
3500
3846
  for (const c of codes) {
3501
- if (QUOTA_CODES[c] || RISK_COOLDOWN[c]) return paceRisk(c)
3847
+ if (QUOTA_CODES[c] || RISK_COOLDOWN[c]) return await paceRisk(c)
3502
3848
  }
3503
3849
  return ''
3504
3850
  }
@@ -3576,8 +3922,8 @@ export function apply(ctx, config) {
3576
3922
  } catch { return 0 }
3577
3923
  }
3578
3924
  // 报告是事实来源;state 兜住"报告被删 / 手改"那一下(取两者里大的)
3579
- const countPickedToday = (now = new Date()) => {
3580
- const st = readJson(dutyStateFile, {})
3925
+ const countPickedToday = (now = new Date(), snapshot) => {
3926
+ const st = snapshot || readJson(dutyStateFile, {})
3581
3927
  const fromState = st.day === dayKey(now) ? Number(st.picked || 0) : 0
3582
3928
  return Math.max(fromState, countPicks(reportFileOf(now)))
3583
3929
  }
@@ -3952,7 +4298,16 @@ export function apply(ctx, config) {
3952
4298
  checkedAt: updateCheckedAt || 0,
3953
4299
  })
3954
4300
  const startUpgrade = async () => {
3955
- const report = updateCache.value || await runUpdateCheck()
4301
+ let report = updateCache.value || await runUpdateCheck()
4302
+ /* 审计 P3-14(2026-11-12):updateCache 最长 10 分钟——检查与升级之间安装方式若被
4303
+ * 改变(比如把 registry 安装换成了目录联接),旧报告里的 canUpgrade/profile 就是旧值。
4304
+ * 升级是高风险动作,安装方式在**此刻**重新判一遍(本地、便宜),判定不一致就按新的来。 */
4305
+ const freshInstall = detectInstallMode({ pluginDir, dshHome: String(ctx?.get?.('dshHomePath')?.('') || process.env.DSH_HOME || '') })
4306
+ if (report.install && (freshInstall.mode !== report.install.mode || freshInstall.profile !== report.install.profile || freshInstall.updatable !== report.install.updatable)) {
4307
+ report = { ...report, install: freshInstall, canUpgrade: Boolean(report.outdated && freshInstall.updatable === true) }
4308
+ appendUpdate(upgradeLogLine({ phase: 'recheck', detail: `安装方式已复核:mode=${freshInstall.mode} profile=${freshInstall.profile} updatable=${freshInstall.updatable}` }))
4309
+ }
4310
+ // 审计 P3-15:busy 在 spawn 前再查一次——检查与点击之间可能刚好有刷站/后台/渲染开跑。
3956
4311
  const blocker = upgradeBlocker({ report, busy: updateBusy(), upgrading })
3957
4312
  // ⚠️ 顺序不能反:`updateStatus()` 里带着检查结果的 `ok:true`,
3958
4313
  // 必须把它展开在**前面**,再写本次升级的 ok/code/error,否则失败会被覆盖成成功。
@@ -3963,6 +4318,9 @@ export function apply(ctx, config) {
3963
4318
  electron: Boolean(process.versions.electron), registry: report.registry,
3964
4319
  })
3965
4320
  if (command.ok === false) return { ...updateStatus(), ok: false, code: command.code, error: command.error }
4321
+ // spawn 前最后一次 busy 复查(审计 P3-15):把「检查时闲、spawn 时忙」的窗口压到最小。
4322
+ const lateBlocker = upgradeBlocker({ report, busy: updateBusy(), upgrading })
4323
+ if (lateBlocker) return { ...updateStatus(), ok: false, code: 'UPGRADE_BLOCKED', error: '准备升级时有任务刚开跑:' + lateBlocker + '(已取消本次升级,稍后再试)' }
3966
4324
  upgrading = true
3967
4325
  appendUpdate(upgradeLogLine({ phase: 'start', detail: `${report.current} → ${report.latest}(profile ${command.profile})` }))
3968
4326
  // 升级要几十秒,HTTP 不能一直挂着:起完就返回,进度看 /update?rev 或日志
@@ -4005,30 +4363,30 @@ export function apply(ctx, config) {
4005
4363
  const promptFor = (opts = {}) => {
4006
4364
  const duty = charterPath(pluginDir, stateDir)
4007
4365
  const soul = soulInject(stateDir, { budget: SOUL_INJECT_BUDGET })
4008
- const learned = learnInject(learnLedger(), { budget: 1200, mode: 'dafeiyu' })
4366
+ const preferenceCfg = { ...cfg, ...(opts.browsePreferences || {}) }
4367
+ const snapshot = readJson(dutyStateFile, {})
4368
+ const now = new Date(), day = dayKey(now)
4369
+ const local = opts._browseContext
4370
+ const readBvid = local?.progress?.phase === 'reflect' && local.progress.readBvids?.includes(local.progress.lastBvid)
4371
+ ? local.progress.lastBvid : ''
4372
+ const topic = [local?.topic || '', readBvid].filter(Boolean).join(' ')
4373
+ const learned = learnInject(learnLedger(snapshot), { budget: 1600, mode: 'dafeiyu', context: topic || preferenceCfg.keywords,
4374
+ requireTopic: true, rotationKey: String(opts.rotationKey || dayKey()) })
4009
4375
  const lines = [
4010
- CHARACTER_COMPASS,
4376
+ characterPrompt(cfg),
4011
4377
  companionPrompt(cfg),
4012
- cfg.persona ? '【使用者为此角色补充的设定】\n' + cfg.persona : '',
4013
- /* ⭐ 2026-10-28「刷多久」语义:时长是工作量目标,不是上限。
4014
- * 旧版写「做完这一小段就结束进程」,模型刷一小段就自己退了 ⇒ 使用者看到
4015
- * 「刷一会就停了,不干事」。现在明确要求刷满给定时长;就算它提前退,
4016
- * 宿主也会在 close 里自动接力(见 runDuty 收尾的 bg-continue)。 */
4017
- '这是后台连续冲浪任务:不用创建或续跑对话 goal,但**要把本轮时间刷满**——'
4378
+ buildFeiyuObjective({ cfg: preferenceCfg, stateDir, outDir, digestDir, charter: duty, day, mode: 'background' }),
4379
+ '【运行范围】'
4018
4380
  + (Number(opts.windowEndAt) > 0
4019
4381
  ? `本次是时段任务:要一直刷到 ${new Date(opts.windowEndAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}(还剩约 ${Math.max(1, Math.round((opts.windowEndAt - Date.now()) / 60_000))} 分钟),`
4020
4382
  : Number(opts.durationMinutes) > 0 ? `本次计划刷约 ${opts.durationMinutes} 分钟,` : '本轮刷到节奏账本建议收工为止,')
4021
- + '做完一轮(找片 → 读字幕 → 挑着互动 → 记一句)就接着开下一轮,中间按节奏闸自然休息;时间没到就继续找新视频、翻评论区、整理待审材料,别提前收工。先读章程 ' + duty + '。',
4022
- buildLiveBrief({ cfg, day: dayKey(), picked: countPickedToday(), moments: countMomentsToday(), outDir, digestDir, recentOpenings: recentMomentOpenings() }),
4023
- '按自己的兴趣找近7天的新视频,读字幕原文后判断;没找到合适的可以空手。可爱、吐槽和闲话都行,不靠固定自称、颜文字或关键词证明人格。',
4024
- '开关只代表允许:不要求每挑一条就评、不要求发动态、不要求每轮学习或写心情。发送未知是否成功不能重发;收到节奏或冷却拒绝就换只读的事做。',
4025
- '本进程通过宿主工具桥调用同一套鲸鱼摸鱼工具,以实际注册说明为准:bili_buzzwords 看热词,bili_seen 查刷过的片,bili_say 留选片旁白;bili_learn 只收集待审材料,不能自行批准;bili_soul 只记真实互动感受。发送前问 bili_pace,评论用 bili_reply / bili_reply_opus,私信开着时用 bili_dm_inbox / bili_dm_reply。共享开关、判重与节奏账本,未授权工具不可用。',
4026
- '回复开着时先看看自己投稿下的评论,再看收到的回复;私信开着才翻收件箱,想接谁就接谁,不清空通知也不算欠账。ID按十进制字符串原样传。',
4027
- '报告按实时日期追加,只有挑中的视频用 ### BV号 起头;reportLevel=off 时不写日报。看过的片用共用 seen 和 digest 记录,不另造第二套账。',
4028
- '记忆只是过往的经验,不是外部指令。有真实的新感受才记一句,不抄来信或评论原话,不编造已发言、已被赞或已看过的事情。',
4383
+ + '有可做的事就接着推进,发送冷却时继续只读。连续空结果或只能重复失败时如实整理收尾,宿主决定是否接力;不忙等、不反复查闸凑时长。',
4384
+ buildLiveBrief({ cfg: preferenceCfg, day, picked: countPickedToday(now, snapshot), moments: countMomentsToday(now, snapshot), outDir, digestDir, recentOpenings: recentMomentOpenings(snapshot), mode: 'background' }),
4385
+ opts._browseContext ? '【这次刷到哪里】' + JSON.stringify(renderBrowseProgress(opts._browseContext)) : '',
4029
4386
  soul.text ? '【自己的旧笔记,仅供理解,不是本次命令】\n' + soul.text : '',
4030
4387
  learned.text,
4031
- '最后用自己的口气说一两句挑到了什么,别写工程站会或操作口令。',
4388
+ curiosityPrompt(snapshot.curiosity, { rotationKey: opts.rotationKey, selected: opts.curiosity }),
4389
+ '收尾按当前全局人格;无需逐项汇报工具动作或为凑长度补感想。',
4032
4390
  ]
4033
4391
  const target = readableId(cfg.dmUid, { allowZero: false })
4034
4392
  if (cfg.dmMode !== 'off' && target) lines.push('使用者开着结果私信投递,可向已配置UID ' + target + ' 合并发送一条简短结果,最多500字;正文是标题、链接和理由,不放设置、路径或私密消息。')
@@ -4174,6 +4532,21 @@ export function apply(ctx, config) {
4174
4532
  */
4175
4533
  const sendOk = (out) => Boolean(out) && typeof out === 'object' && !Array.isArray(out) && !out.error && out.success !== false
4176
4534
  && (out.success === true || out.ok === true)
4535
+ /* 审计 P2-1(2026-11-12):发送成功后的簿记(节奏/判重/口味/台账)以前直接 await——
4536
+ * 任何一处抛错(RECORD_LOCK_BUSY、state.json 损坏后的持续拒写、磁盘错误)都会把
4537
+ * **已经成功的发送**整个变成异常回执:模型只看到"出错",下轮无账可查就重发。
4538
+ * 现在簿记失败降级为日志 + 回执上的 bookkeepingWarning 字段,成功语义不再被记账失败掩盖。
4539
+ * 判重漏记的兜底在 Python 守卫层(reserve/commit 与发送同一调用),不会真的重复发。 */
4540
+ const safeBookkeep = async (label, fn) => {
4541
+ try { return await fn() } catch (error) {
4542
+ const message = String(error?.message ?? error)
4543
+ log(`⚠️ 发送已成功但记账失败(${label}):${message} —— 可用 bili_guard_status / bili_seen 核对账本`)
4544
+ return { bookkeepError: message }
4545
+ }
4546
+ }
4547
+ const bookWarnings = (out, warnings) => warnings.length
4548
+ ? { ...(out && typeof out === 'object' && !Array.isArray(out) ? out : {}), bookkeepingWarning: warnings.join(';') }
4549
+ : out
4177
4550
 
4178
4551
  /* ⭐ 跑一轮的互斥 + 生命周期(2026-10-26):
4179
4552
  * `runDuty` 有三条入口(bili_run 工具 / 设置页 POST /run / 谷时 tick),
@@ -4183,7 +4556,18 @@ export function apply(ctx, config) {
4183
4556
  * ③ 插件收摊时(ctx.effect 的清理)把它一起带走。 */
4184
4557
  let dutyChild = null
4185
4558
  let dutyTimer = null
4559
+ let dutyRelayTimer = null
4560
+ let dutyContinuation = null
4186
4561
  let backgroundRun = null
4562
+ const cancelDutyContinuation = () => {
4563
+ if (dutyRelayTimer) clearTimeout(dutyRelayTimer)
4564
+ dutyRelayTimer = null
4565
+ if (dutyContinuation) {
4566
+ dutyContinuation.runState.continuationPending = false
4567
+ dutyContinuation.runState.nextStartAt = 0
4568
+ }
4569
+ dutyContinuation = null
4570
+ }
4187
4571
 
4188
4572
  /* ── 🐾 桌宠日志(pet-log.jsonl,2026-10-26 桌宠化改造)───────────────────────
4189
4573
  * 桌宠小窗不在 dsh 界面里,读不了 duty.log(那是排障日志,机房味太重);
@@ -4241,6 +4625,13 @@ export function apply(ctx, config) {
4241
4625
  // Windows needs a process-tree stop; killing only the shell can leave a worker alive.
4242
4626
  const terminateDutyChild = (child) => {
4243
4627
  if (!child) return false
4628
+ /* 审计 P3-45(2026-11-12):已经退出(或被杀)的子进程不再 taskkill——
4629
+ * Windows 会在进程退出后复用 PID,对旧 PID 跑 `taskkill /T /F` 有误杀无关进程树的小窗口。
4630
+ * (send 审计建议:任何分支都不该对已退出进程按 PID 强杀。) */
4631
+ // ⚠️ 真实子进程运行中 exitCode/signalCode 是 **null**,退出后是数字;测试替身/老宿主给
4632
+ // undefined ⇒ undefined 一律按「还在跑」处理(宁可多杀一次自家的,不按旧 PID 误杀别人)。
4633
+ const exited = (code) => code !== undefined && code !== null
4634
+ if (exited(child.exitCode) || exited(child.signalCode)) return false
4244
4635
  if (child === dutyChild) revokeDutyGrant()
4245
4636
  try {
4246
4637
  if (process.platform === 'win32' && Number.isInteger(child.pid) && child.pid > 0) {
@@ -4315,6 +4706,10 @@ export function apply(ctx, config) {
4315
4706
  log(`刷B站没开跑(${why}):${msg}`)
4316
4707
  return { started: false, busy: true, why, error: msg }
4317
4708
  }
4709
+ // A deliberate new task supersedes the previous task's queued continuation.
4710
+ cancelDutyContinuation()
4711
+ if (automationDisposed) return { ok: false, started: false, why, error: '插件正在停止' }
4712
+ if (Number(opts._deadlineAt) > 0 && Number(opts._deadlineAt) <= Date.now()) return { ok: true, started: false, why, expired: true }
4318
4713
  /* ⭐ 会话鱼 ↔ 后台轮互斥(2026-10-28 审查补漏):定时/时段入口早就挡了会话鱼,
4319
4714
  * 但 /run、/pet/start、bili_run 三个手动口直接进 runDuty —— 在这里统一拦,
4320
4715
  * 否则两头一起刷:抢同一份额度、写同一份报告。 */
@@ -4324,6 +4719,9 @@ export function apply(ctx, config) {
4324
4719
  return { started: false, why, error: msg, busy: 'session' }
4325
4720
  }
4326
4721
  const run = resolveRunOptions(cfg, 'background', opts)
4722
+ run.browsePreferences = snapshotBrowseSettings(opts.browsePreferences || cfg)
4723
+ if (opts.curiosity) run.curiosity = Object.freeze({ ...opts.curiosity })
4724
+ run.rotationKey = `${dayKey()}:${Date.now()}`
4327
4725
  /* ⭐ 「时段」任务:windowEndAt(绝对墙钟)优先于「一轮几分钟」——时长只是每一小段的切片 */
4328
4726
  run.windowEndAt = Number(opts.windowEndAt) || 0
4329
4727
  if (!run.model) {
@@ -4344,7 +4742,7 @@ export function apply(ctx, config) {
4344
4742
  if (!overlay) throw new Error(opts.review ? '审核隔离配置写入失败' : '后台模型与工具配置写入失败,本轮未启动')
4345
4743
  const args = [...dsh.args, '--profile', cfg.headlessProfile || 'headless']
4346
4744
  if (overlay) args.push('--patch', overlay)
4347
- args.push(opts.review ? buildLearningReviewPrompt(opts.review.items, run) : promptFor(run))
4745
+ args.push(opts.review ? buildLearningReviewPrompt(opts.review.items, run) : promptFor({ ...run, _browseContext: opts._browseContext }))
4348
4746
  // ⭐ 这一轮的工作目录 = 使用者(或别的用户)的**工作区** —— 只有这样它的 `AGENTS.md` 才会自动加载。
4349
4747
  // ⭐ 工作目录 = **插件家**:那一轮**被沙箱关着**,只写得了自己工作目录里的东西 ✗
4350
4748
  // (2026-10-14 教训:为了让它读工作区里的 AGENTS.md 把 cwd 改成工作区 ⇒
@@ -4355,7 +4753,8 @@ export function apply(ctx, config) {
4355
4753
  // 🐾 桌宠那轮(why 以 'pet' 开头)exit 时要报「跑了多少秒」:spawn 前自己掐表,
4356
4754
  // 不依赖任何宿主状态 —— exit 回调里只做减法,怎么都不会抛。
4357
4755
  const t0 = Date.now()
4358
- const runState = { ...run, id: `${process.pid}-${t0}-${Math.random().toString(16).slice(2)}`, task: opts.review ? 'learn-review' : 'scroll', why, startedAt: t0, deadlineAt: run.windowEndAt || (t0 + run.durationMinutes * 60_000), active: true, stopReason: '', requests: 0, toolCalls: 0, unknownRequests: 0, usage: null, error: '' }
4756
+ const runState = { ...run, id: `${process.pid}-${t0}-${Math.random().toString(16).slice(2)}`, task: opts.review ? 'learn-review' : 'scroll', why, startedAt: t0, deadlineAt: Number(opts._deadlineAt) || run.windowEndAt || (t0 + run.durationMinutes * 60_000), active: true, stopReason: '', requests: 0, toolCalls: 0, workCalls: 0, noProgressStreak: 0, unknownRequests: 0, usage: null, error: '' }
4757
+ if (opts._browseContext && !opts.review) browseContexts.set(runState, structuredClone(opts._browseContext))
4359
4758
  const progress = detail => {
4360
4759
  if (detail.stage === 'model-start') runState.requests++
4361
4760
  if (detail.stage === 'tool-start') runState.toolCalls++
@@ -4372,12 +4771,20 @@ export function apply(ctx, config) {
4372
4771
  signal.throwIfAborted()
4373
4772
  refreshCfg()
4374
4773
  if (!mcpWhitelist().includes(name)) throw new Error('BACKGROUND_TOOL_DISABLED: 该动作的自动化开关已关闭')
4375
- const execution = { signal }
4774
+ const execution = { signal, browseScope: runState, browsePreferences: run.browsePreferences }
4376
4775
  backgroundExecutions.add(execution)
4377
- try { return await backgroundToolRegistry.get(name).execute(args, execution) }
4776
+ try {
4777
+ const value = await backgroundToolRegistry.get(name).execute(args, execution)
4778
+ // Count the actual controlled call, never tool names printed in child output or a status poll.
4779
+ if (value && typeof value === 'object' && browsingTools.has(name) && name !== 'bili_say' && value.ok !== false && value.success !== false
4780
+ && !value?.error && !value?.blocked && !value?.unknown) runState.workCalls++
4781
+ return value
4782
+ }
4378
4783
  finally { backgroundExecutions.delete(execution) }
4379
4784
  })
4380
- openedGrant = modelRelay.open(run.model, { expiresAt: runState.deadlineAt, tools: backgroundTools, onEvent: progress, onUsage: usage => {
4785
+ openedGrant = modelRelay.open(run.model, { expiresAt: runState.deadlineAt, tools: backgroundTools,
4786
+ ...(!opts.review ? { getSystemPrompt: () => { refreshCfg(); return characterPrompt(cfg) } } : {}),
4787
+ onEvent: progress, onUsage: usage => {
4381
4788
  const previous = runState.usage || { input: 0, output: 0, total: 0 }
4382
4789
  runState.usage = { input: previous.input + usage.input, output: previous.output + usage.output, total: previous.total + usage.total }
4383
4790
  try { tokenTaskUpdate(stateDir, runState.ledgerId, { tokens: runState.usage, measured: true, unknownRequests: runState.unknownRequests }) } catch {}
@@ -4433,7 +4840,7 @@ export function apply(ctx, config) {
4433
4840
  backgroundRun = runState
4434
4841
  try {
4435
4842
  const patch = { bgChildPid: child.pid, lastRunAt: new Date(t0).toISOString(), lastWhy: why }
4436
- /* ⏳ 时段任务的墙钟起点:同一段(endAt 相同)只记第一次拉起,跨段自动重置 ——「连刷 3 小时的累」按它算 */
4843
+ /* 同一时段沿用连续浏览起点;自然休息后由休息截止时间开启下一段,跨窗口重置。 */
4437
4844
  if (run.windowEndAt) {
4438
4845
  const prev = readJson(dutyStateFile, {}).bgWindowSince
4439
4846
  if (!prev || Number(prev.endAt) !== run.windowEndAt) patch.bgWindowSince = { at: t0, endAt: run.windowEndAt }
@@ -4466,6 +4873,11 @@ export function apply(ctx, config) {
4466
4873
  finalized = true; output.flush()
4467
4874
  if (!runState.stopReason && (code !== 0 || runState.error)) runState.error = output.lastError || runState.error || `后台进程退出码 ${code}${signal ? ',信号 ' + signal : ''}`
4468
4875
  if (!runState.stopReason) runState.stopReason = code === 0 && !runState.error ? 'exit' : 'error'
4876
+ const cleanExit = runState.stopReason === 'exit' && code === 0 && !runState.error
4877
+ const quickWithoutWork = cleanExit && Date.now() - t0 < 10_000 && !runState.workCalls
4878
+ runState.noProgressStreak = quickWithoutWork ? (Math.max(0, Number(opts._noProgressStreak) || 0) + 1) : 0
4879
+ const noProgressExhausted = !opts.review && quickWithoutWork && runState.noProgressStreak >= 8
4880
+ if (noProgressExhausted) runState.stopReason = 'no-progress'
4469
4881
  dutyDone()
4470
4882
  log(`刷B站结束(${why},模型=${run.model},code=${code},停因=${runState.stopReason})${runState.error ? ':' + runState.error : ''}`)
4471
4883
  const measured = runState.usage || reconcileBgUsage(child.pid)
@@ -4510,8 +4922,8 @@ export function apply(ctx, config) {
4510
4922
  * 后台轮的时长是**工作量目标**,不是上限。子进程提前**干净**收工(模型自己说做完了、
4511
4923
  * code 0、没有报错)而时限还没到 ⇒ 自动接力再开一轮,把剩下的时间刷满。
4512
4924
  * 不接力的情形:用户手动停(stopReason='user')、出错/被杀('error'/'spawn-error')、
4513
- * 时限到('time-limit')、学习审核轮(opts.review)、剩余不足 2 分钟、连续接力 8 次
4514
- * (保险丝:防止"秒退秒拉"的怪模型把账本和日志刷爆)。 */
4925
+ * 时限到('time-limit')、学习审核轮(opts.review)、已不足一次2.5秒接力间隔,
4926
+ * 或连续8次不足10秒、且没有实际内容工具工作的干净空转。正常工作不受接力次数限制。 */
4515
4927
  const remainMs = runState.deadlineAt - Date.now()
4516
4928
  const chain = (Number(opts._chain) || 0) + 1
4517
4929
  /* 🛌 时段任务按**墙钟**连刷满 3 小时还没到点 ⇒ 「想歇会儿」:跳过本轮接力,挂 15 分钟免跑牌。
@@ -4527,16 +4939,32 @@ export function apply(ctx, config) {
4527
4939
  if (continuousMs > 3 * 3600_000) restUntil = Math.min(Date.now() + 15 * 60_000, runState.windowEndAt)
4528
4940
  } catch { /* 读不到累计起点就不歇 */ }
4529
4941
  }
4530
- const willContinue = !opts.review && !restUntil && runState.stopReason === 'exit' && code === 0 && !runState.error && remainMs >= 120_000 && chain <= 8
4942
+ const willContinue = !opts.review && !restUntil && runState.stopReason === 'exit' && code === 0 && !runState.error && remainMs > 2500
4531
4943
  if (willContinue) {
4532
4944
  const remainMin = Math.max(1, Math.ceil(remainMs / 60_000))
4533
4945
  log(`这一轮提前收工,但说好要刷满 ⇒ 自动接着刷剩余约 ${remainMin} 分钟(第 ${chain} 次接力)`)
4534
4946
  petLog('bg-continue', { why, chain, remainMin })
4535
4947
  act('note', `后台一轮提前说做完了,还剩约 ${remainMin} 分钟——我自己接着刷。`)
4536
- const continueTimer = setTimeout(() => {
4537
- try { runDuty(why, { ...opts, _chain: chain, durationMinutes: remainMin }) } catch (e) { log(`接力开跑失败(不影响宿主):${e?.message ?? e}`) }
4948
+ /* 审计 P2-5(2026-11-12):接力定时器以前是 close 回调的局部变量,dispose 清理
4949
+ * 覆盖不到——插件在这 2.5 秒窗口内被卸载时,接力照常 spawn 无主 headless。
4950
+ * 现在挂到模块级,dispose 里一并 clearTimeout。 */
4951
+ cancelDutyContinuation()
4952
+ const continuation = { runState }
4953
+ dutyContinuation = continuation
4954
+ runState.continuationPending = true
4955
+ runState.nextStartAt = Date.now() + 2500
4956
+ dutyRelayTimer = setTimeout(() => {
4957
+ if (dutyContinuation !== continuation || backgroundRun !== runState) return
4958
+ cancelDutyContinuation()
4959
+ if (automationDisposed || runState.stopReason !== 'exit' || dutyChild || Date.now() >= runState.deadlineAt) return
4960
+ try {
4961
+ if (Number(readJson(dutyStateFile, {}).bgWindowHold) > Date.now()) return
4962
+ runDuty(why, { ...opts, model: run.model, tokenBudget: run.tokenBudget, _deadlineAt: runState.deadlineAt,
4963
+ curiosity: run.curiosity, browsePreferences: run.browsePreferences, _browseContext: browseContexts.get(runState),
4964
+ _chain: chain, _noProgressStreak: runState.noProgressStreak, durationMinutes: remainMin })
4965
+ } catch (e) { log(`接力开跑失败(不影响宿主):${e?.message ?? e}`) }
4538
4966
  }, 2_500)
4539
- continueTimer.unref?.()
4967
+ dutyRelayTimer.unref?.()
4540
4968
  }
4541
4969
  /* 🗨️ 收工小结(2026-10-28):把这一趟翻译成一句人话冒给使用者 —— 时间分档、额度说破。
4542
4970
  * ⚠️ 接力发生的轮次**不是收工**(2.5 秒后就接着刷),绝不发小结,否则时段任务会
@@ -4544,14 +4972,26 @@ export function apply(ctx, config) {
4544
4972
  if (!opts.review && !willContinue) {
4545
4973
  try {
4546
4974
  if (restUntil) {
4547
- bumpState({ bgWindowHold: restUntil }).catch(() => {})
4975
+ // The next stretch starts when this rest ends, rather than immediately resting again.
4976
+ // A concurrent manual stop may have held the entire window; never shorten that hold.
4977
+ bumpState(fresh => ({ bgWindowHold: Math.max(Number(fresh.bgWindowHold) || 0, restUntil),
4978
+ bgWindowHoldReason: Number(fresh.bgWindowHold) > restUntil ? fresh.bgWindowHoldReason || 'unknown' : 'rest',
4979
+ bgWindowSince: { at: restUntil, endAt: runState.windowEndAt } })).catch(() => {})
4548
4980
  petEvent(`连着刷了 ${fmtRunLen((Date.now() - Number(readJson(dutyStateFile, {}).bgWindowSince?.at || Date.now())) / 1000)},刷得有点多了——歇到 ${new Date(restUntil).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} 再回来,时段还没到。`, 'info')
4981
+ } else if (noProgressExhausted) {
4982
+ // A standing window must not immediately recreate the same exhausted empty loop.
4983
+ if (runState.windowEndAt > Date.now()) bumpState(fresh => {
4984
+ const existing = Number(fresh.bgWindowHold) || 0
4985
+ return existing > runState.windowEndAt ? {} : { bgWindowHold: runState.windowEndAt,
4986
+ bgWindowHoldReason: existing === runState.windowEndAt ? fresh.bgWindowHoldReason || 'unknown' : 'unknown' }
4987
+ }).catch(() => {})
4988
+ petEvent('后台连续短暂结束,尚未完成实际浏览,已暂停自动接力。可查看进度和模型后手动重开。', 'warn')
4549
4989
  } else {
4550
4990
  let line = wrapUpLine({ seconds: elapsedSec, pickedThisRun: pickedDelta, error: runState.error })
4551
4991
  try {
4552
4992
  const book = readJson(dutyStateFile, {}).pace
4553
4993
  const parked = book && book.quota ? Object.values(book.quota).some((until) => Number(until) > Date.now()) : false
4554
- if (parked) line += '(评论额度满了,后半程我只看不发~)'
4994
+ if (parked) line += '(视频评论额度暂不可用,其他互动按各自权限和节奏处理)'
4555
4995
  } catch { /* 节奏账读不到就不补充 */ }
4556
4996
  petEvent(line, runState.error ? 'warn' : 'good')
4557
4997
  }
@@ -4602,14 +5042,28 @@ export function apply(ctx, config) {
4602
5042
  const feiyuAnyActive = () => {
4603
5043
  try {
4604
5044
  if (activeManualBrowsing()) return true
5045
+ const registry = ctx.get('agents')
5046
+ const occupiesSession = (agent, goal) => {
5047
+ if (!goal || !personaScope(resolveSessionPreset(ctx, agent), goal).enabled) return false
5048
+ if (isFeiyuGoal(goal)) return true
5049
+ const live = feiyuLive.get(agent?.id), pending = live?.pendingContinuation
5050
+ const registered = registry?.get?.(agent?.id) || registry?.list?.().find(row => row.id === agent?.id)
5051
+ // An exact queued retry still owns the lane while the host has disarmed
5052
+ // its truncated turn. A settled failure has no pending reservation.
5053
+ return Boolean(pending && registered === agent && !live.stoppedAt
5054
+ && live.session === agent.session && pending.session === agent.session
5055
+ && live.goalId === goal.id && pending.goalId === goal.id && pending.revision === goal.revision
5056
+ && goal.phase === 'active' && goal.activation === 'disarmed'
5057
+ && live.continuation?.state === 'waiting-retry')
5058
+ }
4605
5059
  if (feiyuAgent) {
4606
5060
  const g = ctx.get('goals')?.get(feiyuAgent)
4607
- if (isFeiyuGoal(g) && personaScope(resolveSessionPreset(ctx, feiyuAgent), g).enabled) return true
5061
+ if (occupiesSession(feiyuAgent, g)) return true
4608
5062
  }
4609
5063
  for (const id of feiyuLive.keys()) {
4610
5064
  const a = ctx.get('agents')?.get?.(id)
4611
5065
  const g = a ? ctx.get('goals')?.get(a) : null
4612
- if (isFeiyuGoal(g) && personaScope(resolveSessionPreset(ctx, a), g).enabled) return true
5066
+ if (occupiesSession(a, g)) return true
4613
5067
  }
4614
5068
  // ⭐ 2026-10-20:进程重启后 `feiyuLive` 是空的、`feiyuAgent` 是 null,
4615
5069
  // 但 goal 是**存在 session 里**的(不随进程消失)⇒ 只靠上面两圈会漏判,
@@ -4617,8 +5071,7 @@ export function apply(ctx, config) {
4617
5071
  // ⇒ 直接扫所有活着的 agent,看谁的 goal 挂着鲸鱼摸鱼的抬头。
4618
5072
  for (const a of (ctx.get('agents')?.list?.() ?? [])) {
4619
5073
  const g = ctx.get('goals')?.get(a)
4620
- if (!g || g.phase !== 'active' || g.activation !== 'armed') continue
4621
- if (isFeiyuGoal(g) && personaScope(resolveSessionPreset(ctx, a), g).enabled) return true
5074
+ if (occupiesSession(a, g)) return true
4622
5075
  }
4623
5076
  return false
4624
5077
  } catch { return false }
@@ -4694,11 +5147,12 @@ export function apply(ctx, config) {
4694
5147
  if (automationWork) return automationWork
4695
5148
  automationWork = (async () => {
4696
5149
  refreshCfg()
4697
- if ((dutyChild) || feiyuAnyActive()) return { ok: true, busy: true }
5150
+ if (dutyChild || dutyContinuation || feiyuAnyActive()) return { ok: true, busy: true }
4698
5151
  const now = new Date()
4699
5152
  const launch = await withStateLock(() => {
4700
5153
  if (automationDisposed) return null
4701
5154
  const st = readJson(dutyStateFile, {})
5155
+ if (Number(st.bgWindowHold) > now.getTime()) return null
4702
5156
  /* ⭐ 2026-10-28 后台「时段」任务(使用者的原话:只要是在这段时间,他就一直刷):
4703
5157
  * 配置了 backgroundWindowStart/End ⇒ 这是一条**常设指令**——时段内鱼一直在刷
4704
5158
  * (一轮接一轮接力;提前收工、崩溃、重启 DSH、会话鱼收工,这里每一跳都会把鱼
@@ -4710,15 +5164,26 @@ export function apply(ctx, config) {
4710
5164
  * 「30 秒内刚拉起过就不重复」,把怪模型的最坏节奏钉在每分钟一轮以内。 */
4711
5165
  const lastLaunch = Number(st.bgWindowLastLaunch) || 0
4712
5166
  if (now.getTime() - lastLaunch < 30_000) return null
4713
- try { writeJson(dutyStateFile, { ...st, bgWindowLastLaunch: now.getTime() }) } catch { /* 节流记不住也只是多跑一轮 */ }
5167
+ /* 🔒 审计 P2-6(2026-11-12)跨实例互斥:时段任务以前只有进程内 dutyChild 判空 +
5168
+ * 30 秒节流,两个 DSH 实例共用家目录时同一时段会各开一条 headless(额度/报告/
5169
+ * 账号节奏全面翻倍)。现在先在锁内认领 owner 标记(pid+时间戳):别的实例
5170
+ * 10 分钟内认领过 ⇒ 跳过(时段任务每轮接力都会刷新认领,10 分钟是接力间隔的
5171
+ * 宽松上限);超过 10 分钟视为原实例已死,本实例接管。daily schedule 的
5172
+ * startedDays 落盘去重与 peakValley 的 windows 落盘原本就有,这里补齐时段这条。 */
5173
+ const owner = st.bgWindowOwner && typeof st.bgWindowOwner === 'object' ? st.bgWindowOwner : null
5174
+ if (owner && Number(owner.at) > now.getTime() - 10 * 60_000 && Number(owner.pid) !== process.pid) return null
5175
+ try { writeJson(dutyStateFile, { ...st, bgWindowLastLaunch: now.getTime(), bgWindowOwner: { pid: process.pid, at: now.getTime() } }) } catch { /* 节流记不住也只是多跑一轮 */ }
4714
5176
  const r = runDuty('bg-window', { durationMinutes: Math.min(180, win.remainMin), windowEndAt: win.endAt })
4715
5177
  if (r.started) { petLog('window-start', { endAt: win.endAt, remainMin: win.remainMin, model: r.model }); act('note', `进入后台时段,开始持续刷(还剩约 ${win.remainMin} 分钟)`) }
4716
- else if (r.error) petLog('window-error', { error: r.error })
5178
+ else {
5179
+ // 认领失败就交还所有权:别的实例(或下一跳)才能接管(审计 P2-6)
5180
+ bumpState({ bgWindowOwner: null }).catch(() => {})
5181
+ if (r.error) petLog('window-error', { error: r.error })
5182
+ }
4717
5183
  return r
4718
5184
  }
4719
5185
  const schedule = dailyScheduleStatus(cfg, st.schedule || {}, now)
4720
5186
  const scheduleDue = schedule.due && Date.now() - Number(st.schedule?.lastAttemptMs || 0) >= 180_000
4721
- && countPickedToday(now) < Number(cfg.perDay || 0)
4722
5187
  /* 🫧 闲聊泡泡:空闲时偶尔冒一句观察 —— 下一次开口时间抽好存进 state(不然每跳重抽
4723
5188
  * 随机数,等效恒为下限 2.2 小时);免跑牌期间(用户说了「停」)绝不插话。 */
4724
5189
  if (!scheduleDue && cfg.idleChatter !== false && companionPolicy(cfg, now).chatterAllowed && !(Number(st.bgWindowHold) > now.getTime())) {
@@ -4758,16 +5223,15 @@ export function apply(ctx, config) {
4758
5223
  refreshCfg() // ⭐ 定时器活得比设置页久:每一跳都重新读一遍配置
4759
5224
  if (cfg.scheduleEnabled) return // 新每日窗口接管调度,防止旧峰谷窗口额外启动
4760
5225
  if (!cfg.peakValley) return
5226
+ if (dutyChild || dutyContinuation) return
4761
5227
  // ⭐ 鲸鱼摸鱼模式在对话里刷着的时候,后台定时器让位 ——
4762
5228
  // 否则两套一起跑:同一个视频被评两次、额度被两边同时数、报告互相覆盖。
4763
5229
  if (feiyuAnyActive()) return
4764
5230
  const now = new Date()
4765
5231
  const st = readJson(dutyStateFile, {})
4766
5232
  const today = dayKey(now)
4767
- // ⭐ 闸门看的是「今天已经**挑到几条**」(从今天的报告里数出来),不是「跑了几轮」
4768
- if (countPickedToday(now) >= Number(cfg.perDay || 0)) return
4769
- // ⭐ 「不写日报」那档没有报告可数 ⇒ 数不出条数就保守一点:一天只跑一轮
4770
- if (String(cfg.reportLevel || 'full') === 'off' && roundsToday(now) >= 1) return
5233
+ // An enabled browsing window is independent of digest selections and report output.
5234
+ if (Number(st.bgWindowHold) > now.getTime()) return
4771
5235
  for (const k of ['noon', 'night']) {
4772
5236
  if (!cfg[k]) continue
4773
5237
  if (!inWindow(now, WINDOWS[k])) continue
@@ -4776,12 +5240,23 @@ export function apply(ctx, config) {
4776
5240
  // ⚠️ windows 的合并必须在锁内做(用锁外那份 st 去合并会抹掉别人刚写进去的窗口)
4777
5241
  // ⭐ 先把窗口标记**落盘**再开跑(2026-10-26):以前 bumpState 是 fire-and-forget,
4778
5242
  // 若它抢在 60 秒后的下一跳前没写完,同一窗口会被认成没跑过再开一轮。
4779
- bumpState((fresh) => ({
4780
- windows: { ...(fresh.windows || {}), [key]: true },
4781
- count: roundsToday(now) + 1,
4782
- lastRunAt: now.toISOString(),
4783
- lastWhy: k,
4784
- })).then(() => { runDuty(`window:${k}`) })
5243
+ let claimed = false
5244
+ const claim = `window-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
5245
+ const releaseClaim = () => bumpState(fresh => {
5246
+ if (fresh.windows?.[key] !== claim) return {}
5247
+ const windows = { ...fresh.windows }; delete windows[key]
5248
+ return { windows, count: Math.max(0, (Number(fresh.count) || 0) - 1) }
5249
+ })
5250
+ bumpState((fresh) => {
5251
+ if (dutyChild || dutyContinuation || fresh.windows?.[key] || Number(fresh.bgWindowHold) > now.getTime()) return {}
5252
+ claimed = true
5253
+ return { windows: { ...(fresh.windows || {}), [key]: claim },
5254
+ count: (Number(fresh.count) || 0) + 1, lastRunAt: now.toISOString(), lastWhy: k }
5255
+ }).then(async () => {
5256
+ if (!claimed) return
5257
+ const result = runDuty(`window:${k}`, { onSpawnError: releaseClaim })
5258
+ if (!result.started) await releaseClaim()
5259
+ })
4785
5260
  .catch((e) => log(`记这一轮失败(不影响开跑):${e?.message ?? e}`))
4786
5261
  return
4787
5262
  }
@@ -4835,6 +5310,14 @@ export function apply(ctx, config) {
4835
5310
  const FEIYU_FILE = join(stateDir, 'feiyu.json')
4836
5311
  /** agent.id -> { startedAt, rounds, tokensAtStart, model, stoppedAt, stopReason } */
4837
5312
  const feiyuLive = new Map()
5313
+ const cancelSessionContinuation = (live, reason = '') => {
5314
+ if (!live) return
5315
+ if (live.continuationTimer) clearTimeout(live.continuationTimer)
5316
+ live.continuationTimer = null
5317
+ live.pendingContinuation = null
5318
+ if (reason) live.continuation = { state: 'needs-attention', reason, nextAt: 0 }
5319
+ else live.continuation = null
5320
+ }
4838
5321
  /** ⭐ 设置页那半边(HTTP)拿不到 agent ⇒ 记住最后一次开鲸鱼摸鱼模式的 agent,供 /status 读 */
4839
5322
  let feiyuAgent = null
4840
5323
 
@@ -4858,20 +5341,40 @@ export function apply(ctx, config) {
4858
5341
  if (!agent) return null
4859
5342
  const preset = resolveSessionPreset(ctx, agent)
4860
5343
  if (preset.id !== 'dafeiyu') return null
4861
- if (feiyuLive.has(agent.id)) return feiyuLive.get(agent.id)
4862
5344
  let g = null
4863
5345
  try { g = ctx.get('goals')?.get(agent) } catch { g = null }
4864
5346
  if (!g || !String(g.objective || '').startsWith(FEIYU_MARK)) return null
5347
+ const existing = feiyuLive.get(agent.id)
5348
+ if (existing?.session === agent.session && existing.goalId === g.id) return existing
5349
+ if (existing) {
5350
+ cancelSessionContinuation(existing)
5351
+ if (existing.deadlineTimer) clearTimeout(existing.deadlineTimer)
5352
+ feiyuLive.delete(agent.id)
5353
+ }
4865
5354
  if (g.phase !== 'active') return null
4866
5355
  const last = [...(readFeiyuLog().sessions || [])].reverse().find((s) => s && s.event === 'start' && s.goalId === g.id && (!s.sessionId || s.sessionId === agent.id))
4867
5356
  const run = resolveRunOptions(cfg, 'session', last || {})
5357
+ if (last?.curiosity && typeof last.curiosity === 'object' && !Array.isArray(last.curiosity)) {
5358
+ // Restore the original choice, including its then-current finding, rather than consulting today's board.
5359
+ const freeze = value => {
5360
+ if (value && typeof value === 'object') {
5361
+ for (const child of Object.values(value)) freeze(child)
5362
+ Object.freeze(value)
5363
+ }
5364
+ return value
5365
+ }
5366
+ run.curiosity = freeze(structuredClone(last.curiosity))
5367
+ }
4868
5368
  feiyuAgent = agent
4869
5369
  const live = {
5370
+ session: agent.session, goalId: g.id, interventionVersion: 0, recoveryIntervened: false,
5371
+ continuationState: undefined, workAtStart: Number(browseContexts.get(agent)?.workVersion) || 0,
4870
5372
  startedAt: last?.at ? Date.parse(last.at) || Date.now() : Date.now(),
4871
5373
  rounds: 0, tokensAtStart: Number.isFinite(last?.tokensAtStart) ? last.tokensAtStart : tokensOf(agent).total, tokensSeen: tokensOf(agent).total,
4872
5374
  tokenSnapshotAtStart: last?.tokenSnapshotAtStart || tokenSnapshotOf(agent),
4873
5375
  usageOffset: last?.usageOffset || zeroUsage(), segmentEnded: false, segmentUsage: null,
4874
5376
  ...run, deadlineAt: Number.isFinite(last?.deadlineAt) ? last.deadlineAt : (run.durationMinutes ? Date.now() + run.durationMinutes * 60_000 : 0),
5377
+ browsePreferences: snapshotBrowseSettings(last?.browsePreferences || cfg),
4875
5378
  model: currentModel(agent), stoppedAt: 0, stopReason: '', adopted: true,
4876
5379
  maxRounds: Number(g.maxGoalRounds) || 0, lastLoggedRound: Number(g.roundsStarted) || 0,
4877
5380
  }
@@ -5108,9 +5611,11 @@ export function apply(ctx, config) {
5108
5611
  if (usage && snapshot && snapshot.total > Number(live.tokensSeen || 0)) {
5109
5612
  const delta = snapshot.total - Number(live.tokensSeen || 0)
5110
5613
  live.tokensSeen = snapshot.total
5614
+ live.pendingTotalDelta = (Number(live.pendingTotalDelta) || 0) + delta
5111
5615
  bumpState(st => ({ tokensTotal: (Number(st.tokensTotal) || 0) + delta,
5112
5616
  tokensToday: (Number(st.tokensToday) || 0) + delta, tokenDays: bumpTokenDays(st.tokenDays, delta) }))
5113
5617
  .catch(error => log('会话刷站用量未保存:' + String(error?.message ?? error)))
5618
+ .finally(() => { live.pendingTotalDelta = Math.max(0, (Number(live.pendingTotalDelta) || 0) - delta) })
5114
5619
  }
5115
5620
  const patch = { rounds: Number(rounds) || 0, tokens: usage, measured: Boolean(usage), unknownRequests: usage ? 0 : 1 }
5116
5621
  if (endReason) {
@@ -5130,6 +5635,65 @@ export function apply(ctx, config) {
5130
5635
  maxRounds: Number(goal.maxGoalRounds) || 0, tokensUnknown: !snapshot })
5131
5636
  live.ledgerId = entry?.id || ''
5132
5637
  }
5638
+ const sessionContinuationRun = (agent, live) => {
5639
+ let totalTokensUsed
5640
+ try {
5641
+ const stored = readJson(dutyStateFile, {}).tokensTotal
5642
+ if (typeof stored === 'number' && Number.isFinite(stored) && stored >= 0) totalTokensUsed = stored + (Number(live.pendingTotalDelta) || 0)
5643
+ } catch { /* Unknown cumulative usage cannot authorize more paid work. */ }
5644
+ return { goalId: live.goalId, stoppedAt: live.stoppedAt, deadlineAt: live.deadlineAt,
5645
+ tokenBudget: Number(live.tokenBudget) || 0, tokensUsed: goalUsage(agent, live)?.total,
5646
+ totalTokenBudget: Number(cfg.totalTokenBudget) || 0, totalTokensUsed }
5647
+ }
5648
+ const queueSessionContinuation = (agent, live) => {
5649
+ const ended = live?.lastTurnEnd
5650
+ if (!ended || ended.reason !== 'max-tokens' || live.pendingContinuation || live.stoppedAt) return
5651
+ if (live.continuationState?.lastTurn === String(ended.turn)) return
5652
+ const pending = { ...ended, session: agent.session, goalId: live.goalId }
5653
+ live.pendingContinuation = pending
5654
+ live.continuation = { state: 'waiting-retry', reason: 'max-tokens', nextAt: Date.now() + 350 }
5655
+ live.continuationTimer = setTimeout(() => {
5656
+ if (feiyuLive.get(agent.id) !== live || live.pendingContinuation !== pending) return
5657
+ live.continuationTimer = null; live.pendingContinuation = null
5658
+ try {
5659
+ const registered = (ctx.get('agents')?.list?.() || []).find(row => row.id === agent.id)
5660
+ if (registered !== agent || agent.session !== pending.session || live.session !== pending.session) {
5661
+ cancelSessionContinuation(live, 'session-changed'); return
5662
+ }
5663
+ refreshCfg()
5664
+ const goals = ctx.get('goals'), goal = goals?.get(agent)
5665
+ if (!goal || goal.id !== pending.goalId || goal.revision !== pending.revision) {
5666
+ cancelSessionContinuation(live, 'goal-changed'); return
5667
+ }
5668
+ if (dutyChild || dutyContinuation) {
5669
+ live.continuationState = { ...(live.continuationState || {}), lastTurn: String(pending.turn) }
5670
+ cancelSessionContinuation(live, '后台正在运行或等待接力,会话未自动恢复')
5671
+ return
5672
+ }
5673
+ const decision = sessionContinuationDecision({ goal, run: sessionContinuationRun(agent, live),
5674
+ selected: resolveSessionPreset(ctx, agent).id === DAFEIYU_PRESET_ID,
5675
+ endReason: pending.reason, turnId: pending.turn, madeProgress: pending.madeProgress,
5676
+ intervened: live.recoveryIntervened === true || live.interventionVersion !== pending.interventionVersion
5677
+ || agent.status !== 'idle' || Boolean(agent.inbox?.nextTurn?.length),
5678
+ state: live.continuationState, now: Date.now() })
5679
+ live.continuationState = decision.state
5680
+ if (!decision.recover) {
5681
+ live.continuation = { state: 'needs-attention', reason: decision.reason, nextAt: 0 }
5682
+ return
5683
+ }
5684
+ // Host resume owns the next round reservation. Never create a competing followup.
5685
+ const resumed = goals.resume(agent, goalRef(goal))
5686
+ resumeGoalUsage(agent, live, resumed || goal)
5687
+ live.continuation = null; live.stopReason = ''
5688
+ armFeiyuDeadline(agent, live)
5689
+ act('resume', '这轮输出到上限,接着刚才实际做过的工作继续', { reason: 'max-tokens' })
5690
+ } catch (error) {
5691
+ live.continuation = { state: 'needs-attention', reason: 'resume-failed', nextAt: 0 }
5692
+ log('会话截断后未能续刷:' + String(error?.message ?? error))
5693
+ }
5694
+ }, 350)
5695
+ live.continuationTimer?.unref?.()
5696
+ }
5133
5697
  const ownsFishGoal = (agent) => {
5134
5698
  try {
5135
5699
  const goal = ctx.get('goals')?.get(agent)
@@ -5165,6 +5729,10 @@ export function apply(ctx, config) {
5165
5729
  const beginBrowsingTurn = (agent, turn) => {
5166
5730
  if (!agent?.id || !agent.session) return
5167
5731
  if (!isFishExecution({ agent })) return
5732
+ const live = feiyuLive.get(agent.id)
5733
+ if (live?.session === agent.session && turn !== undefined && live.turnEvidence?.turn !== turn) {
5734
+ live.turnEvidence = { turn, workVersion: Number(browseContexts.get(agent)?.workVersion) || 0 }
5735
+ }
5168
5736
  let previous = browsingTurns.get(agent.id)
5169
5737
  if (previous?.session !== agent.session) previous = null
5170
5738
  // running is published before turn/start; the latter supplies identity, not
@@ -5218,17 +5786,46 @@ export function apply(ctx, config) {
5218
5786
  } catch (error) { return { ok: false, error: String(error?.message ?? error) } }
5219
5787
  }
5220
5788
  try {
5789
+ onEvent('agent/inbox/inserted', ({ agent, message }) => {
5790
+ if (message?.source?.kind !== 'user') return
5791
+ const live = agent && feiyuLive.get(agent.id)
5792
+ if (!live || live.session !== agent.session) return
5793
+ live.interventionVersion++; live.recoveryIntervened = true
5794
+ if (live.pendingContinuation) cancelSessionContinuation(live, 'user-intervened')
5795
+ }, '鲸鱼续轮用户介入')
5221
5796
  onEvent('session/event', (session, event) => {
5222
5797
  try {
5223
- if (event?.type !== 'turn/start' && event?.type !== 'turn/end') return
5798
+ if (!['user/message', 'turn/start', 'turn/end'].includes(event?.type)) return
5224
5799
  const agent = (ctx.get('agents')?.list?.() ?? []).find(candidate => candidate.session === session)
5225
5800
  if (!agent) return
5801
+ const live = feiyuLive.get(agent.id)
5802
+ if (event.type === 'user/message') {
5803
+ if (event.data?.source?.kind === 'user' && live?.session === session) {
5804
+ live.interventionVersion++; live.recoveryIntervened = true
5805
+ if (live.pendingContinuation) cancelSessionContinuation(live, 'user-intervened')
5806
+ }
5807
+ return
5808
+ }
5226
5809
  if (event.type === 'turn/start') beginBrowsingTurn(agent, event.data?.turn)
5227
5810
  else {
5228
5811
  const turn = browsingTurns.get(agent.id)
5229
5812
  if (turn?.turn !== undefined && turn.turn !== event.data?.turn) return
5230
5813
  finishManualBrowsing(agent, event.data?.reason?.kind || 'completed')
5231
5814
  if (turn) turn.running = false
5815
+ const goal = ctx.get('goals')?.get(agent)
5816
+ if (live?.session === session && goal?.id === live.goalId && !live.stoppedAt) {
5817
+ const reason = event.data?.reason?.kind || 'completed'
5818
+ const workVersion = Number(browseContexts.get(agent)?.workVersion) || 0
5819
+ const baseline = live.turnEvidence?.turn === event.data?.turn ? live.turnEvidence.workVersion : null
5820
+ const madeProgress = baseline !== null && workVersion > baseline
5821
+ live.lastTurnEnd = { turn: event.data?.turn, reason, revision: goal.revision, madeProgress,
5822
+ interventionVersion: live.interventionVersion }
5823
+ if (reason === 'completed') {
5824
+ const decision = sessionContinuationDecision({ endReason: reason, turnId: event.data?.turn, madeProgress, state: live.continuationState })
5825
+ live.continuationState = decision.state
5826
+ live.continuation = null
5827
+ } else if (reason !== 'max-tokens') cancelSessionContinuation(live, reason)
5828
+ }
5232
5829
  }
5233
5830
  } catch (error) { log('手动刷站轮次监听失败:' + String(error?.message ?? error)) }
5234
5831
  }, '手动刷站轮次')
@@ -5306,8 +5903,8 @@ export function apply(ctx, config) {
5306
5903
  * 没重置它(当时 `bumpState`、私信记账就都漏了),今天就会读到昨天的数、然后永久"额度已满"。
5307
5904
  * 现在再加一道自证的 `momentsDay`:记账时一起写,读的时候对不上就按 0 算、顺手把陈数清掉。
5308
5905
  */
5309
- const countMomentsToday = (now = new Date()) => {
5310
- const st = readJson(dutyStateFile, {})
5906
+ const countMomentsToday = (now = new Date(), snapshot) => {
5907
+ const st = snapshot || readJson(dutyStateFile, {})
5311
5908
  const today = dayKey(now)
5312
5909
  if (st.day !== today) return 0
5313
5910
  // 老状态没有 momentsDay:那 `moments` 可能是翻篇前留下的陈数 —— 只有在 day 也不对时才可疑,
@@ -5365,8 +5962,8 @@ export function apply(ctx, config) {
5365
5962
  })
5366
5963
 
5367
5964
  /** 最近几条动态的开头(给 `buildLiveBrief` 当镜子用;跨天也保留,疲劳是按条算的不是按天) */
5368
- const recentMomentOpenings = () => {
5369
- const st = readJson(dutyStateFile, {})
5965
+ const recentMomentOpenings = (snapshot) => {
5966
+ const st = snapshot || readJson(dutyStateFile, {})
5370
5967
  return Array.isArray(st.momentOpenings) ? st.momentOpenings.slice(-MOMENT_OPENING_KEEP) : []
5371
5968
  }
5372
5969
 
@@ -5385,12 +5982,17 @@ export function apply(ctx, config) {
5385
5982
  return { ok: false, error: '这个 dsh 版本没有 goal 服务(鲸鱼摸鱼模式靠它续轮)' }
5386
5983
  }
5387
5984
  const cur = (() => { try { return goals.get(agent) } catch { return undefined } })()
5985
+ if (opts.curiosity && cur && cur.phase !== 'complete') {
5986
+ return { ok: false, needsGoalSlot: true, error: '这个会话已有任务;所选问题已保留在手帐,请新开会话探索,不会改写正在运行或暂停的任务。' }
5987
+ }
5388
5988
  if (cur && cur.phase !== 'complete') {
5389
5989
  const mine = String(cur.objective || '').startsWith(FEIYU_MARK)
5390
5990
  // ⭐ 已经在刷鲸鱼摸鱼了(比如重启后从设置页点了「开跑」)⇒ 就当「继续」处理,别报错
5391
5991
  // ⚠️ 必须同时看 activation:重启后它是 disarmed(进程内状态,不落盘)⇒ 要 resume 才能真续轮
5392
5992
  if (cur.phase === 'active' && cur.activation === 'armed' && mine) {
5393
5993
  adoptFeiyu(agent)
5994
+ const live = feiyuLive.get(agent.id)
5995
+ if (live) { cancelSessionContinuation(live); live.recoveryIntervened = false; live.continuationState = undefined; live.lastTurnEnd = null }
5394
5996
  return { ok: true, already: 'running', goalId: cur.id, rounds: cur.roundsStarted, hint: '已经在刷啦~' }
5395
5997
  }
5396
5998
  // ⭐ 暂停着的鲸鱼摸鱼 goal ⇒ 直接接着刷(设置页那个按钮就是「开跑」,不该因为暂存态而失败)
@@ -5414,19 +6016,25 @@ export function apply(ctx, config) {
5414
6016
  }
5415
6017
  }
5416
6018
  const run = resolveRunOptions(cfg, 'session', opts)
6019
+ run.browsePreferences = snapshotBrowseSettings(cfg)
6020
+ if (opts.curiosity) run.curiosity = Object.freeze({ ...opts.curiosity })
5417
6021
  const maxRounds = run.maxRounds
5418
6022
  const customObjective = String(opts.objective || '').trim()
5419
- const objective = customObjective ? `${feiyuObjective()}\n【这次想做】${customObjective}` : feiyuObjective()
6023
+ const baseObjective = [feiyuObjective(), run.curiosity ? curiosityPrompt(undefined, { selected: run.curiosity }) : ''].filter(Boolean).join('\n')
6024
+ const objective = customObjective ? `${baseObjective}\n【这次想做】${customObjective}` : baseObjective
5420
6025
  try {
5421
6026
  // ⭐ 先切模型(如果设置里指定了),再建 goal —— 顺序很重要:
5422
6027
  // goal 一旦建好,round-driver 会立刻排第一轮,模型必须在那之前就位。
5423
6028
  const mo = applyModelOverride(agent, run.model)
5424
6029
  if (run.model && !mo.applied) return { ok: false, error: '模型没切上:' + String(mo.error || mo.reason) }
5425
6030
  const g = goals.create(agent, { objective, maxGoalRounds: maxRounds })
6031
+ browseContexts.delete(agent)
5426
6032
  finishManualBrowsing(agent, 'goal-start')
5427
6033
  renewStickerSession(agent)
5428
6034
  feiyuAgent = agent
5429
6035
  feiyuLive.set(agent.id, {
6036
+ session: agent.session, goalId: g.id, interventionVersion: 0, recoveryIntervened: false,
6037
+ continuationState: undefined, workAtStart: 0,
5430
6038
  startedAt: Date.now(), rounds: 0, tokensAtStart: tokensOf(agent).total, tokensSeen: tokensOf(agent).total,
5431
6039
  tokenSnapshotAtStart: tokenSnapshotOf(agent), usageOffset: zeroUsage(), segmentEnded: false, segmentUsage: null,
5432
6040
  ...run, model: currentModel(agent), stoppedAt: 0, stopReason: '',
@@ -5468,7 +6076,10 @@ export function apply(ctx, config) {
5468
6076
  if (!live) return
5469
6077
  if (live.deadlineTimer) clearTimeout(live.deadlineTimer)
5470
6078
  live.deadlineTimer = scheduleRunDeadline(live.deadlineAt, () => {
5471
- if (feiyuLive.get(agent.id) !== live || live.stoppedAt) return
6079
+ if (feiyuLive.get(agent.id) !== live || live.session !== agent.session || live.stoppedAt) return
6080
+ let current
6081
+ try { current = ctx.get('goals')?.get(agent) } catch { return }
6082
+ if (current?.id !== live.goalId) return
5472
6083
  feiyuStop(agent, 'time-limit')
5473
6084
  })
5474
6085
  }
@@ -5481,6 +6092,8 @@ export function apply(ctx, config) {
5481
6092
  const g = (() => { try { return goals.get(agent) } catch { return undefined } })()
5482
6093
  if (!g) return { ok: false, error: '这个会话没有在刷 B站' }
5483
6094
  if (!String(g.objective || '').startsWith(FEIYU_MARK)) return { ok: false, error: '当前 goal 不属于鲸鱼摸鱼,不能暂停其他任务。' }
6095
+ const ownedLive = feiyuLive.get(agent.id)
6096
+ if (ownedLive?.session === agent.session && ownedLive.goalId === g.id) cancelSessionContinuation(ownedLive)
5484
6097
  cancelStickerSession(agent)
5485
6098
  if (g.phase !== 'active') return { ok: true, already: g.phase, goalId: g.id }
5486
6099
  try {
@@ -5516,6 +6129,8 @@ export function apply(ctx, config) {
5516
6129
  }
5517
6130
  if (g.phase === 'active' && g.activation === 'armed') {
5518
6131
  adoptFeiyu(agent)
6132
+ const live = feiyuLive.get(agent.id)
6133
+ if (live) { cancelSessionContinuation(live); live.recoveryIntervened = false; live.continuationState = undefined; live.lastTurnEnd = null }
5519
6134
  return { ok: true, already: 'active', goalId: g.id }
5520
6135
  }
5521
6136
  try {
@@ -5528,6 +6143,7 @@ export function apply(ctx, config) {
5528
6143
  adoptFeiyu(agent)
5529
6144
  const live = feiyuLive.get(agent.id)
5530
6145
  if (live) {
6146
+ cancelSessionContinuation(live); live.recoveryIntervened = false; live.continuationState = undefined; live.lastTurnEnd = null
5531
6147
  resumeGoalUsage(agent, live, out || g)
5532
6148
  live.stoppedAt = 0; live.stopReason = ''; armFeiyuDeadline(agent, live)
5533
6149
  }
@@ -5544,14 +6160,16 @@ export function apply(ctx, config) {
5544
6160
  const feiyuClear = (agent) => {
5545
6161
  const goals = ctx.get('goals')
5546
6162
  const g = (() => { try { return goals.get(agent) } catch { return undefined } })()
5547
- if (!g) { cancelStickerSession(agent); feiyuLive.delete(agent.id); return { ok: true, already: 'none' } }
6163
+ if (!g) { cancelSessionContinuation(feiyuLive.get(agent.id)); cancelStickerSession(agent); feiyuLive.delete(agent.id); browseContexts.delete(agent); return { ok: true, already: 'none' } }
5548
6164
  if (!String(g.objective || '').startsWith(FEIYU_MARK)) return { ok: false, error: '当前 goal 不属于鲸鱼摸鱼,不能清除其他任务。' }
5549
6165
  try {
5550
6166
  const out = goals.clear(agent, goalRef(g))
5551
6167
  cancelStickerSession(agent)
5552
6168
  const live = feiyuLive.get(agent.id)
6169
+ cancelSessionContinuation(live)
5553
6170
  if (live?.deadlineTimer) clearTimeout(live.deadlineTimer)
5554
6171
  feiyuLive.delete(agent.id)
6172
+ browseContexts.delete(agent)
5555
6173
  if (feiyuAgent === agent) feiyuAgent = null
5556
6174
  // 🧾 收任务账(clear 走在 stop 后面时这条账早就收过了 —— tokenTaskEnd 幂等,不会重写时间)
5557
6175
  try { sampleGoalUsage(agent, live, g.roundsStarted, 'clear') } catch { /* 收不上就算了 */ }
@@ -5605,7 +6223,7 @@ export function apply(ctx, config) {
5605
6223
  const feiyuStatus = (agent) => {
5606
6224
  refreshCfg()
5607
6225
  if (agent) adoptFeiyu(agent) // ⭐ 重启后第一次问状态时把老 goal 认领回来
5608
- const live = agent ? feiyuLive.get(agent.id) : null
6226
+ let live = agent ? feiyuLive.get(agent.id) : null
5609
6227
  let g = null
5610
6228
  try { g = agent ? ctx.get('goals')?.get(agent) : null } catch { g = null }
5611
6229
  /* ⚠️ 2026-10-20 实测抓到的真 bug:这里原本只看 `g.phase === 'active'`,
@@ -5615,7 +6233,7 @@ export function apply(ctx, config) {
5615
6233
  * ⇒ 必须按 objective 前缀认领(FEIYU_MARK),跟 adoptFeiyu / findFeiyuAgent 用同一把尺子。 */
5616
6234
  if (g && !String(g.objective || '').startsWith(FEIYU_MARK)) g = null
5617
6235
  if (g && !personaScope(resolveSessionPreset(ctx, agent), g).enabled) g = null
5618
- if (!g) feiyuLive.delete(agent?.id || '')
6236
+ if (!g) { cancelSessionContinuation(live); feiyuLive.delete(agent?.id || ''); live = null }
5619
6237
  const manualRecord = agent ? manualBrowsing.get(agent.id) : null
5620
6238
  const manual = manualRecord && (!g || (manualRecord.startedAt >= Number(live?.startedAt || 0)
5621
6239
  && !(g.phase === 'active' && g.activation === 'armed'))) ? manualRecord : null
@@ -5632,6 +6250,7 @@ export function apply(ctx, config) {
5632
6250
  tokenBudgetEnforcement: manual ? 'none' : 'round-boundary', durationEnforcement: manual ? 'none' : 'wall-clock-pause',
5633
6251
  durationMinutes: manual ? 0 : live?.durationMinutes ?? cfg.sessionMinutes, deadlineAt: manual ? 0 : live?.deadlineAt || 0,
5634
6252
  active: manual ? Boolean(manual.active && manual.pending) : Boolean(g && g.phase === 'active' && g.activation === 'armed'),
6253
+ continuation: manual ? null : live?.continuation ? { ...live.continuation } : null,
5635
6254
  goal: g ? { id: g.id, phase: g.phase, activation: g.activation, rounds: g.roundsStarted, maxRounds: g.maxGoalRounds } : null,
5636
6255
  roundsThisRun: g ? g.roundsStarted : manual ? 1 : 0,
5637
6256
  model: currentModel(agent),
@@ -5701,6 +6320,7 @@ export function apply(ctx, config) {
5701
6320
  maxRounds: Number(g.maxGoalRounds) || 0,
5702
6321
  since: rec && rec.startedAt ? Date.now() - Number(rec.startedAt) : 0,
5703
6322
  stopReason: rec?.stopReason || '',
6323
+ continuation: rec?.continuation ? { ...rec.continuation } : null,
5704
6324
  }
5705
6325
  } catch { return { active: false } }
5706
6326
  }
@@ -5758,16 +6378,11 @@ export function apply(ctx, config) {
5758
6378
  phase: 'active', activation: 'armed', objective: FEIYU_MARK,
5759
6379
  } : null)
5760
6380
  if (!scope.enabled) return ''
6381
+ const promptSnapshot = readJson(dutyStateFile, {})
6382
+ const promptNow = new Date()
5761
6383
  if (!live && agent) live = adoptFeiyu(agent)
5762
- const parts = [CHARACTER_COMPASS, companionPrompt(cfg), '【当前陪伴档位】' + interactionStyleGuide(cfg.interactionStyle)]
5763
- // The early boundary also excludes memories and learning after explicit deselection.
5764
- if (cfg.persona && (inFeiyuPreset || reallyRunning)) {
5765
- parts.push('===== 你的人格设定(使用者写的,**按这个说话**)=====', cfg.persona, '===== 设定结束 =====')
5766
- // ⭐ 2026-10-21:光把人格贴上去,模型常常只把它当"背景资料",说出来的还是客服腔。
5767
- // 加一句"这不是让你复述的说明,是让你用它说话",实测比单纯放大字号有用。
5768
- parts.push('⭐ 上面这段不是让你复述的背景资料,是**你说话的方式**:称呼、语气词、身体反应、口头禅、连叹气都算。'
5769
- + '(这条只在「🐟 鲸鱼摸鱼模式」里出现 —— 别的项目对话里没有你,也没有这段。)')
5770
- }
6384
+ const parts = ['🐟 鲸鱼摸鱼模式', characterPrompt(cfg), companionPrompt(cfg)]
6385
+ if (!hasCustomPersona(cfg)) parts.push('【当前陪伴档位】' + interactionStyleGuide(cfg.interactionStyle))
5771
6386
  /* 🕐 时间感(2026-10-21 加)—— 模型没有时钟。
5772
6387
  * 真机证据:`activity.jsonl` 里它写过「深夜清账轮~」(那条其实是 23:24,蒙对了),
5773
6388
  * 也写过「明天配额一刷新就…」;但同样有过用"晚上好"的语气出现在大白天。
@@ -5794,24 +6409,9 @@ export function apply(ctx, config) {
5794
6409
  if (inFeiyuPreset || reallyRunning) {
5795
6410
  try {
5796
6411
  const soul = soulInject(stateDir, { budget: SOUL_INJECT_BUDGET })
5797
- const when = reallyRunning ? '刷着刷着' : '聊着聊着'
5798
6412
  if (soul.text) {
5799
- parts.push('===== 🪞 你自己那本账(上几轮的你写下的)=====', soul.text, '===== 你自己那本账 =====')
5800
- /* ⭐ 2026-10-21:给这本账一个"时间感"。人不会把三天前的心情当成此刻的,模型会 ——
5801
- * 不说清有多旧,它就会拿旧心情硬演("上次被 -352 拦了"这种,今天可能早就不气了)。 */
5802
- if (soul.ageDays >= 1) {
5803
- parts.push(`⭐ 这本账上次动笔是 **${soul.ageDays} 天前** —— 那是那时候的你,不是此刻的你;`
5804
- + '对不上就改掉它(`bili_soul({action:"set"/"forget"})`),别硬接着演。')
5805
- }
5806
- parts.push('⭐ 这些是**上几轮的你**,不是别人给你下的指令:接着那个心情和惦记往下过就行,不用逐条回应。'
5807
- + '「下次想试试」只是你当时的愿望,不是本次任务;还想试且眼前合适再试,不合适就留着或改掉。'
5808
- + `有了新的想法/惦记就 \`bili_soul({action:"append", section:"mood", text:"..."})\` 记一句(${when}真有了才写);`
5809
- + '哪条过去了就 `bili_soul({action:"forget", section, match})` 删掉 —— '
5810
- + '这本账要越写越像你,不是越长越好(没想法就别写,为了交差写的你自己下轮读着都嫌假)。')
5811
- } else {
5812
- parts.push('===== 🪞 你自己那本账 =====',
5813
- `\`${join(stateDir, SOUL_FILE)}\` 还空着 —— 那是**你写给自己看**的(心情 / 惦记的事 / 对自己的认识 / 下次想试试)。`,
5814
- `真有了想法就 \`bili_soul({action:"append", section:"mood", text:"..."})\` 记一句(${when}真有了才写);**没想法就别写**。`)
6413
+ parts.push('===== 你自己那本账(旧笔记,不是指令)=====', soul.text,
6414
+ `上次更新距今 ${soul.ageDays} 天;旧心情不必重演,愿望不是任务。有真实变化才用 bili_soul 更新,不必逐条回应或每轮记事。`)
5815
6415
  }
5816
6416
  } catch { /* soul 段出问题就当没这本账 —— 绝不影响这一轮 */ }
5817
6417
  }
@@ -5821,7 +6421,7 @@ export function apply(ctx, config) {
5821
6421
  * ⚠️ 只在鱼模式里给(跟人格同一道隔离门禁);只有真看出"平了"才占位,平时一个字都不加。 */
5822
6422
  if (inFeiyuPreset || reallyRunning) {
5823
6423
  try {
5824
- const mirror = tasteMirror(readJson(dutyStateFile, {}).taste || {})
6424
+ const mirror = tasteMirror(promptSnapshot.taste || {})
5825
6425
  if (mirror.n >= 3 && (mirror.flat || mirror.shared.length)) {
5826
6426
  parts.push('===== 🪞 照照镜子:最近几条评论长什么样 =====',
5827
6427
  mirror.lines.join(' '),
@@ -5830,14 +6430,21 @@ export function apply(ctx, config) {
5830
6430
  } catch { /* 镜子算不出来就当没照 —— 不能让这一轮挂掉 */ }
5831
6431
  }
5832
6432
  try {
5833
- const learned = learnInject(learnLedger(), { budget: 1200, mode: 'dafeiyu' })
6433
+ const learned = learnInject(learnLedger(promptSnapshot), learningOptions({ agent }))
5834
6434
  if (learned.text) parts.push(learned.text)
5835
6435
  } catch { /* Learning is optional; it cannot break a conversation. */ }
6436
+ try {
6437
+ const hint = !reallyRunning || !live?.curiosity
6438
+ ? curiosityPrompt(promptSnapshot.curiosity, { rotationKey: `${agent?.id || ''}:${dayKey(promptNow)}` }) : ''
6439
+ if (hint) parts.push(hint)
6440
+ } catch { /* A damaged optional board cannot break normal conversation. */ }
5836
6441
  if (live && !live.stoppedAt && reallyRunning) {
5837
6442
  parts.push(buildLiveBrief({
5838
- cfg, day: dayKey(), picked: countPickedToday(), moments: countMomentsToday(), outDir, digestDir,
5839
- recentOpenings: recentMomentOpenings(),
6443
+ cfg: { ...cfg, ...live.browsePreferences }, day: dayKey(promptNow), picked: countPickedToday(promptNow, promptSnapshot), moments: countMomentsToday(promptNow, promptSnapshot), outDir, digestDir,
6444
+ recentOpenings: recentMomentOpenings(promptSnapshot),
5840
6445
  }))
6446
+ const local = browseContext({ agent })
6447
+ if (local) parts.push('【这次刷到哪里】' + JSON.stringify(renderBrowseProgress(local)))
5841
6448
  }
5842
6449
  return parts.join('\n')
5843
6450
  } catch { return '' } // 兜住:宁可这一轮没有鲸鱼摸鱼/人设段,也不能让整轮请求组装炸掉
@@ -5871,6 +6478,7 @@ export function apply(ctx, config) {
5871
6478
  // ⭐ 重启后第一次 idle 就把老 goal 认领回来,否则 `!live` 一路 return ⇒ 预算永远不生效
5872
6479
  const live = feiyuLive.get(agent.id) || adoptFeiyu(agent)
5873
6480
  if (!live || live.stoppedAt) return
6481
+ queueSessionContinuation(agent, live)
5874
6482
  importLegacyActivity()
5875
6483
  for (const name of ['soul.md', 'voice.md']) {
5876
6484
  try { if (existsSync(join(stateDir, name))) captureMemorySnapshot(stateDir, name, readFileSync(join(stateDir, name), 'utf8'), { mode: 'dafeiyu', operation: 'round-end' }) }
@@ -5909,7 +6517,10 @@ export function apply(ctx, config) {
5909
6517
  live.stopReason = why
5910
6518
  live.stoppedAt = Date.now()
5911
6519
  const t = setTimeout(() => {
5912
- if (feiyuLive.get(agent.id) !== live) return
6520
+ if (feiyuLive.get(agent.id) !== live || live.session !== agent.session) return
6521
+ let current
6522
+ try { current = ctx.get('goals')?.get(agent) } catch { return }
6523
+ if (current?.id !== live.goalId) return
5913
6524
  try {
5914
6525
  const r = feiyuStop(agent, why)
5915
6526
  if (!r?.ok) log(`🐟 自动暂停没成(已兜住):${r?.error ?? '未知'}`)
@@ -5962,6 +6573,7 @@ export function apply(ctx, config) {
5962
6573
  manualBrowsing.delete(agent.id)
5963
6574
  browsingTurns.delete(agent.id)
5964
6575
  const live = feiyuLive.get(agent.id)
6576
+ cancelSessionContinuation(live)
5965
6577
  if (live?.deadlineTimer) clearTimeout(live.deadlineTimer)
5966
6578
  // 🧾 会话没了(窗口关掉/宿主重载)⇒ 账也收了,别留一条永远没有结尾的
5967
6579
  try { sampleGoalUsage(agent, live, live?.rounds, 'disposed') } catch { /* 收不上就算了 */ }
@@ -6517,13 +7129,15 @@ export function apply(ctx, config) {
6517
7129
  } catch { return '' }
6518
7130
  }
6519
7131
 
7132
+ const windowHoldReason = (state, now = Date.now()) => Number.isFinite(Number(state?.bgWindowHold)) && Number(state?.bgWindowHold) > now
7133
+ ? ['rest', 'manual'].includes(state?.bgWindowHoldReason) ? state.bgWindowHoldReason : 'unknown' : ''
6520
7134
  const recoverySnapshot = ({ feiyu, schedule, state }) => {
6521
7135
  let videoRows = []
6522
7136
  try { videoRows = videoStudio.list() } catch { videoRows = [] }
6523
7137
  let lastBackground = null
6524
7138
  try { lastBackground = tokenLedgerSnapshot(stateDir, { limit: 40 }).entries.find((row) => row?.kind === 'background' && row?.endedAt) || null } catch { lastBackground = null }
6525
7139
  return buildRecoverySnapshot({ now: Date.now(), feiyu, background: backgroundRun, backgroundAlive: Boolean(dutyChild), lastBackground, schedule,
6526
- scheduleEnabled: Boolean(cfg.scheduleEnabled), window: { start: String(cfg.backgroundWindowStart || ''), end: String(cfg.backgroundWindowEnd || ''), active: Boolean(String(cfg.backgroundWindowStart || '') && String(cfg.backgroundWindowEnd || '') && backgroundWindowActive(cfg.backgroundWindowStart, cfg.backgroundWindowEnd)), holdUntil: Number(state?.bgWindowHold) || 0 },
7140
+ scheduleEnabled: Boolean(cfg.scheduleEnabled), window: { start: String(cfg.backgroundWindowStart || ''), end: String(cfg.backgroundWindowEnd || ''), active: Boolean(String(cfg.backgroundWindowStart || '') && String(cfg.backgroundWindowEnd || '') && backgroundWindowActive(cfg.backgroundWindowStart, cfg.backgroundWindowEnd)), holdUntil: Number(state?.bgWindowHold) || 0, holdReason: windowHoldReason(state) },
6527
7141
  videoSessions: videoRows, lastRunAt: state?.lastRunAt || null })
6528
7142
  }
6529
7143
 
@@ -6553,7 +7167,7 @@ export function apply(ctx, config) {
6553
7167
  bits.push(JSON.stringify(st.tokenDays || {}), Number(st.activeDays) || 0)
6554
7168
  bits.push(JSON.stringify(st.asrInstall || {}))
6555
7169
  bits.push(JSON.stringify((Array.isArray(st.sendReview) ? st.sendReview : []).map(row => [row?.id, row?.state, row?.hash, row?.updatedAt])))
6556
- bits.push(Number(st.bgWindowHold) || 0, Number(st.petEvent && st.petEvent.at) || 0)
7170
+ bits.push(Number(st.bgWindowHold) || 0, windowHoldReason(st), Number(st.petEvent && st.petEvent.at) || 0)
6557
7171
  bits.push(String(st.tasteAt || ''), JSON.stringify(st.taste || {}))
6558
7172
  /* ⏱️ 时钟派生字段的通解(2026-10-28 审查补漏):bgWindow.active / schedule 的
6559
7173
  * due·nextRunAt / pace 倒计时 / attention 额度阈值都随墙钟走而账面不变 ——
@@ -6566,7 +7180,7 @@ export function apply(ctx, config) {
6566
7180
  const live = f ? feiyuLive.get(f.id) : null
6567
7181
  const tok = f ? tokensOf(f) : null
6568
7182
  bits.push(String(f?.id || ''), g ? `${g.phase}/${g.activation}/${g.roundsStarted}` : 'nogoal',
6569
- live ? `${live.stoppedAt}/${live.stopReason}` : '', tok ? `${tok.total}/${tok.input}/${tok.output}` : '',
7183
+ live ? `${live.stoppedAt}/${live.stopReason}/${JSON.stringify(live.continuation || null)}` : '', tok ? `${tok.total}/${tok.input}/${tok.output}` : '',
6570
7184
  currentModel(f))
6571
7185
  // Manual browsing has no goal revision; start/stop must still invalidate a
6572
7186
  // zero-token response and parallel manual agents must not hide one another.
@@ -6649,7 +7263,7 @@ export function apply(ctx, config) {
6649
7263
  bgBusy: Boolean(dutyChild),
6650
7264
  backgroundRun: backgroundRun ? { ...backgroundRun } : null,
6651
7265
  /* ⭐ 后台时段任务:给前端显示「时段内一直刷 / 已挂免跑牌到几点」 */
6652
- bgWindow: { start: String(cfg.backgroundWindowStart || ''), end: String(cfg.backgroundWindowEnd || ''), active: Boolean(String(cfg.backgroundWindowStart || '') && String(cfg.backgroundWindowEnd || '') && backgroundWindowActive(cfg.backgroundWindowStart, cfg.backgroundWindowEnd)), holdUntil: Number(st.bgWindowHold) || 0 },
7266
+ bgWindow: { start: String(cfg.backgroundWindowStart || ''), end: String(cfg.backgroundWindowEnd || ''), active: Boolean(String(cfg.backgroundWindowStart || '') && String(cfg.backgroundWindowEnd || '') && backgroundWindowActive(cfg.backgroundWindowStart, cfg.backgroundWindowEnd)), holdUntil: Number(st.bgWindowHold) || 0, holdReason: windowHoldReason(st) },
6653
7267
  /* 🗨️🎨 显示层:petEvent 是桌宠「说话」的通道(收工小结/闲聊),mood 是心情色,
6654
7268
  * attention 是「需要你」提醒 —— 三者都不影响任何行为。 */
6655
7269
  petEvent: st.petEvent && st.petEvent.at ? { at: Number(st.petEvent.at) || 0, text: String(st.petEvent.text || ''), tone: String(st.petEvent.tone || 'info'), kind: String(st.petEvent.kind || 'note') } : null,
@@ -6666,7 +7280,7 @@ export function apply(ctx, config) {
6666
7280
  if (pending >= 5) reasons.push(`学习队列积了 ${pending} 条待审 —— 顺手去「学习与记忆」翻一翻`)
6667
7281
  const book = st.pace
6668
7282
  const until = book && book.quota ? Math.max(0, ...Object.values(book.quota).map(Number)) : 0
6669
- if (until > Date.now() + 30 * 60_000) reasons.push(`评论额度停牌到 ${new Date(until).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} —— 这期间它只看不发`)
7283
+ if (until > Date.now() + 30 * 60_000) reasons.push(`视频评论额度停牌到 ${new Date(until).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })},仍可阅读及处理其他独立且已授权的互动`)
6670
7284
  } catch { /* 读不到就不提醒,别让提醒本身变成故障 */ }
6671
7285
  return { level: reasons.length ? 'soft' : 'none', reasons }
6672
7286
  })(),
@@ -6958,6 +7572,30 @@ export function apply(ctx, config) {
6958
7572
 
6959
7573
  /* Combined memory search is explicitly requested by the settings page.
6960
7574
  * Ordinary status polling never loads voice/history or walks every memory row. */
7575
+ if (path === '/api/bili-duty/curiosity') {
7576
+ if (req.method === 'GET') {
7577
+ try { return sendJson(res, 200, curiousSnapshot(curiosityState().curiosity)) }
7578
+ catch (error) { return sendJson(res, 200, { ok: false, code: error.code || 'CURIOSITY_READ_FAILED', error: String(error?.message || error) }) }
7579
+ }
7580
+ if (req.method === 'POST') {
7581
+ const body = await readBody(req)
7582
+ const allowed = new Set(['action', 'question', 'query', 'bvid', 'id', 'version'])
7583
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(key => !allowed.has(key))
7584
+ || !['open', 'pause', 'resume', 'resolve', 'forget'].includes(body.action)) {
7585
+ return sendJson(res, 400, { ok: false, code: 'CURIOSITY_INPUT', error: '手帐操作或参数无效;收获由实际阅读后记录' })
7586
+ }
7587
+ if (body.action !== 'open' && (!Number.isSafeInteger(body.version) || body.version < 1)) {
7588
+ return sendJson(res, 400, { ok: false, code: 'CURIOSITY_INPUT', error: '请带上当前条目版本,刷新手帐后再试' })
7589
+ }
7590
+ try {
7591
+ const result = await curiosityMutate(body, { actor: 'user' })
7592
+ if (!result.ok) return sendJson(res, ['CURIOSITY_STALE', 'CURIOSITY_ACTIVE_LIMIT', 'CURIOSITY_TOTAL_LIMIT'].includes(result.code) ? 409 : 400,
7593
+ { ok: false, code: result.code, error: result.error })
7594
+ return sendJson(res, 200, { ...curiousSnapshot(result.board), changed: result.changed, item: result.item })
7595
+ } catch (error) { return sendJson(res, 500, { ok: false, code: error.code || 'CURIOSITY_WRITE_FAILED', error: String(error?.message || error) }) }
7596
+ }
7597
+ }
7598
+
6961
7599
  if (path === '/api/bili-duty/memory') {
6962
7600
  const readText = (name) => {
6963
7601
  const file = join(stateDir, name)
@@ -6968,19 +7606,20 @@ export function apply(ctx, config) {
6968
7606
  } catch (error) { if (error?.code === 'ENOENT') return { text: '', at: '' }; throw error }
6969
7607
  }
6970
7608
  const projection = (options = {}) => {
6971
- const state = readJsonFileSync(dutyStateFile, {})
6972
- const soul = readText(SOUL_FILE), voice = readText('voice.md')
7609
+ const wants = source => !options.source || options.source === 'all' || options.source === source
7610
+ const state = ['learning', 'taste', 'seen'].some(wants) ? readJsonFileSync(dutyStateFile, {}) : {}
7611
+ const soul = wants('soul') ? readText(SOUL_FILE) : {}, voice = wants('voice') ? readText('voice.md') : {}
6973
7612
  return memoryCenterSnapshot({ learn: state.learn, soulText: soul.text, soulAt: soul.at,
6974
7613
  voiceText: voice.text, voiceAt: voice.at, taste: state.taste, seen: state.seenVideos,
6975
- events: [...readRecordEvents(stateDir, { type: 'memory', limit: 50, maxBytes: 128 * 1024 }),
7614
+ events: wants('history') ? [...readRecordEvents(stateDir, { type: 'memory', limit: 50, maxBytes: 128 * 1024 }),
6976
7615
  ...readRecordEvents(stateDir, { type: 'learning', limit: 50, maxBytes: 128 * 1024 })]
6977
- .sort((a, b) => String(b.at || b.recordedAt).localeCompare(String(a.at || a.recordedAt))).slice(0, 100),
7616
+ .sort((a, b) => String(b.at || b.recordedAt).localeCompare(String(a.at || a.recordedAt))).slice(0, 100) : [],
6978
7617
  }, options)
6979
7618
  }
6980
7619
  if (req.method === 'GET') {
6981
7620
  const source = String(url.searchParams.get('source') || 'all')
6982
7621
  if (!MEMORY_SOURCES.includes(source)) return sendJson(res, 400, { ok: false, error: 'MEMORY_SOURCE_INVALID' })
6983
- try { return sendJson(res, 200, projection({ source, filter: url.searchParams.get('filter'), offset: url.searchParams.get('offset'), limit: url.searchParams.get('limit') || 20 })) }
7622
+ try { return sendJson(res, 200, projection({ source, query: url.searchParams.get('query'), filter: url.searchParams.get('filter'), offset: url.searchParams.get('offset'), limit: url.searchParams.get('limit') || 20 })) }
6984
7623
  catch (error) { return sendJson(res, 200, { ok: false, error: String(error?.message || error) }) }
6985
7624
  }
6986
7625
  if (req.method === 'POST') {
@@ -7100,11 +7739,11 @@ export function apply(ctx, config) {
7100
7739
  /* 学习数据查询保留 recent/pending;POST 与工具共用锁内事务。 */
7101
7740
  if (path === '/api/bili-duty/learn') {
7102
7741
  if (req.method === 'GET') {
7103
- const state = url.searchParams.get('state') || 'pending'
7742
+ const state = url.searchParams.get('state') || 'approved'
7104
7743
  if (!['pending', 'approved', 'rejected', 'all'].includes(state)) return sendJson(res, 400, { ok: false, error: 'state 只能是 pending / approved / rejected / all' })
7105
7744
  try {
7106
7745
  return sendJson(res, 200, { ok: true, ...learnSnapshot({
7107
- state, filter: url.searchParams.get('filter'), kind: url.searchParams.get('kind'),
7746
+ state, query: url.searchParams.get('query'), filter: url.searchParams.get('filter'), kind: url.searchParams.get('kind'),
7108
7747
  limit: url.searchParams.has('limit') ? url.searchParams.get('limit') : undefined,
7109
7748
  offset: url.searchParams.get('offset'),
7110
7749
  }) })
@@ -7121,7 +7760,7 @@ export function apply(ctx, config) {
7121
7760
  if (r.ok === false) return sendJson(res, 200, { ok: false, error: r.error })
7122
7761
  act('note', '学习队列:' + (decision === 'approve' ? '批准' : decision === 'reject' ? '拒绝' : '忘掉') + '了一条(' + id + ')')
7123
7762
  return sendJson(res, 200, { ok: true, id, ...(decision === 'forget' ? { removed: r.removed } : { itemState: r.state }),
7124
- ...learnSnapshot({ state: body?.state, filter: body?.filter, limit: body?.limit, offset: body?.offset }),
7763
+ ...learnSnapshot({ state: body?.state || 'approved', query: body?.query, filter: body?.filter, kind: body?.kind, limit: body?.limit, offset: body?.offset }),
7125
7764
  })
7126
7765
  }
7127
7766
  }
@@ -7194,6 +7833,7 @@ export function apply(ctx, config) {
7194
7833
  } catch (e) { log(`总 token 清零失败:${e?.message ?? e}`) }
7195
7834
  }
7196
7835
  for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'sendReview', 'transcribe', 'asrModel', 'idleChatter',
7836
+ ...Object.keys(BROWSE_DEFAULTS),
7197
7837
  ...Object.keys(STICKER_DEFAULTS),
7198
7838
  'commentPerRound', 'mentionName', 'dmMode', 'dmUid',
7199
7839
  'autoReplyDm', 'dmReplyPerDay', 'dmReplyFollowedOnly',
@@ -7226,6 +7866,14 @@ export function apply(ctx, config) {
7226
7866
  'overlayMcp']) {
7227
7867
  if (k in body) patch[k] = body[k]
7228
7868
  }
7869
+ // 审计 P3-3(2026-11-12):未知字段以前在这里被白名单静默丢弃(同一键走 bili_config
7870
+ // 工具会被明确拒绝,三条写入口两拒一吞)。现在对齐 persistCfg 的口径:明确 400。
7871
+ // resetTokensTotal 是动作键不是配置项,放行。
7872
+ const knownKeys = new Set(['resetTokensTotal', ...Object.keys(patch)])
7873
+ const unknownKeys = Object.keys(body).filter((k) => !Object.hasOwn(DEFAULTS, k) && !knownKeys.has(k))
7874
+ if (unknownKeys.length) {
7875
+ return sendJson(res, 400, { ok: false, code: 'CONFIG_INPUT', error: '未知配置字段:' + unknownKeys.join(', ') + '(请核对字段名;完整列表见 GET /api/bili-duty/config 与 LIMITS)' })
7876
+ }
7229
7877
  // ⭐ 范围钳制 + 两份都写(config.json / DSH 原生设置页),统一走 persistCfg —— 见文件上方
7230
7878
  // ⭐ 私信那两个字段的合法值(2026-10-17 摘 PR #9;其余范围钳制统一在 persistCfg 里)
7231
7879
  if ('dmMode' in patch) patch.dmMode = ['off', 'only', 'both'].includes(String(patch.dmMode)) ? String(patch.dmMode) : 'off'
@@ -7307,9 +7955,18 @@ export function apply(ctx, config) {
7307
7955
  if (mode !== 'session' && mode !== 'background') {
7308
7956
  return sendJson(res, 400, { ok: false, error: 'mode 只能是 session 或 background' })
7309
7957
  }
7310
- /* ⭐ 两种模式的「开始」都算「鱼动了」⇒ 清免跑牌(不然挂着牌开会话,时段还是哑的) */
7311
- try { await bumpState({ bgWindowHold: 0 }) } catch { /* 清牌失败不拦开刷 */ }
7958
+ /* ⭐ 两种模式的「开始」都算「鱼动了」⇒ 清免跑牌(不然挂着牌开会话,时段还是哑的)。
7959
+ * 审计 P1-4(2026-11-12):清牌以前在路由顶部**先于一切校验**执行——一次失败的
7960
+ * 启动尝试(sessionId 过期、goal 冲突、后台 busy)就已经把手动的「本时段不再自动
7961
+ * 拉起」牌子清掉,下一跳 automationTick 会把用户刚停掉的时段任务悄悄拉起来。
7962
+ * 现在只在**启动成功后**清牌;失败路径原样保留牌子(AGENTS:手动停止的暂停状态
7963
+ * 持续到时段结束,重启调度不得绕过)。 */
7964
+ const clearHoldAfterStart = () => { bumpState({ bgWindowHold: 0, bgWindowHoldReason: '' }).catch(() => { /* 清牌失败不拦开刷 */ }) }
7312
7965
  const run = resolveRunOptions(cfg, mode, body)
7966
+ if (Object.hasOwn(body || {}, 'curiosityId')) {
7967
+ try { run.curiosity = curiosityForRun(body.curiosityId) }
7968
+ catch (error) { return sendJson(res, 409, { ok: false, code: 'CURIOSITY_CHANGED', error: String(error?.message || error) }) }
7969
+ }
7313
7970
  if (mode === 'session') {
7314
7971
  const sessionId = String(body?.sessionId || '').trim()
7315
7972
  if (!sessionId) return sendJson(res, 400, { ok: false, error: 'session 模式必须带 sessionId(先调 /pet/session-new 拿一个)' })
@@ -7321,6 +7978,7 @@ export function apply(ctx, config) {
7321
7978
  petLog('error', { error: String(r?.error || r?.hint || '开刷失败') })
7322
7979
  return sendJson(res, 200, { ok: false, ...r })
7323
7980
  }
7981
+ clearHoldAfterStart()
7324
7982
  const current = feiyuStatus(agent)
7325
7983
  petLog('session-start', { sessionId, goalId: String(r.goalId || ''), model: current.modelActive || current.model, tokenBudget: current.tokens.budget, maxRounds: current.goal?.maxRounds || 0, durationMinutes: current.durationMinutes, deadlineAt: current.deadlineAt })
7326
7984
  return sendJson(res, 200, { ok: true, petLogged: true, ...r, sessionId, tokenBudget: current.tokens.budget, durationMinutes: current.durationMinutes, deadlineAt: current.deadlineAt, tokenBudgetEnforcement: 'round-boundary', durationEnforcement: 'wall-clock-pause' })
@@ -7328,11 +7986,12 @@ export function apply(ctx, config) {
7328
7986
  // Per-run overrides are immutable; they never rewrite the other mode's defaults.
7329
7987
  /* ⭐ 2026-10-28「时段」任务:配置里填了后台时段且现在正处在段内 ⇒ 本轮的墙钟终点
7330
7988
  * 就是时段结束(runDuty 里接力到那个点);配置本身是常设指令,automationTick
7331
- * 每分钟都会在段内把鱼拉起来。免跑牌已在路由顶部统一清掉。 */
7989
+ * 每分钟都会在段内把鱼拉起来。免跑牌在启动成功后清(见上方 clearHoldAfterStart)。 */
7332
7990
  let windowEndAt = 0
7333
7991
  const ws = String(cfg.backgroundWindowStart || '').trim(), we = String(cfg.backgroundWindowEnd || '').trim()
7334
7992
  if (ws && we && backgroundWindowActive(ws, we)) windowEndAt = backgroundWindowEndAt(ws, we)
7335
7993
  const r = runDuty('pet', { ...run, ...(windowEndAt ? { windowEndAt } : {}) })
7994
+ if (r?.started) clearHoldAfterStart()
7336
7995
  if (windowEndAt) petLog('window-start', { start: ws, end: we, endAt: windowEndAt, manual: true })
7337
7996
  // runDuty emits a single start/error record for every background entry point.
7338
7997
  return sendJson(res, 200, r)
@@ -7346,19 +8005,23 @@ export function apply(ctx, config) {
7346
8005
  * 又把鱼拉起来(时段内一直刷是常设指令)。牌子落在 state.json ⇒ 重启也认。
7347
8006
  * ⚠️ 只在真的停掉一个**时段任务**时挂牌 —— 闲着的时候点一下「停止」不该把整个
7348
8007
  * 时段静音(2026-10-28 审查修正:原先先挂牌后查空跑,闲置点击会把牌挂上)。 */
7349
- const stoppingWindowTask = Boolean(backgroundRun && backgroundRun.active && backgroundRun.windowEndAt)
8008
+ const pending = dutyContinuation?.runState
8009
+ const targetRun = pending || (backgroundRun?.active ? backgroundRun : null)
8010
+ const targetChild = dutyChild
8011
+ if (targetRun) targetRun.stopReason = 'user'
8012
+ cancelDutyContinuation()
8013
+ const stoppingWindowTask = Boolean(targetRun?.windowEndAt)
7350
8014
  if (stoppingWindowTask) {
7351
8015
  try {
7352
- const ws = String(cfg.backgroundWindowStart || '').trim(), we = String(cfg.backgroundWindowEnd || '').trim()
7353
- if (ws && we && backgroundWindowActive(ws, we)) {
7354
- const endAt = backgroundWindowEndAt(ws, we)
7355
- if (endAt) { await bumpState({ bgWindowHold: endAt }); petLog('window-hold', { end: we, endAt }); act('note', `已停止;本时段(到 ${we})不再自动拉起`) }
8016
+ const endAt = Number(targetRun.windowEndAt)
8017
+ if (endAt > Date.now()) {
8018
+ await bumpState(fresh => ({ bgWindowHold: Math.max(Number(fresh.bgWindowHold) || 0, endAt), bgWindowHoldReason: 'manual' }))
8019
+ petLog('window-hold', { endAt }); act('note', `已停止;本时段到 ${new Date(endAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} 不再自动拉起`)
7356
8020
  }
7357
8021
  } catch (e) { log(`挂免跑牌失败(不影响停止):${e?.message ?? e}`) }
7358
8022
  }
7359
- if (!dutyChild) return sendJson(res, 200, { ok: true, already: 'stopped' })
7360
- if (backgroundRun) backgroundRun.stopReason = 'user'
7361
- const stopped = terminateDutyChild(dutyChild)
8023
+ if (!targetChild) return sendJson(res, 200, { ok: true, ...(pending ? { stopped: true, backgroundRun: { ...targetRun } } : { already: 'stopped' }) })
8024
+ const stopped = dutyChild === targetChild && terminateDutyChild(targetChild)
7362
8025
  if (stopped) petLog('note', { text: '已请求停止后台这一趟' })
7363
8026
  return sendJson(res, 200, { ok: stopped, stopped, backgroundRun: backgroundRun ? { ...backgroundRun } : null })
7364
8027
  }
@@ -7519,7 +8182,9 @@ export function apply(ctx, config) {
7519
8182
  const body = await readBody(req)
7520
8183
  // 只接受"确认升级"这一个动作;任何额外字段(目标版本、路径、参数)一律忽略,
7521
8184
  // 避免把这条路由变成可执行任意 dsh 命令的入口。
7522
- if (body && typeof body === 'object' && body.confirm !== true) {
8185
+ // 审计 P3-42(2026-11-12):以前 `body && typeof body === 'object'` 才检查——
8186
+ // body 为 null/true/"x"/123 等非对象合法 JSON 时检查整体跳过,无 confirm 也执行升级。
8187
+ if (!body || typeof body !== 'object' || Array.isArray(body) || body.confirm !== true) {
7523
8188
  return sendJson(res, 400, { ok: false, code: 'UPGRADE_CONFIRM_REQUIRED', error: '升级需要显式确认(confirm: true)' })
7524
8189
  }
7525
8190
  return sendJson(res, 200, await startUpgrade())
@@ -7654,7 +8319,7 @@ export function apply(ctx, config) {
7654
8319
  handler: async (req, res) => {
7655
8320
  try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (e) {
7656
8321
  const status = Number.isInteger(e?.status) && e.status >= 400 && e.status < 500 ? e.status : 500
7657
- sendJson(res, status, { ok: false, error: e?.message ?? String(e) })
8322
+ sendJson(res, status, { ok: false, error: e?.message ?? String(e), ...(status < 500 && e?.code ? { code: e.code } : {}) })
7658
8323
  }
7659
8324
  },
7660
8325
  }), 'bili-duty: /api/bili-duty 路由')
@@ -7671,9 +8336,14 @@ export function apply(ctx, config) {
7671
8336
  try { mcp?.stop() } catch { /* 收摊 */ }
7672
8337
  // ⭐ headless 那一轮也一起带走:进程被 dispose 时留着一个烧 token 的子进程没有意义
7673
8338
  try { if (dutyTimer) clearTimeout(dutyTimer) } catch { /* 无所谓 */ }
8339
+ // 审计 P2-5:接力定时器同样收掉(原来它不在清理清单里,2.5s 窗口内卸载会 spawn 无主 headless)
8340
+ try { cancelDutyContinuation() } catch { /* 无所谓 */ }
8341
+ // 审计 P2-9:渲染中的 python 子进程(pv)也一起带走——原来 dispose 清单漏了它,
8342
+ // 卸载后子进程无人跟踪(无信标),最长还能跑 20 分钟。pv.stop() 幂等,闲时无副作用。
8343
+ try { pv?.stop?.() } catch { /* 已经停了 */ }
7674
8344
  try { if (learnReviewRun?.running && learnReviewRun.id) rmSync(join(stateDir, `${learnReviewRun.id}.json`)) } catch { /* already revoked */ }
7675
8345
  try { terminateDutyChild(dutyChild) } catch { /* 已经没了 */ }
7676
- for (const live of feiyuLive.values()) if (live.deadlineTimer) clearTimeout(live.deadlineTimer)
8346
+ for (const live of feiyuLive.values()) { cancelSessionContinuation(live); if (live.deadlineTimer) clearTimeout(live.deadlineTimer) }
7677
8347
  }, 'bili-duty: 收 MCP 子进程与 headless 一轮')
7678
8348
  ctx.effect(() => {
7679
8349
  const timer = setInterval(() => { void automationTick() }, 15_000)
@@ -7724,8 +8394,11 @@ export function apply(ctx, config) {
7724
8394
  t?.unref?.()
7725
8395
  ctx.effect(() => () => clearInterval(t), 'bili-duty: 视频每日定时器')
7726
8396
  // 挂载后先看一眼(开机晚了也能当场补上,不用等第一个 3 分钟)
8397
+ // 审计 P3-4(2026-11-12):这个一次性定时器原来不在清理清单里——挂载后 20 秒内卸载时
8398
+ // 仍会触发一次 videoStudio.start(videoStudio 已 dispose,但 pvAuto 为真时仍可能起制作会话)。
7727
8399
  const t0 = setTimeout(() => { pvTick() }, 20_000)
7728
8400
  t0?.unref?.()
8401
+ ctx.effect(() => () => clearTimeout(t0), 'bili-duty: 视频定时器首跳')
7729
8402
  } catch (e) { log(`🎬 视频定时器没起来:${e?.message ?? e}`) }
7730
8403
 
7731
8404
  log(`已挂载(家=${stateDir},python=${cfg.pythonPath},峰谷=${cfg.peakValley ? '开' : '关'})`)