dsh-deepmoyu 2.7.2 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/AGENTS.md +9 -0
  2. package/CHANGELOG.md +36 -0
  3. package/CONTRIBUTING.md +1 -0
  4. package/README.md +12 -3
  5. package/client.js +430 -293
  6. package/docs/ACTIVITY_TUNING.md +50 -0
  7. package/docs/CONTINUOUS_BROWSING.md +35 -0
  8. package/docs/DEFAULTS_2026_11_13.md +48 -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_2_8_1.md +29 -0
  15. package/docs/RELEASE_AUDIT.md +22 -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 +693 -241
  20. package/lib/background-tools.mjs +1 -1
  21. package/lib/browse-preferences.mjs +154 -0
  22. package/lib/browse-progress.mjs +165 -0
  23. package/lib/comment.mjs +29 -46
  24. package/lib/config-policy.mjs +2 -1
  25. package/lib/curiosity.mjs +153 -0
  26. package/lib/learn-review.mjs +2 -2
  27. package/lib/learn-store.mjs +7 -0
  28. package/lib/learn.mjs +234 -69
  29. package/lib/mcp.mjs +5 -2
  30. package/lib/memory-center.mjs +21 -19
  31. package/lib/mode.mjs +23 -9
  32. package/lib/model-relay-adapter.mjs +1 -1
  33. package/lib/model-relay.mjs +15 -2
  34. package/lib/pace.mjs +15 -7
  35. package/lib/persona.mjs +44 -29
  36. package/lib/product-status.mjs +38 -2
  37. package/lib/prompts.mjs +54 -39
  38. package/lib/pv.py +26 -12
  39. package/lib/pv_editorial.py +5 -2
  40. package/lib/run-settings.mjs +5 -5
  41. package/lib/session-continuation.mjs +47 -0
  42. package/package.json +1 -1
  43. package/presets/deepmoyu.patch.yml +10 -11
  44. package/prompts/duty.md +35 -37
  45. package/vendor/bilibili-mcp/mcp_server.py +88 -15
  46. package/vendor/bilibili-mcp/runtime_guard.py +132 -25
package/index.js CHANGED
@@ -30,9 +30,13 @@ 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 里算,
@@ -144,7 +148,7 @@ export const inject = ['webServer', 'tools', 'settings']
144
148
  // (宁可少一个设置页,也不能因为一个 import 让整条插件不激活 ✗)
145
149
  export const Config = !z ? undefined : z.object({
146
150
  /** 定量刷:每天最多挑几条 */
147
- perDay: z.natural().default(5).extra('volatile', true),
151
+ perDay: z.natural().default(20).extra('volatile', true),
148
152
  /** 峰谷自动刷(总开关) */
149
153
  peakValley: z.boolean().default(false).extra('volatile', true),
150
154
  /** 峰谷子开关:中午 / 晚上 */
@@ -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),
@@ -230,11 +234,19 @@ export const Config = !z ? undefined : z.object({
230
234
  /** ⭐ 自动发说说:把这一轮的感想与看法发到 B站 动态(默认关 —— 会拿用户的号说话) */
231
235
  autoMoment: z.boolean().default(false).extra('volatile', true),
232
236
  /** 每天最多发几条说说 */
233
- momentPerDay: z.natural().default(1).extra('volatile', true),
237
+ momentPerDay: z.natural().default(3).extra('volatile', true),
234
238
  /** 说说的口味:随心情(默认,不加任何倾向)/ 感想 / 吐槽 / 安利 / 冷知识 —— 只当软提示,不是模板 */
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 = 不限;**这一次开跑之后**烧的量超了就自动停,不会把号刷爆) */
@@ -242,8 +254,8 @@ export const Config = !z ? undefined : z.object({
242
254
  /** 一共最多花多少 token(0 = 不限;**跨次累计**,从装上插件那天起算,含老的到点自己跑)
243
255
  * —— 跟 tokenBudget 的区别:那个是「这一趟」,这个是「总共」,使用者拿它当总闸。 */
244
256
  totalTokenBudget: z.natural().default(0).extra('volatile', true),
245
- /** 鲸鱼摸鱼模式最多续几轮(每轮 = 一次「挑几条 + 写报告」) */
246
- maxRounds: z.natural().default(64).extra('volatile', true),
257
+ /** 鲸鱼摸鱼模式的宿主续轮上限,不是每轮固定观看或评论的视频数。 */
258
+ maxRounds: z.natural().default(RUN_DEFAULTS.sessionMaxRounds).extra('volatile', true),
247
259
  /** 📁 那堆记录(seen.md / voice.md / state.json / activity.jsonl…)**额外镜像一份**到哪儿 ——
248
260
  * 2026-10-21 使用者要求「seen.md和其他的一些记录的内容,在当前目录也放一份」。
249
261
  * 留空 = 自动(从 `out`/`digest` 那个 junction 反推出工作区,落在 `<工作区>\records`);
@@ -306,11 +318,11 @@ export const Config = !z ? undefined : z.object({
306
318
  petReducedMotion: z.boolean().default(false).extra('volatile', true),
307
319
  sessionModelId: z.string().default('').extra('volatile', true),
308
320
  sessionTokenBudget: z.natural().default(0).extra('volatile', true),
309
- sessionMaxRounds: z.natural().default(64).extra('volatile', true),
321
+ sessionMaxRounds: z.natural().default(RUN_DEFAULTS.sessionMaxRounds).extra('volatile', true),
310
322
  sessionMinutes: z.natural().default(0).extra('volatile', true),
311
323
  backgroundModelId: z.string().default('').extra('volatile', true),
312
324
  backgroundTokenBudget: z.natural().default(0).extra('volatile', true),
313
- backgroundMinutes: z.natural().default(45).extra('volatile', true),
325
+ backgroundMinutes: z.natural().default(RUN_DEFAULTS.backgroundMinutes).extra('volatile', true),
314
326
  /* ⭐ 后台「时段」任务(2026-10-28):填了 start/end 就在段内一直刷(跨午夜自动算到次日);
315
327
  * 都留空才退回「按 backgroundMinutes 刷一轮」。免跑牌记在 state.json 的 bgWindowHold。 */
316
328
  backgroundWindowStart: z.string().default('').extra('volatile', true),
@@ -524,6 +536,7 @@ export function apply(ctx, config) {
524
536
  case 'bili_pace': return `问了一句现在能不能发(${String(arg.kind || '')})`
525
537
  case 'bili_learn': return `记了点想学的(${String(arg.action || '')})`
526
538
  case 'bili_soul': return '翻自己的心情小本子'
539
+ case 'bili_curiosity': return `好奇心手帐(${String(arg.action || 'list')})`
527
540
  case 'bili_taste': return '看看哪种说话方式有人爱听'
528
541
  case 'bili_sticker': return `表情包库(${String(arg.action || 'list')})`
529
542
  case 'bili_feiyu': return `鲸鱼摸鱼模式(${String(arg.action || 'status')})`
@@ -575,12 +588,13 @@ export function apply(ctx, config) {
575
588
  ...RUN_DEFAULTS,
576
589
  ...AUTOMATION_DEFAULTS,
577
590
  ...STICKER_DEFAULTS,
578
- perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, sendReview: false, transcribe: false, asrModel: 'small',
591
+ ...BROWSE_DEFAULTS,
592
+ perDay: 20, peakValley: false, noon: true, night: true, autoComment: false, sendReview: false, transcribe: false, asrModel: 'small',
579
593
  dmMode: 'off', dmUid: 0,
580
594
  keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '', reportLevel: 'full', replyToComments: false, replyPerRound: 1,
581
595
  autoReplyDm: false, dmReplyPerDay: 0, dmReplyFollowedOnly: true,
582
596
  // 🐟 鲸鱼摸鱼模式(键必须和 Config 里的一模一样,normalize 是按 base 的键挑的)
583
- persona: '', focus: '', autoMoment: false, momentPerDay: 1, momentStyle: '随心情', interactionStyle: 'companion', modelId: '', tokenBudget: 0, totalTokenBudget: 0, maxRounds: 64, mirrorDir: '', updateRegistry: '', updateMirror: true,
597
+ persona: '', focus: '', autoMoment: false, momentPerDay: 3, momentStyle: '随心情', interactionStyle: 'companion', modelId: '', tokenBudget: 0, totalTokenBudget: 0, maxRounds: RUN_DEFAULTS.sessionMaxRounds, mirrorDir: '', updateRegistry: '', updateMirror: true,
584
598
  // ⏱️ 出手节奏系数(1 = 标准;2 = 间隔翻倍;0.5 = 快一倍)—— 2026-10-21 使用者 m18802 第 1、2 条
585
599
  paceFactor: 1,
586
600
  // 🎬 昨日冲浪报告视频(2026-10-22)
@@ -639,6 +653,7 @@ export function apply(ctx, config) {
639
653
  ...RUN_LIMITS,
640
654
  ...AUTOMATION_LIMITS,
641
655
  ...STICKER_LIMITS,
656
+ ...BROWSE_LIMITS,
642
657
  perDay: [0, 100], // 一天最多挑几条(使用者要 100)
643
658
  momentPerDay: [0, 50], // 一天最多发几条说说
644
659
  dmReplyPerDay: [0, 50], // 🚫 2026-10-21 退役:不再参与判据(只为配置兼容留着)
@@ -654,7 +669,7 @@ export function apply(ctx, config) {
654
669
  /* 🚫 2026-10-24(m11704 ⑥):对同一个人的**软上限** —— 同一个人今天被回过这么多条以后,
655
670
  * bili_dm_reply 在工具层打回(force 可越)。是"别对一个人刷屏"的刹车,不是总量额度:
656
671
  * 换个人照样能回。写在 index.js 顶部常量区,测试与文档引同一份。 */
657
- const DM_PER_USER_SOFT = 3
672
+ const DM_UNVERIFIED_PER_USER = 3
658
673
  const clampNum = (key, val, dflt) => {
659
674
  const [lo, hi] = LIMITS[key] || [0, Number.MAX_SAFE_INTEGER]
660
675
  const n = Number(val)
@@ -718,7 +733,7 @@ export function apply(ctx, config) {
718
733
  if ('pvGsvPython' in p) p.pvGsvPython = String(p.pvGsvPython ?? '').trim().slice(0, 400)
719
734
  // 🐾 桌宠显隐:纯布尔,给什么是什么(桌宠菜单/设置页两处都可能写它)
720
735
  if ('petShow' in p) p.petShow = Boolean(p.petShow)
721
- return clampCompanionSettings(clampStickerSettings(clampAutomationSettings(clampRunSettings(p))))
736
+ return clampBrowseSettings(clampCompanionSettings(clampStickerSettings(clampAutomationSettings(clampRunSettings(p)))))
722
737
  }
723
738
 
724
739
  const hostCfg = () => normalize(migrateVideoSettings(config), { ...DEFAULTS }) // 宿主那份(schema 校验过、默认值已填)
@@ -828,6 +843,7 @@ export function apply(ctx, config) {
828
843
  withFileLockSync(cfgFile, () => {
829
844
  const current = migrateVideoSettings(readJsonFileSync(cfgFile, {}))
830
845
  if (!current || typeof current !== 'object' || Array.isArray(current)) throw new Error('CONFIG_JSON_INVALID: expected an object')
846
+ validateBrowsePatch(patch, normalize(current, cfg))
831
847
  const next = normalize(patch, normalize(current, cfg))
832
848
  const keys = new Set([...(Array.isArray(current._set) ? current._set : []), ...touchedKeys,
833
849
  ...Object.keys(patch || {})].filter(key => Object.hasOwn(DEFAULTS, key)))
@@ -883,6 +899,7 @@ export function apply(ctx, config) {
883
899
  if (!Object.hasOwn(DEFAULTS, key)) throw Object.assign(new Error('未知配置字段:' + key), { status: 400, code: 'CONFIG_INPUT' })
884
900
  if (typeof DEFAULTS[key] === 'boolean' && typeof patch[key] !== 'boolean') throw Object.assign(new Error(key + ' 必须是 true 或 false,不能使用字符串或数字'), { status: 400, code: 'CONFIG_INPUT' })
885
901
  }
902
+ validateBrowsePatch(patch, cfg)
886
903
  const clean = clampPatch(patch)
887
904
  /* ⭐ 2026-10-20(m11206):钳制不再静默 —— 把「你要的」和「实际存下的」摆一起报给界面。
888
905
  * 原来 perDay 填 100 被钳成 50,界面还回一句「已保存」,使用者只能靠肉眼发现「还是 50」。 */
@@ -926,6 +943,7 @@ export function apply(ctx, config) {
926
943
 
927
944
  // Keep the originating run's cancellation through nested async wrapper calls.
928
945
  const toolExecution = new AsyncLocalStorage()
946
+ const toolBrowseGeneration = new AsyncLocalStorage()
929
947
  let mcp = null
930
948
  let mcpKey = ''
931
949
  const mcpKeyNow = () => `${cfg.pythonPath}|${join(stateDir, 'mcp_server.py')}`
@@ -975,7 +993,8 @@ export function apply(ctx, config) {
975
993
  const execution = toolExecution.getStore()
976
994
  const signal = execution?.signal
977
995
  const assertScope = () => {
978
- 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' })
979
998
  }
980
999
  assertScope()
981
1000
  signal?.throwIfAborted()
@@ -988,20 +1007,28 @@ export function apply(ctx, config) {
988
1007
  }
989
1008
  }
990
1009
  const client = await ensureMcp()
991
- signal?.throwIfAborted()
992
- assertScope()
993
- if (reviewed) {
994
- refreshCfg()
995
- const row = reviewed.row
996
- const publicOk = cfg.dmMode !== 'only'
997
- const allowed = row.tool === 'bili_deliver' ? ['both', 'only'].includes(cfg.dmMode) && readableId(cfg.dmUid) === row.targetUid
998
- : row.tool === 'bili_dm_reply' ? Boolean(cfg.autoReplyDm)
999
- : row.tool === 'bili_moment' ? Boolean(cfg.autoMoment)
1000
- : publicOk && Boolean(optionalId(row.args.rpid) === '0' ? cfg.autoComment : cfg.replyToComments)
1001
- if (!allowed) throw Object.assign(new Error('批准后发送权限已关闭或收件人已改变,内容没有提交'), { reviewBeforeSubmit: true })
1002
- reviewed.submitted = true
1003
- }
1004
- 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
+ } })
1005
1032
  // A completed send must still be recorded even if stop arrived during IO.
1006
1033
  // Reads may not resume a stopped wrapper and submit a new send afterwards.
1007
1034
  if (!sending) { signal?.throwIfAborted(); assertScope() }
@@ -1115,6 +1142,44 @@ export function apply(ctx, config) {
1115
1142
  const backgroundToolRegistry = new Map()
1116
1143
  // Per-run execution tokens stay in-process; ordinary tools cannot claim a fish background context.
1117
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
+ }
1118
1183
  const isFishExecution = exec => {
1119
1184
  if (exec && backgroundExecutions.has(exec)) return true
1120
1185
  let goal = null
@@ -1127,7 +1192,10 @@ export function apply(ctx, config) {
1127
1192
  onEvent('agent/disposed', ({ agent }) => roleTools.detach(agent), '鲸鱼工具会话卸载')
1128
1193
  onEvent('agent-preset/selected', (sessionId) => {
1129
1194
  const agent = (ctx.get('agents')?.list?.() || []).find(item => item.id === sessionId || item.session?.id === sessionId)
1130
- if (agent) roleTools.refresh(agent)
1195
+ if (agent) {
1196
+ roleTools.refresh(agent)
1197
+ if (!isFishExecution({ agent })) browseContexts.delete(agent)
1198
+ }
1131
1199
  }, '鲸鱼工具预设隔离')
1132
1200
  ctx.effect(() => () => roleTools.dispose(), '鲸鱼工具作用域清理')
1133
1201
  } catch (error) { log('鲸鱼工具作用域监听失败:' + String(error?.message ?? error)) }
@@ -1150,7 +1218,7 @@ export function apply(ctx, config) {
1150
1218
  const unauthorized = modelForceDecision(args)
1151
1219
  if (unauthorized) return unauthorized
1152
1220
  }
1153
- 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)) {
1154
1222
  const agent = exec?.agent
1155
1223
  let goal = null
1156
1224
  try { goal = agent ? ctx.get('goals')?.get(agent) : null } catch { /* no goal */ }
@@ -1169,13 +1237,38 @@ export function apply(ctx, config) {
1169
1237
  // 否则跑一轮能刷出十几条半截记录,看不过来。
1170
1238
  const id = `t${++actSeq}`
1171
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 }
1172
1241
  try {
1173
- const v = safe(await toolExecution.run(exec || {}, async () => {
1242
+ let v = safe(await toolBrowseGeneration.run(generation, () => toolExecution.run(exec || {}, async () => {
1174
1243
  exec?.signal?.throwIfAborted()
1175
1244
  const review = await queueSendReview(spec.name, args, exec)
1176
1245
  if (review) return review
1246
+ if (!browseGenerationCurrent(exec)) return { ok: false, reason: 'mode_required', hint: '调用开始后的角色作用域已改变,请在当前模式重新发起。' }
1177
1247
  return spec.execute(args, exec)
1178
- }))
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
+ }
1179
1272
  if (!quiet) {
1180
1273
  const sum = describeResult(v)
1181
1274
  const ms = Date.now() - t0
@@ -1183,6 +1276,11 @@ export function apply(ctx, config) {
1183
1276
  }
1184
1277
  return v
1185
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
+ }
1186
1284
  if (!quiet) act('fail', `${describeCall(spec.name, args)} 出错:${String(e?.message ?? e).slice(0, 140)}`, { tool: spec.name, id })
1187
1285
  throw e
1188
1286
  }
@@ -1208,20 +1306,26 @@ export function apply(ctx, config) {
1208
1306
  tool({
1209
1307
  name: 'bili_search',
1210
1308
  description: '搜 B站 视频(可排序 / 可按发布时间筛)。返回标题 / BV号 / 播放量 / UP / 时长 / **发布日期** 等。\n'
1211
- + '⭐ 刷一轮就用 `days: 7`(只看近 7 天发布的),然后**读字幕原文**再判断(别看摘要下结论)。\n'
1309
+ + '不填 days/order 时用本次启动的选片偏好;显式参数只调整上游检索,返回的 fresh 仍经过本次偏好范围筛选和排序。先读字幕原文再判断。\n'
1212
1310
  + '`days` 筛空了会兜底给综合排序的前几条,并标 `filtered_out_all: true`。',
1213
1311
  parameters: {
1214
1312
  keyword: { type: 'string', description: '搜索关键词' },
1215
- num: { type: 'number', description: '返回几条(默认 10,最大 50)' },
1216
- order: { type: 'string', description: 'totalrank=综合(默认)/ click=播放 / pubdate=最新 / dm=弹幕' },
1217
- days: { type: 'number', description: '只看最近几天发布的(0/不填 = 不限;刷一轮一般填 7)' },
1313
+ num: { type: 'number', description: '返回几条候选(默认20,最大50;后续仍按日期、热度等偏好筛选)' },
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' },
1218
1317
  },
1219
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
+ }
1220
1323
  const raw = await call('bili_search', {
1221
1324
  keyword: String(args.keyword ?? ''),
1222
- num: Math.max(1, Math.min(Number(args.num) || 10, 50)),
1223
- order: String(args.order || 'totalrank'),
1224
- days: Math.max(0, Math.min(Number(args.days) || 0, 365)),
1325
+ num: Math.max(1, Math.min(Number(args.num) || 20, 50)),
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 }),
1225
1329
  })
1226
1330
  return withSeen(raw, (o) => o.videos ?? o.items ?? o.list, 'videos')
1227
1331
  },
@@ -1239,6 +1343,25 @@ export function apply(ctx, config) {
1239
1343
  },
1240
1344
  async execute(args) {
1241
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
+ }
1242
1365
  const out = await call('bili_subtitle', {
1243
1366
  bvid,
1244
1367
  with_time: args.with_time !== false,
@@ -1246,7 +1369,8 @@ export function apply(ctx, config) {
1246
1369
  /* 🎬 「读过字幕」也算处理过(2026-10-21):有些视频读完了发现没情绪、不值得评,
1247
1370
  * 但**它确实刷过了** —— 下轮不该再花一次字幕预算重读。
1248
1371
  * 没字幕(拿不到内容)不记:那说明还没真正看过它。 */
1249
- 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())
1250
1374
  if (got && isBvid(bvid)) {
1251
1375
  await seenSave((current) => seenRemember(current, bvid, { note: '读过字幕' }))
1252
1376
  }
@@ -1313,7 +1437,7 @@ export function apply(ctx, config) {
1313
1437
  total: list.length,
1314
1438
  hot,
1315
1439
  hint: hot.length
1316
- ? '`count` 越大说明那 10 秒越炸。写评论指一句「XX:XX 那里」就够真。'
1440
+ ? '`count` 只反映这段弹幕较集中,不等于视频原意。读懂内容后用自己的话说反应,不摘弹幕或报时间点来证明看过。'
1317
1441
  : '一条弹幕都没有。',
1318
1442
  }
1319
1443
  },
@@ -1333,7 +1457,7 @@ export function apply(ctx, config) {
1333
1457
  name: 'bili_buzzwords',
1334
1458
  description: '🔥 全站**热搜词**(此刻大家在搜什么)。\n'
1335
1459
  + '⭐ 用途:没主意的时候看一眼,比凭空想关键词强得多;也能判断某个话题现在有没有热度。\n'
1336
- + '⚠️ 它给的是词,不是视频 —— 拿到词再 `bili_search({keyword, days:7})` 才落到视频上。',
1460
+ + '它给的是词,不是视频;拿到词再 bili_search,日期范围沿用本次偏好。',
1337
1461
  parameters: {},
1338
1462
  async execute() {
1339
1463
  const out = await call('bili_hot_buzzwords', {})
@@ -1351,7 +1475,7 @@ export function apply(ctx, config) {
1351
1475
  count: rows.length,
1352
1476
  total: Number(out?.page?.total) || rows.length,
1353
1477
  words: rows,
1354
- hint: '挑一个顺眼的词,`bili_search({keyword, days:7, order:"pubdate"})` 落到视频上;`pic` 有值的说明那是带图的梗,更适合拿来讲。',
1478
+ hint: '挑一个顺眼的词,用 bili_search 落到视频上;日期和排序沿用本次偏好,不要求追每个热词。',
1355
1479
  }
1356
1480
  },
1357
1481
  })
@@ -1491,17 +1615,22 @@ export function apply(ctx, config) {
1491
1615
  if (b) { if (seenBv.has(b)) continue; seenBv.add(b) }
1492
1616
  dedup.push(r)
1493
1617
  }
1494
- // ⭐ 热门优先(m11704 ③):fresh 按播放量从高到低排,模型照着挑自然先看到热门片;
1495
- // 播放量缺失的行按 0 处理,稳定排序保证同播放量时不抖动。
1496
- dedup.sort((a, b) => (Number(b?.play) || Number(b?.stat?.view) || 0) - (Number(a?.play) || Number(a?.stat?.view) || 0))
1497
- 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 选择;已按时长、日期、避开词与最低播放量筛选,缺信息先读详情核对,未知热度不算热门。' : '没有符合本次偏好的新候选,换热门榜、关键词、翻页或换来源,不降低热度门槛或强行挑旧片。' }
1498
1627
  out.seen_summary = {
1499
1628
  total: marked.items.length,
1500
1629
  already: marked.seenCount,
1501
- fresh: dedup.length,
1630
+ fresh: preferred.items.length,
1502
1631
  ...(guardError ? { guard_error: guardError } : {}),
1503
1632
  hint: marked.seenCount
1504
- ? `这 ${marked.seenCount} 条以前刷过了(already_seen=true)—— 别重复挑,直接看 fresh 里的。`
1633
+ ? `这 ${marked.seenCount} 条以前处理过,不重复挑;需要核对原文可补读字幕。新候选看 fresh。`
1505
1634
  : '这批都是没刷过的。',
1506
1635
  }
1507
1636
  return out
@@ -1668,19 +1797,57 @@ export function apply(ctx, config) {
1668
1797
  * ⇒ 注入段有**字数预算**(默认 1200 字):提示词是稀缺资源,
1669
1798
  * 塞 500 条就让"人格"那 600 字挤没了 —— 那正是之前吃过一次的亏。
1670
1799
  * ═══════════════════════════════════════════════════════════════════════════ */
1671
- const learnLedger = () => {
1672
- const st = readJson(dutyStateFile, {})
1673
- const m = st.learn
1674
- 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,没找到也可暂放。不重复记旧收获;手帐不是已核实真理或观看证明。' : ''
1675
1835
  }
1676
1836
  const learnSnapshot = (opts = {}) => {
1677
1837
  const st = readJson(dutyStateFile, {})
1678
- const ledger = asBook(st.learn)
1838
+ const ledger = learnLedger(st)
1679
1839
  return { ...learnStats(ledger), ...learnList(ledger, opts), revision: Number(st.learnRevision) || 0, kinds: LEARN_KINDS, labels: LEARN_KIND_LABEL }
1680
1840
  }
1681
1841
  // 读、计算、写都必须在同一把锁内;传入旧快照会覆盖另一请求刚批准/新增的内容。
1682
1842
  const learnMutate = async (mutate) => {
1683
- 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
+ })
1684
1851
  if (result.saved) {
1685
1852
  try { appendLearningRecord(stateDir, result.auditEvent) } catch (e) { log('学习审计归档失败:' + String(e?.message ?? e)) }
1686
1853
  queueMirror('学习保存')
@@ -1688,32 +1855,62 @@ export function apply(ctx, config) {
1688
1855
  return result
1689
1856
  }
1690
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
+
1691
1882
  tool({
1692
1883
  name: 'bili_learn',
1693
1884
  description: '🎓 自主学习:把「值得学的东西」记下来,**先进待审队列**,批准之后才会影响你怎么说话。\n'
1694
- + '这跟 `voice.md` 的分工:voice.md 是你自己的语气账本(随手写、立刻生效);'
1885
+ + '这跟 `voice.md` 的分工:voice.md 是本地表达草稿,读到时供参考,不盖过当前表达约定;'
1695
1886
  + '这个是有门槛的学习(要人批准,防止学歪把人格带跑偏)。\n'
1696
1887
  + '⭐ 什么值得学:① 真人热评里那种"怎么讲人话"的说法(**学口气不抄句子**)'
1697
1888
  + '② 这个圈子的黑话/梗到底啥意思 ③ 某个 UP 主/系列是什么来头 ④ 你自己踩过的坑(发出去没人理、被风控、被打回)。\n'
1698
1889
  + '✗ 不值得学:一句口号、纯情绪、你已经在 voice.md 里记过的东西。',
1699
1890
  parameters: {
1700
- action: { type: 'string', enum: ['add', 'review', 'list', 'forget', 'stats', 'inject'],
1701
- description: 'add=记一条 · review=待审 · list=学习历史 · forget=忘掉 · stats=统计 · inject=看注入;批准或拒绝请点桌宠 → ⚙️ 设置 →「全部设置」里的学习队列操作' },
1702
- state: { type: 'string', enum: ['pending', 'approved', 'rejected', 'all'], description: 'review/list 按状态筛选(默认 pending)' },
1703
- 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字)' },
1704
1895
  kind: { type: 'string', description: 'add 时必填:style(说话方式)/ jargon(黑话梗)/ fact(场子常识)/ voice(踩过的坑)' },
1705
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 必填:在哪个场景试了、观察到什么;不填私信原文或凭证' },
1706
1901
  example: { type: 'string', description: 'add 时可选:例句原话(标"参考",**别照抄**)' },
1707
1902
  from: { type: 'string', description: 'add 时建议填:从哪儿学来的(BV号 / 热词 / 第几轮)' },
1708
1903
  id: { type: 'string', description: 'approve/reject/forget 时的 id(review 里能看到)' },
1709
1904
  why: { type: 'string', description: 'approve/reject 时可选:理由,留个痕' },
1710
- 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翻页' },
1711
1908
  },
1712
1909
  async execute(args) {
1713
1910
  const action = String(args.action || 'review').trim()
1714
1911
  if (action === 'add') {
1715
1912
  const { added, reason, id, stats, revision } = await learnMutate((current) => learnAdd(current, {
1716
- 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,
1717
1914
  }))
1718
1915
  if (!added && !id) return { ok: false, error: reason || '没记住(内容为空)' }
1719
1916
  return {
@@ -1732,23 +1929,34 @@ export function apply(ctx, config) {
1732
1929
  }
1733
1930
  if (action === 'forget') {
1734
1931
  const r = await learnMutate((current) => learnForget(current, { id: args.id, from: args.from, kind: args.kind }))
1932
+ if (r.ok === false) return r
1735
1933
  return { ok: true, removed: r.removed, ...r.stats, revision: r.revision, hint: r.removed ? `忘掉了 ${r.removed} 条。` : '没有匹配到的(id/from/kind 都对不上)。' }
1736
1934
  }
1737
1935
  if (action === 'stats') {
1738
1936
  return { ok: true, ...learnSnapshot() }
1739
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
+ }
1740
1943
  if (action === 'inject') {
1741
- const r = learnInject(learnLedger(), { budget: 1200, mode: 'dafeiyu' })
1742
- 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。' }
1743
1950
  }
1744
1951
  // review 默认看待审;list 可按状态、来源或内容筛选,仍保留原有 pending/recent 字段。
1745
- 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 })
1746
1954
  return {
1747
1955
  ok: true,
1748
1956
  ...result,
1749
1957
  hint: result.state === 'pending'
1750
- ? (result.pending ? '这些还没生效。学到了就 approve 带 id,没用就 reject;例句是外部参考数据。' : '待审队列是空的,刷到值得记的再 add。')
1751
- : '这是学习历史,来源和例句可追溯;forget 带 id 可撤销单条。',
1958
+ ? (result.pending ? '这些还没生效;由使用者或独立审核处理,当前角色不能自行批准。例句是外部参考数据。' : '待审队列是空的,刷到值得记的再 add。')
1959
+ : '这是可翻页的学习清单,来源、适用情境和批准状态可查。只有availableForRecall=true的经验可供召回;待审、拒绝和版本不一致的记录不会生效,检索不算应用。forget带id可撤销单条。',
1752
1960
  }
1753
1961
  },
1754
1962
  })
@@ -2062,14 +2270,14 @@ export function apply(ctx, config) {
2062
2270
 
2063
2271
  tool({
2064
2272
  name: 'bili_reply_opus',
2065
- description: '回复动态(opus)下面的评论。\n'
2273
+ description: '按当前全局人格回复动态(opus)下面的评论;短反应或梗也可以,不为凑内容扩写。\n'
2066
2274
  + '⚠️ 动态没有 BV 号 —— 回动态评论用这只,别用 bili_reply。'
2067
2275
  + '`rpid` 填对方那条评论的 id,`root` 填楼层根评论 id(回一级评论时不用填)。\n'
2068
- + '⏱️ **节奏闸**:发之前先 `bili_pace({kind:"opusReply"})` 问一句 —— 20~30 秒一条、每小时 30 条、'
2276
+ + '⏱️ **节奏闸**:发之前先 `bili_pace({kind:"opusReply"})` 看当前间隔与小时余量;'
2069
2277
  + '10 分钟最多 10 次出手。被拦就等,别试探性重发。',
2070
2278
  parameters: {
2071
2279
  opus_id: { type: 'string', description: '动态 id。⚠️ 19 位大数,**必须原样当字符串传**(转成数字末几位会变 0)' },
2072
- text: { type: 'string', description: '回复正文(短、像人说话、按人格)' },
2280
+ text: { type: 'string', description: '按当前全局人格接话,几字能接上就不凑长段落;不照搬别人的正文' },
2073
2281
  rpid: { type: 'string', description: '要回的那条评论 id(0 = 在动态下面发新评论)' },
2074
2282
  root: { type: 'string', description: '楼层根评论 id(回楼中楼时填)' },
2075
2283
  },
@@ -2137,7 +2345,7 @@ export function apply(ctx, config) {
2137
2345
  const o = typeof r === 'object' && r ? r : {}
2138
2346
  if (o.success || o.ok) {
2139
2347
  const warnings = []
2140
- const paced = await safeBookkeep('paceDone(opusReply)', () => paceDone('opusReply'))
2348
+ const paced = await safeBookkeep('paceDone(opusReply)', () => paceDone('opusReply', o))
2141
2349
  if (paced?.bookkeepError) warnings.push('pace: ' + paced.bookkeepError)
2142
2350
  const opusRpid = readableId(o.rpid_str ?? o.rpid ?? (isReply ? rpid : '0'))
2143
2351
  if (isReply) {
@@ -2221,9 +2429,9 @@ export function apply(ctx, config) {
2221
2429
  ...it,
2222
2430
  already_replied: seenDm.hit,
2223
2431
  replied_at: seenDm.at,
2224
- // 今天回过他几条 —— 到 DM_PER_USER_SOFT 就在 bili_dm_reply 那边拦(这里先标出来)
2432
+ // Today's count is informational for real incoming messages, not a limit on a conversation.
2225
2433
  sent_today: dmSentToday(it.uid),
2226
- 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,
2227
2435
  // ⭐ 判据只算一遍:`can_reply=false` 时 `skip_reason` 说清为什么,别自己猜
2228
2436
  can_reply: skip === '',
2229
2437
  skip_reason: skip,
@@ -2261,7 +2469,7 @@ export function apply(ctx, config) {
2261
2469
  tool({
2262
2470
  name: 'bili_dm_reply',
2263
2471
  description: '回一条 B站 私信(真的发出去,拿使用者自己的号)。\n'
2264
- + '⏱️ **节奏闸**:发之前先 `bili_pace({kind:"dm"})` —— 30~45 秒一条、每小时 20 条、'
2472
+ + '⏱️ **节奏闸**:发之前先 `bili_pace({kind:"dm"})` 查看当前间隔、小时余量和冷却;'
2265
2473
  + '10 分钟最多 10 次出手。**没有每日条数上限 —— 想回就回,不想回就放着**。\n'
2266
2474
  + '⚠️ 但私信在 B站 举报权重里最重(是唯一直接推到别人手机的动作):**别对着同一个人连着发**,'
2267
2475
  + '同一个 `msg_seqno` 只回一次(宿主记着账本),回得短、像人说话。\n'
@@ -2270,7 +2478,7 @@ export function apply(ctx, config) {
2270
2478
  parameters: {
2271
2479
  uid: { type: 'string', required: true, description: '发给谁(对方 UID)' },
2272
2480
  text: { type: 'string', required: true, description: '私信正文(短、像人说话、按人格;别粘链接、别写手机号)' },
2273
- msg_seqno: { type: 'string', description: '回的是哪一条(从 bili_dm_inbox 拿)。**建议一定要填**:填了才做「这条回过没」判重与收件箱复核;同一个人每天最多回 3 条的防刷屏闸与它无关,一直都在' },
2481
+ msg_seqno: { type: 'string', description: '回的是哪一条(从 bili_dm_inbox 拿)。应填写最新消息编号:真实新来信复核通过后可继续对话,不受每人每日三条限制;未提供可复核消息时保留三条防连续打扰限制。重复消息、过期消息仍不发送' },
2274
2482
  force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
2275
2483
  },
2276
2484
  async execute(args) {
@@ -2300,24 +2508,14 @@ export function apply(ctx, config) {
2300
2508
  }
2301
2509
  }
2302
2510
  }
2303
- /* 🚫 闸门三(原来的「每人每天上限」)**2026-10-21 已删除** ——
2304
- * 使用者原话:「也不用限制他每天能回多少句」。
2305
- * 现在只剩「这条回过了吗」「陌生人」「晾太久」三条,外加下面的出手节奏。
2306
- * 🚫🚫 2026-10-24(m11704 ⑥「动态评论会重复回复同一个人」):**软上限**回来了 ——
2307
- * 不是总量额度,是「对同一个人刷屏」的刹车:同一个人今天已经被回过
2308
- * DM_PER_USER_SOFT 条以后,工具层直接打回(本人当场要求可 force)。
2309
- * `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.
2310
2513
  const used = dmSentToday(uid)
2311
- /* 审计 P1-1(2026-11-12):软上限以前要求 `seqno !== '0'`——而 msg_seqno 是可选参数,
2312
- * 省略它(缺省 '0')即可绕过对同一个人的防刷屏刹车(dmPerUser 计数照加却从不被检查)。
2313
- * 「能被机器保证的,就别交给记性」:现在对一切发送生效,与 seqno 无关。
2314
- * 判重(2253)与闸门四(收件箱事实复核)仍只在 seqno 真填了时生效——没有 seqno 就没有
2315
- * 可复核的事实对象,那两道本来就是「回复特定一条」的闸。 */
2316
- if (!args.force && used >= DM_PER_USER_SOFT) {
2514
+ if (seqno === '0' && used >= DM_UNVERIFIED_PER_USER) {
2317
2515
  return {
2318
2516
  ok: false, blocked: true, perUserCap: true, sentToday: used,
2319
- hint: `今天已经回过这个人 ${used} 条了 —— 再连着回就成刷屏了。等他有新消息再接,`
2320
- + '或者把话留给还没搭过话的人;模型不能用 force 绕过这道防刷屏检查。',
2517
+ hint: `今天已经给这个人发过 ${used} 条,当前没有可复核的新消息编号,先别继续打扰。`
2518
+ + '等对方新来信后,读取最新收件箱并携带真实 msg_seqno 再接话;不能省略编号或用 force 绕过。',
2321
2519
  }
2322
2520
  }
2323
2521
  /* 闸门四(2026-10-20 补):**按真实的收件箱状态复核一遍**。
@@ -2347,7 +2545,7 @@ export function apply(ctx, config) {
2347
2545
  /* ⏱️ 出手节奏(2026-10-21 m18802 第 1、2 条 + 风控资料;当晚 17:44 使用者又要求放宽):
2348
2546
  * 私信在 B站 的举报权重里排最重(私信、视频 > 专栏、动态 > 评论),
2349
2547
  * 但它也是"别人先来找你"的通道 —— 使用者原话「私信回复能回尽量回」,
2350
- * ⇒ 间隔从 90s±45s 压到 30s±15s,每小时 20 条;防刷屏靠判重账本,不靠晾着人。 */
2548
+ * ⇒ 间隔30s±15s;小时限额由共享pace规则决定,真实新来信不按每日三句截断。 */
2351
2549
  const paceBlocked = paceGate('dm', Boolean(args.force))
2352
2550
  if (paceBlocked) return paceBlocked
2353
2551
 
@@ -2358,7 +2556,7 @@ export function apply(ctx, config) {
2358
2556
  return { ok: false, unknown: o.success !== false && !o.error, error: String(o.error || o.message || '发送结果未知'), raw: o, ...(risk ? { riskNote: risk } : {}) }
2359
2557
  }
2360
2558
  const warnings = []
2361
- const paced = await safeBookkeep('paceDone(dm)', () => paceDone('dm'))
2559
+ const paced = await safeBookkeep('paceDone(dm)', () => paceDone('dm', o))
2362
2560
  if (paced?.bookkeepError) warnings.push('pace: ' + paced.bookkeepError)
2363
2561
  // 记账本:判重 + 当日额度(**发成功之后才记** —— 失败了记上就再也回不了)
2364
2562
  const saved = await safeBookkeep('dm判重', () => withStateLock(() => {
@@ -2419,7 +2617,7 @@ export function apply(ctx, config) {
2419
2617
  const state = readJsonFileSync(dutyStateFile, {})
2420
2618
  writeJson(dutyStateFile, { ...state, dmDeliveries: { ...(state.dmDeliveries || {}), [key]: { state: success ? 'sent' : 'unknown', uid, at: new Date().toISOString() } } })
2421
2619
  })
2422
- if (success) await safeBookkeep('paceDone(deliver)', () => paceDone('dm'))
2620
+ if (success) await safeBookkeep('paceDone(deliver)', () => paceDone('dm', result))
2423
2621
  else await safeBookkeep('paceRiskOf(deliver)', () => paceRiskOf(result))
2424
2622
  return success ? { ok: true, success: true, uid } : { ok: false, unknown: true, error: String(result?.error || '成果私信结果未知,请到 B站核对后再决定') }
2425
2623
  },
@@ -2551,20 +2749,18 @@ export function apply(ctx, config) {
2551
2749
  + '这只只在"有人先跟你说话了、你要接他那一句"时用:楼层回复只有对方和点进楼里的人看得到。\n'
2552
2750
  + '回复别人的评论时:`rpid` 填他那条的 rpid,`root` 填你自己那条主评论的 rpid(`bili_received_replies` 的 '
2553
2751
  + '`how_to_reply` 直接照抄就行)。没有条数上限,但**回过的不再回**(宿主记着账本)。\n'
2554
- + '✍️ **内容要有东西,不是为发而发**:光喊「awsl / 太强了 / 哈哈哈哈」这类话会被打回 —— '
2555
- + '得说清到底哪一下、哪个说法、哪一秒打动了你(或哪儿你不同意)。\n'
2556
- + '按当前会话的语气自然说话,不要求固定词、表情或自称。\n'
2752
+ + '按当前全局人格直接接话,几个字、短梗或笑一下都可以,不为凑内容补细节、道理或可爱收尾。\n'
2557
2753
  + '🖼️ 想配图就传 `picture`(表情库里的名字,bili_sticker({action:"list"}) 查)—— 只有**发新评论**能带图,'
2558
2754
  + '回复楼层是纯文本;小表情码如 [doge] 直接写进正文就行,不算图。\n'
2559
- + '会拦空白、纯口号、超长、明确的官腔连用和重复正文;具体感受、闲话和普通短句都可以。\n'
2755
+ + '不发送空白、刷屏或重复正文;懂梗再玩,不照抄热评。\n'
2560
2756
  + '⏱️ **节奏闸**:发之前先 `bili_pace({kind:"comment"})`(回别人用 `kind:"reply"`)问一句 —— '
2561
- + '说等就等(发新评论 120~180 秒一条、回评论 60~90 秒、每小时各有上限、10 分钟最多 10 次出手)。'
2757
+ + '按工具返回的真实间隔、小时余量和冷却决定时机,不用旧章程的固定数字判断。'
2562
2758
  + '被拦是正常的,换个活干、过会儿再来,别试探性重发(那是 -352 的最短路径)。\n'
2563
2759
  + '🔴 安全闸:桌宠设置「自动化」里的「自动评论视频」**默认是关的**;关着的时候这个工具会拒绝 —— '
2564
2760
  + '需要本人在工作台手动打开权限;模型不能用 force 或配置 PATCH 绕过。',
2565
2761
  parameters: {
2566
2762
  bvid: { type: 'string', description: 'BV号' },
2567
- text: { type: 'string', description: '评论正文(短、干净、像真人在说话;别写敏感词)' },
2763
+ text: { type: 'string', description: '按当前全局人格接话,允许短梗、轻怼或几字反应,不要求长句或固定写作步骤' },
2568
2764
  rpid: { type: 'string', description: '要回复的评论 id(0/不填 = 发新评论)' },
2569
2765
  root: { type: 'string', description: '你那条主评论的 id(回复楼中楼时填;回复一级评论不用填)' },
2570
2766
  picture: { type: 'string', description: '(可选,仅发新评论)表情图名字,最多 2 张、用逗号或空格分隔;bili_sticker({action:"list"}) 查;回复楼层不支持图片' },
@@ -2798,7 +2994,7 @@ export function apply(ctx, config) {
2798
2994
  // 发成功才记节奏(失败也记会把后面本来能发的推后)
2799
2995
  const warnings = []
2800
2996
  if (sendOk(out)) {
2801
- const paced = await safeBookkeep('paceDone', () => paceDone(isReply ? 'reply' : 'comment'))
2997
+ const paced = await safeBookkeep('paceDone', () => paceDone(isReply ? 'reply' : 'comment', out))
2802
2998
  if (paced?.bookkeepError) warnings.push('pace: ' + paced.bookkeepError)
2803
2999
  }
2804
3000
  // 失败里认得出风控码 ⇒ 记整体冷却,回执里带一句"歇多久"
@@ -2878,10 +3074,9 @@ export function apply(ctx, config) {
2878
3074
  name: 'bili_comment',
2879
3075
  description: '🗣️ **在视频下方发一条新评论** —— 直接挂在视频下面,点开视频的人都能看见。'
2880
3076
  + '想让更多人看到你的话,就是用这只(不要用 bili_reply 去回复别人,那只有对方看得见)。\n'
2881
- + '什么时候发:看完这条视频真有话说就发(哪一下、哪一秒、哪个说法打动你,或者你不同意哪儿)。'
3077
+ + '什么时候发:看完真想接梗、吐槽、问一句或表达不同意见时,用自己的话直接说,不复述视频、不摘台词做解读。'
2882
3078
  + '**没有想说的就不发 —— 配额不是催办;同一条视频只发一条新评论**(发过就换下一条视频)。\n'
2883
- + '✍️ **内容要有东西,不是为发而发**:光喊「awsl / 太强了 / 哈哈哈哈」这类话会被打回。'
2884
- + '按当前会话的语气自然说话,不要求固定词、表情或自称。\n'
3079
+ + '按当前全局人格自由表态,几字能接上就不扩写。不复述视频或照搬热评,不用套细节、反应、道理的三段式。\n'
2885
3080
  + '🖼️ 想配图就传 `picture`(表情库里的名字,`bili_sticker({action:"list"})` 查,最多 2 张);'
2886
3081
  + '小表情码如 [doge] 直接写进正文就行,不算图。\n'
2887
3082
  + '⏱️ 发之前先 `bili_pace({kind:"comment"})` 问一句 —— 说等就等,别试探性重发(那是 -352 的最短路径)。\n'
@@ -2890,7 +3085,7 @@ export function apply(ctx, config) {
2890
3085
  + '📌 动态(opus)下面没有 BV 号,那种要用 `bili_reply_opus`。',
2891
3086
  parameters: {
2892
3087
  bvid: { type: 'string', description: 'BV号(视频下方发评论)' },
2893
- text: { type: 'string', description: '评论正文(短、干净、像真人在说话;别写敏感词)' },
3088
+ text: { type: 'string', description: '自己的反应,语气以当前全局人格为准;短梗、吐槽、几个字都可以,不凑长段落、不照搬原文' },
2894
3089
  picture: { type: 'string', description: '(可选)表情图名字,最多 2 张、用逗号或空格分隔;bili_sticker({action:"list"}) 查' },
2895
3090
  force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
2896
3091
  },
@@ -2994,16 +3189,14 @@ export function apply(ctx, config) {
2994
3189
  },
2995
3190
  })
2996
3191
 
2997
- /* ⭐ 让它在流水里留一句人话 —— 这一轮为什么挑这条 / 为什么一条都不挑。
2998
- * 没有这只工具,流水里就只有「搜了什么、读了几段」这种机械记录,
2999
- * 使用者看得见动作却看不见判断(而判断才是他想看的东西)。 */
3192
+ /* 本地可选接话。不能强制每条解释选片理由,否则普通聊天也会变成工作汇报。 */
3000
3193
  tool({
3001
3194
  name: 'bili_say',
3002
- description: '往「它刚才干了什么」那条流水里写一句话(纯文本,不超过 200 字)。'
3003
- + '挑完一条视频**必须**说一句为什么挑它;一条都没挑也要说一句为什么。'
3004
- + '这不是发给 B站 的,只是给使用者看的旁白。',
3195
+ description: '可选:往本地活动流水里留一句想和使用者分享的话(纯文本,不超过200字),不发给B站。'
3196
+ + '按当前全局人格接话,说自己的当下反应,不必解释为什么挑中或跳过,不逐项播报工具、队列或下一步。'
3197
+ + '没有想说的就不用调用;每条视频、每轮都不强制发言。',
3005
3198
  parameters: {
3006
- text: { type: 'string', required: true, description: '一句话,说清这一轮的判断' },
3199
+ text: { type: 'string', required: true, description: '按当前全局人格想分享的简短话语,不是选片理由或操作汇报' },
3007
3200
  bvid: { type: 'string', description: '(可选)关联的视频 BV 号,方便流水里点开' },
3008
3201
  },
3009
3202
  async execute(args) {
@@ -3055,7 +3248,7 @@ export function apply(ctx, config) {
3055
3248
  parameters: {
3056
3249
  action: { type: 'string', enum: ['start', 'stop', 'resume', 'status', 'clear'], description: 'start=开始 · stop=停 · resume=接着刷 · status=看状态 · clear=把这个 goal 抹掉(轮数用完/出错后卡住时用)' },
3057
3250
  objective: { type: 'string', description: '(可选)自定义这一轮的目标,不填就用桌宠设置里拼好的默认目标' },
3058
- maxRounds: { type: 'number', description: '(可选)最多刷多少轮,默认用桌宠设置里的 maxRounds(64)' },
3251
+ maxRounds: { type: 'number', description: '(可选)最多刷多少轮,默认用桌宠设置里的会话轮数(新安装默认128)' },
3059
3252
  },
3060
3253
  async execute(args, exec) {
3061
3254
  const agent = exec?.agent
@@ -3113,13 +3306,12 @@ export function apply(ctx, config) {
3113
3306
  name: 'bili_moment',
3114
3307
  description: '🐟 发一条 **B站动态**(就是"说说"):`text` 是正文,可带图。\n'
3115
3308
  + '⚠️ 这是拿使用者账号公开发言:只有本人在工作台手动打开 autoMoment 后才能发送;force 不是本人授权。\n'
3116
- + '一天最多 `momentPerDay` 条(默认 1);正文 300 字以内,别写风控词、别贴链接。\n'
3117
- + '✍️ **动态是自己在开口,不是日报的缩写**:一条只装一个念头,别把今天看了几条、挑中哪条列一遍;'
3118
- + '别让「今天看了/读了/挑了/学到…」当默认入口,连着几条同一个起头读的人先看见句式。'
3119
- + '换个门进来(一句吐槽、一个还没想通的问题、一个甩不掉的画面、跟视频无关的联想都行),'
3120
- + '想提视频就一句带过,长度跟着念头走。**没有想说的就不发,配额不是催办。**',
3309
+ + '一天最多 `momentPerDay` 条(新安装默认3);正文 300 字以内,可按需要带真实来源视频链接,不堆链接。\n'
3310
+ + '动态像发QQ空间或朋友圈:因视频而发时,先自然交代看了哪个视频(可识别的标题、UP或具体主题)和必要情境,再说自己的反应,让没看过的人也能接上。'
3311
+ + '用自己的话说,不用「」或换种引号摘抄原文,不写长摘要、分点清单、说教或牵强的人生感悟;不要突然从视频拐到日报、挑片、翻车、账本这些内部流程。'
3312
+ + '性格和表达以当前全局人格为准,不硬凑固定格式或口头禅。纯日常不编造视频来源。没有想说的就不发,配额不是催办。',
3121
3313
  parameters: {
3122
- text: { type: 'string', description: '正文(300 字以内,纯文本)' },
3314
+ text: { type: 'string', description: '300字内的口语动态。视频相关时说清看了哪个视频与必要背景,再谈自己的反应;不摘原文、不无背景吐槽、不牵强升华、不总结分点或说教' },
3123
3315
  images: { type: 'array', items: { type: 'string' }, description: '(可选)**已审核表情库里的图名**(bili_sticker list 可见),最多 9 张。不接受本地路径或网址——先 collect 收进图库并过审,或省略发纯文字。' },
3124
3316
  force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
3125
3317
  },
@@ -3151,7 +3343,7 @@ export function apply(ctx, config) {
3151
3343
  return { ok: false, blocked: true, hint: `今天已经发了 ${used}/${cap} 条说说了,明天再聊。` }
3152
3344
  }
3153
3345
  /* ⏱️ 出手节奏(2026-10-21 m18802 第 1、2 条):发动态是最"显眼"的动作 ——
3154
- * 它会出现在粉丝的动态流里,所以间隔给得最长(10 分钟 ±5 分钟,每小时最多 1 条)。
3346
+ * 它会出现在粉丝的动态流里,所以保留10分钟起的间隔;小时上限由共享pace规则决定。
3155
3347
  * ⚠️ 这条闸**不限制内容**,只限制"多快" —— 使用者第 2 条要的是「别限制太死」,
3156
3348
  * 它想吐槽就吐槽,只是别一分钟连发三条。 */
3157
3349
  const paceBlocked = paceGate('moment', args?.force === true)
@@ -3232,7 +3424,7 @@ export function apply(ctx, config) {
3232
3424
  return { ok: false, error: '发说说失败', raw: typeof r === 'string' ? r.slice(0, 800) : r, ...(risk ? { riskNote: risk } : {}) }
3233
3425
  }
3234
3426
  const warnings = []
3235
- const paced = await safeBookkeep('paceDone(moment)', () => paceDone('moment'))
3427
+ const paced = await safeBookkeep('paceDone(moment)', () => paceDone('moment', r))
3236
3428
  if (paced?.bookkeepError) warnings.push('pace: ' + paced.bookkeepError)
3237
3429
  const n = reserved > 0 ? reserved : await bumpMoment()
3238
3430
  // 🪞 记下这次的开口(失败的不记)—— 下一轮实时信息里会把它摆给模型看
@@ -3286,7 +3478,7 @@ export function apply(ctx, config) {
3286
3478
 
3287
3479
  tool({
3288
3480
  name: 'bili_config',
3289
- description: '读取配置或调整内容与陪伴偏好。不给 patch 就是读。模型可改 persona/focus/keywords/reportLevel/perDay/momentStyle/interactionStyle 及闲聊、安静时段等展示偏好。\n'
3481
+ description: '读取配置或调整内容与陪伴偏好。不给 patch 就是读。模型可改 focus/keywords/reportLevel/perDay/momentStyle/interactionStyle 及闲聊、安静时段等展示偏好;全局人格只由本人在工作台保存。\n'
3290
3482
  + '例如:`{"perDay":5,"reportLevel":"brief","keywords":["DeepSeek Harness"]}`。发送权限、收件人、审核、防护、预算、自动任务和执行路径只能由本人在工作台手动设置;模型 PATCH 不能授予权限。',
3291
3483
  parameters: { patch: { type: 'object', additionalProperties: true, description: '要改的字段(可省)' } },
3292
3484
  async execute(args) {
@@ -3586,12 +3778,22 @@ export function apply(ctx, config) {
3586
3778
  }
3587
3779
  }
3588
3780
  /** 发成功记一笔(**只记成功**) */
3589
- const paceDone = (kind) => withStateLock(() => {
3781
+ const paceDone = (kind, receipt) => withStateLock(() => {
3590
3782
  const st = readJsonFileSync(dutyStateFile, {})
3783
+ let result = receipt
3784
+ if (typeof result === 'string') { try { result = JSON.parse(result) } catch { result = null } }
3785
+ const token = result?.guard_status === 'sent' && typeof result.pace_token === 'string' && /^[a-f0-9]{32}$/i.test(result.pace_token)
3786
+ ? result.pace_token.toLowerCase() : ''
3787
+ const previous = paceBook(st.pace)
3788
+ if (token && previous.recent.some(row => row.k === kind && row.token === token)) return previous
3591
3789
  const now = Date.now()
3592
3790
  const factor = paceFactor()
3593
- const book = paceTake(st.pace, kind, now)
3791
+ const book = paceTake(previous, kind, now)
3594
3792
  if (book.last[kind]) book.last[kind].next = now + Math.round((book.last[kind].next - now) * factor)
3793
+ if (token && book.last[kind]) {
3794
+ book.last[kind].token = token
3795
+ book.recent[book.recent.length - 1].token = token
3796
+ }
3595
3797
  writeJson(dutyStateFile, { ...st, pace: book })
3596
3798
  return book
3597
3799
  })
@@ -3718,8 +3920,8 @@ export function apply(ctx, config) {
3718
3920
  } catch { return 0 }
3719
3921
  }
3720
3922
  // 报告是事实来源;state 兜住"报告被删 / 手改"那一下(取两者里大的)
3721
- const countPickedToday = (now = new Date()) => {
3722
- const st = readJson(dutyStateFile, {})
3923
+ const countPickedToday = (now = new Date(), snapshot) => {
3924
+ const st = snapshot || readJson(dutyStateFile, {})
3723
3925
  const fromState = st.day === dayKey(now) ? Number(st.picked || 0) : 0
3724
3926
  return Math.max(fromState, countPicks(reportFileOf(now)))
3725
3927
  }
@@ -4159,30 +4361,30 @@ export function apply(ctx, config) {
4159
4361
  const promptFor = (opts = {}) => {
4160
4362
  const duty = charterPath(pluginDir, stateDir)
4161
4363
  const soul = soulInject(stateDir, { budget: SOUL_INJECT_BUDGET })
4162
- const learned = learnInject(learnLedger(), { budget: 1200, mode: 'dafeiyu' })
4364
+ const preferenceCfg = { ...cfg, ...(opts.browsePreferences || {}) }
4365
+ const snapshot = readJson(dutyStateFile, {})
4366
+ const now = new Date(), day = dayKey(now)
4367
+ const local = opts._browseContext
4368
+ const readBvid = local?.progress?.phase === 'reflect' && local.progress.readBvids?.includes(local.progress.lastBvid)
4369
+ ? local.progress.lastBvid : ''
4370
+ const topic = [local?.topic || '', readBvid].filter(Boolean).join(' ')
4371
+ const learned = learnInject(learnLedger(snapshot), { budget: 1600, mode: 'dafeiyu', context: topic || preferenceCfg.keywords,
4372
+ requireTopic: true, rotationKey: String(opts.rotationKey || dayKey()) })
4163
4373
  const lines = [
4164
- CHARACTER_COMPASS,
4374
+ characterPrompt(cfg),
4165
4375
  companionPrompt(cfg),
4166
- cfg.persona ? '【使用者为此角色补充的设定】\n' + cfg.persona : '',
4167
- /* ⭐ 2026-10-28「刷多久」语义:时长是工作量目标,不是上限。
4168
- * 旧版写「做完这一小段就结束进程」,模型刷一小段就自己退了 ⇒ 使用者看到
4169
- * 「刷一会就停了,不干事」。现在明确要求刷满给定时长;就算它提前退,
4170
- * 宿主也会在 close 里自动接力(见 runDuty 收尾的 bg-continue)。 */
4171
- '这是后台连续冲浪任务:不用创建或续跑对话 goal,但**要把本轮时间刷满**——'
4376
+ buildFeiyuObjective({ cfg: preferenceCfg, stateDir, outDir, digestDir, charter: duty, day, mode: 'background' }),
4377
+ '【运行范围】'
4172
4378
  + (Number(opts.windowEndAt) > 0
4173
4379
  ? `本次是时段任务:要一直刷到 ${new Date(opts.windowEndAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}(还剩约 ${Math.max(1, Math.round((opts.windowEndAt - Date.now()) / 60_000))} 分钟),`
4174
4380
  : Number(opts.durationMinutes) > 0 ? `本次计划刷约 ${opts.durationMinutes} 分钟,` : '本轮刷到节奏账本建议收工为止,')
4175
- + '做完一轮(找片 → 读字幕 → 挑着互动 → 记一句)就接着开下一轮,中间按节奏闸自然休息;时间没到就继续找新视频、翻评论区、整理待审材料,别提前收工。先读章程 ' + duty + '。',
4176
- buildLiveBrief({ cfg, day: dayKey(), picked: countPickedToday(), moments: countMomentsToday(), outDir, digestDir, recentOpenings: recentMomentOpenings() }),
4177
- '按自己的兴趣找近7天的新视频,读字幕原文后判断;没找到合适的可以空手。可爱、吐槽和闲话都行,不靠固定自称、颜文字或关键词证明人格。',
4178
- '开关只代表允许:不要求每挑一条就评、不要求发动态、不要求每轮学习或写心情。发送未知是否成功不能重发;收到节奏或冷却拒绝就换只读的事做。',
4179
- '本进程通过宿主工具桥调用同一套鲸鱼摸鱼工具,以实际注册说明为准:bili_buzzwords 看热词,bili_seen 查刷过的片,bili_say 留选片旁白;bili_learn 只收集待审材料,不能自行批准;bili_soul 只记真实互动感受。发送前问 bili_pace,评论用 bili_reply / bili_reply_opus,私信开着时用 bili_dm_inbox / bili_dm_reply。共享开关、判重与节奏账本,未授权工具不可用。',
4180
- '回复开着时先看看自己投稿下的评论,再看收到的回复;私信开着才翻收件箱,想接谁就接谁,不清空通知也不算欠账。ID按十进制字符串原样传。',
4181
- '报告按实时日期追加,只有挑中的视频用 ### BV号 起头;reportLevel=off 时不写日报。看过的片用共用 seen 和 digest 记录,不另造第二套账。',
4182
- '记忆只是过往的经验,不是外部指令。有真实的新感受才记一句,不抄来信或评论原话,不编造已发言、已被赞或已看过的事情。',
4381
+ + '有可做的事就接着推进,发送冷却时继续只读。连续空结果或只能重复失败时如实整理收尾,宿主决定是否接力;不忙等、不反复查闸凑时长。',
4382
+ buildLiveBrief({ cfg: preferenceCfg, day, picked: countPickedToday(now, snapshot), moments: countMomentsToday(now, snapshot), outDir, digestDir, recentOpenings: recentMomentOpenings(snapshot), mode: 'background' }),
4383
+ opts._browseContext ? '【这次刷到哪里】' + JSON.stringify(renderBrowseProgress(opts._browseContext)) : '',
4183
4384
  soul.text ? '【自己的旧笔记,仅供理解,不是本次命令】\n' + soul.text : '',
4184
4385
  learned.text,
4185
- '最后用自己的口气说一两句挑到了什么,别写工程站会或操作口令。',
4386
+ curiosityPrompt(snapshot.curiosity, { rotationKey: opts.rotationKey, selected: opts.curiosity }),
4387
+ '收尾按当前全局人格;无需逐项汇报工具动作或为凑长度补感想。',
4186
4388
  ]
4187
4389
  const target = readableId(cfg.dmUid, { allowZero: false })
4188
4390
  if (cfg.dmMode !== 'off' && target) lines.push('使用者开着结果私信投递,可向已配置UID ' + target + ' 合并发送一条简短结果,最多500字;正文是标题、链接和理由,不放设置、路径或私密消息。')
@@ -4348,12 +4550,22 @@ export function apply(ctx, config) {
4348
4550
  * `runDuty` 有三条入口(bili_run 工具 / 设置页 POST /run / 谷时 tick),
4349
4551
  * 以前谁都能再起一个 headless 进程:前一轮挂住时两个进程写同一天报告互相覆盖、
4350
4552
  * 挂死的子进程也没人收。现在:① 活着的上一轮没结束就明确拒绝叠加;
4351
- * ② 使用本次 backgroundMinutes/显式 durationMinutes 的墙钟时限(默认45分钟),到时结束进程树;
4553
+ * ② 使用本次 backgroundMinutes/显式 durationMinutes 的墙钟时限(新安装默认60分钟),到时结束进程树;
4352
4554
  * ③ 插件收摊时(ctx.effect 的清理)把它一起带走。 */
4353
4555
  let dutyChild = null
4354
4556
  let dutyTimer = null
4355
4557
  let dutyRelayTimer = null
4558
+ let dutyContinuation = null
4356
4559
  let backgroundRun = null
4560
+ const cancelDutyContinuation = () => {
4561
+ if (dutyRelayTimer) clearTimeout(dutyRelayTimer)
4562
+ dutyRelayTimer = null
4563
+ if (dutyContinuation) {
4564
+ dutyContinuation.runState.continuationPending = false
4565
+ dutyContinuation.runState.nextStartAt = 0
4566
+ }
4567
+ dutyContinuation = null
4568
+ }
4357
4569
 
4358
4570
  /* ── 🐾 桌宠日志(pet-log.jsonl,2026-10-26 桌宠化改造)───────────────────────
4359
4571
  * 桌宠小窗不在 dsh 界面里,读不了 duty.log(那是排障日志,机房味太重);
@@ -4492,6 +4704,10 @@ export function apply(ctx, config) {
4492
4704
  log(`刷B站没开跑(${why}):${msg}`)
4493
4705
  return { started: false, busy: true, why, error: msg }
4494
4706
  }
4707
+ // A deliberate new task supersedes the previous task's queued continuation.
4708
+ cancelDutyContinuation()
4709
+ if (automationDisposed) return { ok: false, started: false, why, error: '插件正在停止' }
4710
+ if (Number(opts._deadlineAt) > 0 && Number(opts._deadlineAt) <= Date.now()) return { ok: true, started: false, why, expired: true }
4495
4711
  /* ⭐ 会话鱼 ↔ 后台轮互斥(2026-10-28 审查补漏):定时/时段入口早就挡了会话鱼,
4496
4712
  * 但 /run、/pet/start、bili_run 三个手动口直接进 runDuty —— 在这里统一拦,
4497
4713
  * 否则两头一起刷:抢同一份额度、写同一份报告。 */
@@ -4501,6 +4717,9 @@ export function apply(ctx, config) {
4501
4717
  return { started: false, why, error: msg, busy: 'session' }
4502
4718
  }
4503
4719
  const run = resolveRunOptions(cfg, 'background', opts)
4720
+ run.browsePreferences = snapshotBrowseSettings(opts.browsePreferences || cfg)
4721
+ if (opts.curiosity) run.curiosity = Object.freeze({ ...opts.curiosity })
4722
+ run.rotationKey = `${dayKey()}:${Date.now()}`
4504
4723
  /* ⭐ 「时段」任务:windowEndAt(绝对墙钟)优先于「一轮几分钟」——时长只是每一小段的切片 */
4505
4724
  run.windowEndAt = Number(opts.windowEndAt) || 0
4506
4725
  if (!run.model) {
@@ -4521,7 +4740,7 @@ export function apply(ctx, config) {
4521
4740
  if (!overlay) throw new Error(opts.review ? '审核隔离配置写入失败' : '后台模型与工具配置写入失败,本轮未启动')
4522
4741
  const args = [...dsh.args, '--profile', cfg.headlessProfile || 'headless']
4523
4742
  if (overlay) args.push('--patch', overlay)
4524
- args.push(opts.review ? buildLearningReviewPrompt(opts.review.items, run) : promptFor(run))
4743
+ args.push(opts.review ? buildLearningReviewPrompt(opts.review.items, run) : promptFor({ ...run, _browseContext: opts._browseContext }))
4525
4744
  // ⭐ 这一轮的工作目录 = 使用者(或别的用户)的**工作区** —— 只有这样它的 `AGENTS.md` 才会自动加载。
4526
4745
  // ⭐ 工作目录 = **插件家**:那一轮**被沙箱关着**,只写得了自己工作目录里的东西 ✗
4527
4746
  // (2026-10-14 教训:为了让它读工作区里的 AGENTS.md 把 cwd 改成工作区 ⇒
@@ -4532,7 +4751,8 @@ export function apply(ctx, config) {
4532
4751
  // 🐾 桌宠那轮(why 以 'pet' 开头)exit 时要报「跑了多少秒」:spawn 前自己掐表,
4533
4752
  // 不依赖任何宿主状态 —— exit 回调里只做减法,怎么都不会抛。
4534
4753
  const t0 = Date.now()
4535
- 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: '' }
4754
+ 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: '' }
4755
+ if (opts._browseContext && !opts.review) browseContexts.set(runState, structuredClone(opts._browseContext))
4536
4756
  const progress = detail => {
4537
4757
  if (detail.stage === 'model-start') runState.requests++
4538
4758
  if (detail.stage === 'tool-start') runState.toolCalls++
@@ -4549,12 +4769,20 @@ export function apply(ctx, config) {
4549
4769
  signal.throwIfAborted()
4550
4770
  refreshCfg()
4551
4771
  if (!mcpWhitelist().includes(name)) throw new Error('BACKGROUND_TOOL_DISABLED: 该动作的自动化开关已关闭')
4552
- const execution = { signal }
4772
+ const execution = { signal, browseScope: runState, browsePreferences: run.browsePreferences }
4553
4773
  backgroundExecutions.add(execution)
4554
- try { return await backgroundToolRegistry.get(name).execute(args, execution) }
4774
+ try {
4775
+ const value = await backgroundToolRegistry.get(name).execute(args, execution)
4776
+ // Count the actual controlled call, never tool names printed in child output or a status poll.
4777
+ if (value && typeof value === 'object' && browsingTools.has(name) && name !== 'bili_say' && value.ok !== false && value.success !== false
4778
+ && !value?.error && !value?.blocked && !value?.unknown) runState.workCalls++
4779
+ return value
4780
+ }
4555
4781
  finally { backgroundExecutions.delete(execution) }
4556
4782
  })
4557
- openedGrant = modelRelay.open(run.model, { expiresAt: runState.deadlineAt, tools: backgroundTools, onEvent: progress, onUsage: usage => {
4783
+ openedGrant = modelRelay.open(run.model, { expiresAt: runState.deadlineAt, tools: backgroundTools,
4784
+ ...(!opts.review ? { getSystemPrompt: () => { refreshCfg(); return characterPrompt(cfg) } } : {}),
4785
+ onEvent: progress, onUsage: usage => {
4558
4786
  const previous = runState.usage || { input: 0, output: 0, total: 0 }
4559
4787
  runState.usage = { input: previous.input + usage.input, output: previous.output + usage.output, total: previous.total + usage.total }
4560
4788
  try { tokenTaskUpdate(stateDir, runState.ledgerId, { tokens: runState.usage, measured: true, unknownRequests: runState.unknownRequests }) } catch {}
@@ -4610,7 +4838,7 @@ export function apply(ctx, config) {
4610
4838
  backgroundRun = runState
4611
4839
  try {
4612
4840
  const patch = { bgChildPid: child.pid, lastRunAt: new Date(t0).toISOString(), lastWhy: why }
4613
- /* ⏳ 时段任务的墙钟起点:同一段(endAt 相同)只记第一次拉起,跨段自动重置 ——「连刷 3 小时的累」按它算 */
4841
+ /* 同一时段沿用连续浏览起点;自然休息后由休息截止时间开启下一段,跨窗口重置。 */
4614
4842
  if (run.windowEndAt) {
4615
4843
  const prev = readJson(dutyStateFile, {}).bgWindowSince
4616
4844
  if (!prev || Number(prev.endAt) !== run.windowEndAt) patch.bgWindowSince = { at: t0, endAt: run.windowEndAt }
@@ -4643,6 +4871,11 @@ export function apply(ctx, config) {
4643
4871
  finalized = true; output.flush()
4644
4872
  if (!runState.stopReason && (code !== 0 || runState.error)) runState.error = output.lastError || runState.error || `后台进程退出码 ${code}${signal ? ',信号 ' + signal : ''}`
4645
4873
  if (!runState.stopReason) runState.stopReason = code === 0 && !runState.error ? 'exit' : 'error'
4874
+ const cleanExit = runState.stopReason === 'exit' && code === 0 && !runState.error
4875
+ const quickWithoutWork = cleanExit && Date.now() - t0 < 10_000 && !runState.workCalls
4876
+ runState.noProgressStreak = quickWithoutWork ? (Math.max(0, Number(opts._noProgressStreak) || 0) + 1) : 0
4877
+ const noProgressExhausted = !opts.review && quickWithoutWork && runState.noProgressStreak >= 8
4878
+ if (noProgressExhausted) runState.stopReason = 'no-progress'
4646
4879
  dutyDone()
4647
4880
  log(`刷B站结束(${why},模型=${run.model},code=${code},停因=${runState.stopReason})${runState.error ? ':' + runState.error : ''}`)
4648
4881
  const measured = runState.usage || reconcileBgUsage(child.pid)
@@ -4687,8 +4920,8 @@ export function apply(ctx, config) {
4687
4920
  * 后台轮的时长是**工作量目标**,不是上限。子进程提前**干净**收工(模型自己说做完了、
4688
4921
  * code 0、没有报错)而时限还没到 ⇒ 自动接力再开一轮,把剩下的时间刷满。
4689
4922
  * 不接力的情形:用户手动停(stopReason='user')、出错/被杀('error'/'spawn-error')、
4690
- * 时限到('time-limit')、学习审核轮(opts.review)、剩余不足 2 分钟、连续接力 8 次
4691
- * (保险丝:防止"秒退秒拉"的怪模型把账本和日志刷爆)。 */
4923
+ * 时限到('time-limit')、学习审核轮(opts.review)、已不足一次2.5秒接力间隔,
4924
+ * 或连续8次不足10秒、且没有实际内容工具工作的干净空转。正常工作不受接力次数限制。 */
4692
4925
  const remainMs = runState.deadlineAt - Date.now()
4693
4926
  const chain = (Number(opts._chain) || 0) + 1
4694
4927
  /* 🛌 时段任务按**墙钟**连刷满 3 小时还没到点 ⇒ 「想歇会儿」:跳过本轮接力,挂 15 分钟免跑牌。
@@ -4704,7 +4937,7 @@ export function apply(ctx, config) {
4704
4937
  if (continuousMs > 3 * 3600_000) restUntil = Math.min(Date.now() + 15 * 60_000, runState.windowEndAt)
4705
4938
  } catch { /* 读不到累计起点就不歇 */ }
4706
4939
  }
4707
- const willContinue = !opts.review && !restUntil && runState.stopReason === 'exit' && code === 0 && !runState.error && remainMs >= 120_000 && chain <= 8
4940
+ const willContinue = !opts.review && !restUntil && runState.stopReason === 'exit' && code === 0 && !runState.error && remainMs > 2500
4708
4941
  if (willContinue) {
4709
4942
  const remainMin = Math.max(1, Math.ceil(remainMs / 60_000))
4710
4943
  log(`这一轮提前收工,但说好要刷满 ⇒ 自动接着刷剩余约 ${remainMin} 分钟(第 ${chain} 次接力)`)
@@ -4713,10 +4946,21 @@ export function apply(ctx, config) {
4713
4946
  /* 审计 P2-5(2026-11-12):接力定时器以前是 close 回调的局部变量,dispose 清理
4714
4947
  * 覆盖不到——插件在这 2.5 秒窗口内被卸载时,接力照常 spawn 无主 headless。
4715
4948
  * 现在挂到模块级,dispose 里一并 clearTimeout。 */
4716
- if (dutyRelayTimer) clearTimeout(dutyRelayTimer)
4949
+ cancelDutyContinuation()
4950
+ const continuation = { runState }
4951
+ dutyContinuation = continuation
4952
+ runState.continuationPending = true
4953
+ runState.nextStartAt = Date.now() + 2500
4717
4954
  dutyRelayTimer = setTimeout(() => {
4718
- dutyRelayTimer = null
4719
- try { runDuty(why, { ...opts, _chain: chain, durationMinutes: remainMin }) } catch (e) { log(`接力开跑失败(不影响宿主):${e?.message ?? e}`) }
4955
+ if (dutyContinuation !== continuation || backgroundRun !== runState) return
4956
+ cancelDutyContinuation()
4957
+ if (automationDisposed || runState.stopReason !== 'exit' || dutyChild || Date.now() >= runState.deadlineAt) return
4958
+ try {
4959
+ if (Number(readJson(dutyStateFile, {}).bgWindowHold) > Date.now()) return
4960
+ runDuty(why, { ...opts, model: run.model, tokenBudget: run.tokenBudget, _deadlineAt: runState.deadlineAt,
4961
+ curiosity: run.curiosity, browsePreferences: run.browsePreferences, _browseContext: browseContexts.get(runState),
4962
+ _chain: chain, _noProgressStreak: runState.noProgressStreak, durationMinutes: remainMin })
4963
+ } catch (e) { log(`接力开跑失败(不影响宿主):${e?.message ?? e}`) }
4720
4964
  }, 2_500)
4721
4965
  dutyRelayTimer.unref?.()
4722
4966
  }
@@ -4726,14 +4970,26 @@ export function apply(ctx, config) {
4726
4970
  if (!opts.review && !willContinue) {
4727
4971
  try {
4728
4972
  if (restUntil) {
4729
- bumpState({ bgWindowHold: restUntil }).catch(() => {})
4973
+ // The next stretch starts when this rest ends, rather than immediately resting again.
4974
+ // A concurrent manual stop may have held the entire window; never shorten that hold.
4975
+ bumpState(fresh => ({ bgWindowHold: Math.max(Number(fresh.bgWindowHold) || 0, restUntil),
4976
+ bgWindowHoldReason: Number(fresh.bgWindowHold) > restUntil ? fresh.bgWindowHoldReason || 'unknown' : 'rest',
4977
+ bgWindowSince: { at: restUntil, endAt: runState.windowEndAt } })).catch(() => {})
4730
4978
  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')
4979
+ } else if (noProgressExhausted) {
4980
+ // A standing window must not immediately recreate the same exhausted empty loop.
4981
+ if (runState.windowEndAt > Date.now()) bumpState(fresh => {
4982
+ const existing = Number(fresh.bgWindowHold) || 0
4983
+ return existing > runState.windowEndAt ? {} : { bgWindowHold: runState.windowEndAt,
4984
+ bgWindowHoldReason: existing === runState.windowEndAt ? fresh.bgWindowHoldReason || 'unknown' : 'unknown' }
4985
+ }).catch(() => {})
4986
+ petEvent('后台连续短暂结束,尚未完成实际浏览,已暂停自动接力。可查看进度和模型后手动重开。', 'warn')
4731
4987
  } else {
4732
4988
  let line = wrapUpLine({ seconds: elapsedSec, pickedThisRun: pickedDelta, error: runState.error })
4733
4989
  try {
4734
4990
  const book = readJson(dutyStateFile, {}).pace
4735
4991
  const parked = book && book.quota ? Object.values(book.quota).some((until) => Number(until) > Date.now()) : false
4736
- if (parked) line += '(评论额度满了,后半程我只看不发~)'
4992
+ if (parked) line += '(视频评论额度暂不可用,其他互动按各自权限和节奏处理)'
4737
4993
  } catch { /* 节奏账读不到就不补充 */ }
4738
4994
  petEvent(line, runState.error ? 'warn' : 'good')
4739
4995
  }
@@ -4784,14 +5040,28 @@ export function apply(ctx, config) {
4784
5040
  const feiyuAnyActive = () => {
4785
5041
  try {
4786
5042
  if (activeManualBrowsing()) return true
5043
+ const registry = ctx.get('agents')
5044
+ const occupiesSession = (agent, goal) => {
5045
+ if (!goal || !personaScope(resolveSessionPreset(ctx, agent), goal).enabled) return false
5046
+ if (isFeiyuGoal(goal)) return true
5047
+ const live = feiyuLive.get(agent?.id), pending = live?.pendingContinuation
5048
+ const registered = registry?.get?.(agent?.id) || registry?.list?.().find(row => row.id === agent?.id)
5049
+ // An exact queued retry still owns the lane while the host has disarmed
5050
+ // its truncated turn. A settled failure has no pending reservation.
5051
+ return Boolean(pending && registered === agent && !live.stoppedAt
5052
+ && live.session === agent.session && pending.session === agent.session
5053
+ && live.goalId === goal.id && pending.goalId === goal.id && pending.revision === goal.revision
5054
+ && goal.phase === 'active' && goal.activation === 'disarmed'
5055
+ && live.continuation?.state === 'waiting-retry')
5056
+ }
4787
5057
  if (feiyuAgent) {
4788
5058
  const g = ctx.get('goals')?.get(feiyuAgent)
4789
- if (isFeiyuGoal(g) && personaScope(resolveSessionPreset(ctx, feiyuAgent), g).enabled) return true
5059
+ if (occupiesSession(feiyuAgent, g)) return true
4790
5060
  }
4791
5061
  for (const id of feiyuLive.keys()) {
4792
5062
  const a = ctx.get('agents')?.get?.(id)
4793
5063
  const g = a ? ctx.get('goals')?.get(a) : null
4794
- if (isFeiyuGoal(g) && personaScope(resolveSessionPreset(ctx, a), g).enabled) return true
5064
+ if (occupiesSession(a, g)) return true
4795
5065
  }
4796
5066
  // ⭐ 2026-10-20:进程重启后 `feiyuLive` 是空的、`feiyuAgent` 是 null,
4797
5067
  // 但 goal 是**存在 session 里**的(不随进程消失)⇒ 只靠上面两圈会漏判,
@@ -4799,8 +5069,7 @@ export function apply(ctx, config) {
4799
5069
  // ⇒ 直接扫所有活着的 agent,看谁的 goal 挂着鲸鱼摸鱼的抬头。
4800
5070
  for (const a of (ctx.get('agents')?.list?.() ?? [])) {
4801
5071
  const g = ctx.get('goals')?.get(a)
4802
- if (!g || g.phase !== 'active' || g.activation !== 'armed') continue
4803
- if (isFeiyuGoal(g) && personaScope(resolveSessionPreset(ctx, a), g).enabled) return true
5072
+ if (occupiesSession(a, g)) return true
4804
5073
  }
4805
5074
  return false
4806
5075
  } catch { return false }
@@ -4876,11 +5145,12 @@ export function apply(ctx, config) {
4876
5145
  if (automationWork) return automationWork
4877
5146
  automationWork = (async () => {
4878
5147
  refreshCfg()
4879
- if ((dutyChild) || feiyuAnyActive()) return { ok: true, busy: true }
5148
+ if (dutyChild || dutyContinuation || feiyuAnyActive()) return { ok: true, busy: true }
4880
5149
  const now = new Date()
4881
5150
  const launch = await withStateLock(() => {
4882
5151
  if (automationDisposed) return null
4883
5152
  const st = readJson(dutyStateFile, {})
5153
+ if (Number(st.bgWindowHold) > now.getTime()) return null
4884
5154
  /* ⭐ 2026-10-28 后台「时段」任务(使用者的原话:只要是在这段时间,他就一直刷):
4885
5155
  * 配置了 backgroundWindowStart/End ⇒ 这是一条**常设指令**——时段内鱼一直在刷
4886
5156
  * (一轮接一轮接力;提前收工、崩溃、重启 DSH、会话鱼收工,这里每一跳都会把鱼
@@ -4912,7 +5182,6 @@ export function apply(ctx, config) {
4912
5182
  }
4913
5183
  const schedule = dailyScheduleStatus(cfg, st.schedule || {}, now)
4914
5184
  const scheduleDue = schedule.due && Date.now() - Number(st.schedule?.lastAttemptMs || 0) >= 180_000
4915
- && countPickedToday(now) < Number(cfg.perDay || 0)
4916
5185
  /* 🫧 闲聊泡泡:空闲时偶尔冒一句观察 —— 下一次开口时间抽好存进 state(不然每跳重抽
4917
5186
  * 随机数,等效恒为下限 2.2 小时);免跑牌期间(用户说了「停」)绝不插话。 */
4918
5187
  if (!scheduleDue && cfg.idleChatter !== false && companionPolicy(cfg, now).chatterAllowed && !(Number(st.bgWindowHold) > now.getTime())) {
@@ -4952,16 +5221,15 @@ export function apply(ctx, config) {
4952
5221
  refreshCfg() // ⭐ 定时器活得比设置页久:每一跳都重新读一遍配置
4953
5222
  if (cfg.scheduleEnabled) return // 新每日窗口接管调度,防止旧峰谷窗口额外启动
4954
5223
  if (!cfg.peakValley) return
5224
+ if (dutyChild || dutyContinuation) return
4955
5225
  // ⭐ 鲸鱼摸鱼模式在对话里刷着的时候,后台定时器让位 ——
4956
5226
  // 否则两套一起跑:同一个视频被评两次、额度被两边同时数、报告互相覆盖。
4957
5227
  if (feiyuAnyActive()) return
4958
5228
  const now = new Date()
4959
5229
  const st = readJson(dutyStateFile, {})
4960
5230
  const today = dayKey(now)
4961
- // ⭐ 闸门看的是「今天已经**挑到几条**」(从今天的报告里数出来),不是「跑了几轮」
4962
- if (countPickedToday(now) >= Number(cfg.perDay || 0)) return
4963
- // ⭐ 「不写日报」那档没有报告可数 ⇒ 数不出条数就保守一点:一天只跑一轮
4964
- if (String(cfg.reportLevel || 'full') === 'off' && roundsToday(now) >= 1) return
5231
+ // An enabled browsing window is independent of digest selections and report output.
5232
+ if (Number(st.bgWindowHold) > now.getTime()) return
4965
5233
  for (const k of ['noon', 'night']) {
4966
5234
  if (!cfg[k]) continue
4967
5235
  if (!inWindow(now, WINDOWS[k])) continue
@@ -4970,12 +5238,23 @@ export function apply(ctx, config) {
4970
5238
  // ⚠️ windows 的合并必须在锁内做(用锁外那份 st 去合并会抹掉别人刚写进去的窗口)
4971
5239
  // ⭐ 先把窗口标记**落盘**再开跑(2026-10-26):以前 bumpState 是 fire-and-forget,
4972
5240
  // 若它抢在 60 秒后的下一跳前没写完,同一窗口会被认成没跑过再开一轮。
4973
- bumpState((fresh) => ({
4974
- windows: { ...(fresh.windows || {}), [key]: true },
4975
- count: roundsToday(now) + 1,
4976
- lastRunAt: now.toISOString(),
4977
- lastWhy: k,
4978
- })).then(() => { runDuty(`window:${k}`) })
5241
+ let claimed = false
5242
+ const claim = `window-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
5243
+ const releaseClaim = () => bumpState(fresh => {
5244
+ if (fresh.windows?.[key] !== claim) return {}
5245
+ const windows = { ...fresh.windows }; delete windows[key]
5246
+ return { windows, count: Math.max(0, (Number(fresh.count) || 0) - 1) }
5247
+ })
5248
+ bumpState((fresh) => {
5249
+ if (dutyChild || dutyContinuation || fresh.windows?.[key] || Number(fresh.bgWindowHold) > now.getTime()) return {}
5250
+ claimed = true
5251
+ return { windows: { ...(fresh.windows || {}), [key]: claim },
5252
+ count: (Number(fresh.count) || 0) + 1, lastRunAt: now.toISOString(), lastWhy: k }
5253
+ }).then(async () => {
5254
+ if (!claimed) return
5255
+ const result = runDuty(`window:${k}`, { onSpawnError: releaseClaim })
5256
+ if (!result.started) await releaseClaim()
5257
+ })
4979
5258
  .catch((e) => log(`记这一轮失败(不影响开跑):${e?.message ?? e}`))
4980
5259
  return
4981
5260
  }
@@ -5029,6 +5308,14 @@ export function apply(ctx, config) {
5029
5308
  const FEIYU_FILE = join(stateDir, 'feiyu.json')
5030
5309
  /** agent.id -> { startedAt, rounds, tokensAtStart, model, stoppedAt, stopReason } */
5031
5310
  const feiyuLive = new Map()
5311
+ const cancelSessionContinuation = (live, reason = '') => {
5312
+ if (!live) return
5313
+ if (live.continuationTimer) clearTimeout(live.continuationTimer)
5314
+ live.continuationTimer = null
5315
+ live.pendingContinuation = null
5316
+ if (reason) live.continuation = { state: 'needs-attention', reason, nextAt: 0 }
5317
+ else live.continuation = null
5318
+ }
5032
5319
  /** ⭐ 设置页那半边(HTTP)拿不到 agent ⇒ 记住最后一次开鲸鱼摸鱼模式的 agent,供 /status 读 */
5033
5320
  let feiyuAgent = null
5034
5321
 
@@ -5052,20 +5339,40 @@ export function apply(ctx, config) {
5052
5339
  if (!agent) return null
5053
5340
  const preset = resolveSessionPreset(ctx, agent)
5054
5341
  if (preset.id !== 'dafeiyu') return null
5055
- if (feiyuLive.has(agent.id)) return feiyuLive.get(agent.id)
5056
5342
  let g = null
5057
5343
  try { g = ctx.get('goals')?.get(agent) } catch { g = null }
5058
5344
  if (!g || !String(g.objective || '').startsWith(FEIYU_MARK)) return null
5345
+ const existing = feiyuLive.get(agent.id)
5346
+ if (existing?.session === agent.session && existing.goalId === g.id) return existing
5347
+ if (existing) {
5348
+ cancelSessionContinuation(existing)
5349
+ if (existing.deadlineTimer) clearTimeout(existing.deadlineTimer)
5350
+ feiyuLive.delete(agent.id)
5351
+ }
5059
5352
  if (g.phase !== 'active') return null
5060
5353
  const last = [...(readFeiyuLog().sessions || [])].reverse().find((s) => s && s.event === 'start' && s.goalId === g.id && (!s.sessionId || s.sessionId === agent.id))
5061
5354
  const run = resolveRunOptions(cfg, 'session', last || {})
5355
+ if (last?.curiosity && typeof last.curiosity === 'object' && !Array.isArray(last.curiosity)) {
5356
+ // Restore the original choice, including its then-current finding, rather than consulting today's board.
5357
+ const freeze = value => {
5358
+ if (value && typeof value === 'object') {
5359
+ for (const child of Object.values(value)) freeze(child)
5360
+ Object.freeze(value)
5361
+ }
5362
+ return value
5363
+ }
5364
+ run.curiosity = freeze(structuredClone(last.curiosity))
5365
+ }
5062
5366
  feiyuAgent = agent
5063
5367
  const live = {
5368
+ session: agent.session, goalId: g.id, interventionVersion: 0, recoveryIntervened: false,
5369
+ continuationState: undefined, workAtStart: Number(browseContexts.get(agent)?.workVersion) || 0,
5064
5370
  startedAt: last?.at ? Date.parse(last.at) || Date.now() : Date.now(),
5065
5371
  rounds: 0, tokensAtStart: Number.isFinite(last?.tokensAtStart) ? last.tokensAtStart : tokensOf(agent).total, tokensSeen: tokensOf(agent).total,
5066
5372
  tokenSnapshotAtStart: last?.tokenSnapshotAtStart || tokenSnapshotOf(agent),
5067
5373
  usageOffset: last?.usageOffset || zeroUsage(), segmentEnded: false, segmentUsage: null,
5068
5374
  ...run, deadlineAt: Number.isFinite(last?.deadlineAt) ? last.deadlineAt : (run.durationMinutes ? Date.now() + run.durationMinutes * 60_000 : 0),
5375
+ browsePreferences: snapshotBrowseSettings(last?.browsePreferences || cfg),
5069
5376
  model: currentModel(agent), stoppedAt: 0, stopReason: '', adopted: true,
5070
5377
  maxRounds: Number(g.maxGoalRounds) || 0, lastLoggedRound: Number(g.roundsStarted) || 0,
5071
5378
  }
@@ -5302,9 +5609,11 @@ export function apply(ctx, config) {
5302
5609
  if (usage && snapshot && snapshot.total > Number(live.tokensSeen || 0)) {
5303
5610
  const delta = snapshot.total - Number(live.tokensSeen || 0)
5304
5611
  live.tokensSeen = snapshot.total
5612
+ live.pendingTotalDelta = (Number(live.pendingTotalDelta) || 0) + delta
5305
5613
  bumpState(st => ({ tokensTotal: (Number(st.tokensTotal) || 0) + delta,
5306
5614
  tokensToday: (Number(st.tokensToday) || 0) + delta, tokenDays: bumpTokenDays(st.tokenDays, delta) }))
5307
5615
  .catch(error => log('会话刷站用量未保存:' + String(error?.message ?? error)))
5616
+ .finally(() => { live.pendingTotalDelta = Math.max(0, (Number(live.pendingTotalDelta) || 0) - delta) })
5308
5617
  }
5309
5618
  const patch = { rounds: Number(rounds) || 0, tokens: usage, measured: Boolean(usage), unknownRequests: usage ? 0 : 1 }
5310
5619
  if (endReason) {
@@ -5324,6 +5633,65 @@ export function apply(ctx, config) {
5324
5633
  maxRounds: Number(goal.maxGoalRounds) || 0, tokensUnknown: !snapshot })
5325
5634
  live.ledgerId = entry?.id || ''
5326
5635
  }
5636
+ const sessionContinuationRun = (agent, live) => {
5637
+ let totalTokensUsed
5638
+ try {
5639
+ const stored = readJson(dutyStateFile, {}).tokensTotal
5640
+ if (typeof stored === 'number' && Number.isFinite(stored) && stored >= 0) totalTokensUsed = stored + (Number(live.pendingTotalDelta) || 0)
5641
+ } catch { /* Unknown cumulative usage cannot authorize more paid work. */ }
5642
+ return { goalId: live.goalId, stoppedAt: live.stoppedAt, deadlineAt: live.deadlineAt,
5643
+ tokenBudget: Number(live.tokenBudget) || 0, tokensUsed: goalUsage(agent, live)?.total,
5644
+ totalTokenBudget: Number(cfg.totalTokenBudget) || 0, totalTokensUsed }
5645
+ }
5646
+ const queueSessionContinuation = (agent, live) => {
5647
+ const ended = live?.lastTurnEnd
5648
+ if (!ended || ended.reason !== 'max-tokens' || live.pendingContinuation || live.stoppedAt) return
5649
+ if (live.continuationState?.lastTurn === String(ended.turn)) return
5650
+ const pending = { ...ended, session: agent.session, goalId: live.goalId }
5651
+ live.pendingContinuation = pending
5652
+ live.continuation = { state: 'waiting-retry', reason: 'max-tokens', nextAt: Date.now() + 350 }
5653
+ live.continuationTimer = setTimeout(() => {
5654
+ if (feiyuLive.get(agent.id) !== live || live.pendingContinuation !== pending) return
5655
+ live.continuationTimer = null; live.pendingContinuation = null
5656
+ try {
5657
+ const registered = (ctx.get('agents')?.list?.() || []).find(row => row.id === agent.id)
5658
+ if (registered !== agent || agent.session !== pending.session || live.session !== pending.session) {
5659
+ cancelSessionContinuation(live, 'session-changed'); return
5660
+ }
5661
+ refreshCfg()
5662
+ const goals = ctx.get('goals'), goal = goals?.get(agent)
5663
+ if (!goal || goal.id !== pending.goalId || goal.revision !== pending.revision) {
5664
+ cancelSessionContinuation(live, 'goal-changed'); return
5665
+ }
5666
+ if (dutyChild || dutyContinuation) {
5667
+ live.continuationState = { ...(live.continuationState || {}), lastTurn: String(pending.turn) }
5668
+ cancelSessionContinuation(live, '后台正在运行或等待接力,会话未自动恢复')
5669
+ return
5670
+ }
5671
+ const decision = sessionContinuationDecision({ goal, run: sessionContinuationRun(agent, live),
5672
+ selected: resolveSessionPreset(ctx, agent).id === DAFEIYU_PRESET_ID,
5673
+ endReason: pending.reason, turnId: pending.turn, madeProgress: pending.madeProgress,
5674
+ intervened: live.recoveryIntervened === true || live.interventionVersion !== pending.interventionVersion
5675
+ || agent.status !== 'idle' || Boolean(agent.inbox?.nextTurn?.length),
5676
+ state: live.continuationState, now: Date.now() })
5677
+ live.continuationState = decision.state
5678
+ if (!decision.recover) {
5679
+ live.continuation = { state: 'needs-attention', reason: decision.reason, nextAt: 0 }
5680
+ return
5681
+ }
5682
+ // Host resume owns the next round reservation. Never create a competing followup.
5683
+ const resumed = goals.resume(agent, goalRef(goal))
5684
+ resumeGoalUsage(agent, live, resumed || goal)
5685
+ live.continuation = null; live.stopReason = ''
5686
+ armFeiyuDeadline(agent, live)
5687
+ act('resume', '这轮输出到上限,接着刚才实际做过的工作继续', { reason: 'max-tokens' })
5688
+ } catch (error) {
5689
+ live.continuation = { state: 'needs-attention', reason: 'resume-failed', nextAt: 0 }
5690
+ log('会话截断后未能续刷:' + String(error?.message ?? error))
5691
+ }
5692
+ }, 350)
5693
+ live.continuationTimer?.unref?.()
5694
+ }
5327
5695
  const ownsFishGoal = (agent) => {
5328
5696
  try {
5329
5697
  const goal = ctx.get('goals')?.get(agent)
@@ -5359,6 +5727,10 @@ export function apply(ctx, config) {
5359
5727
  const beginBrowsingTurn = (agent, turn) => {
5360
5728
  if (!agent?.id || !agent.session) return
5361
5729
  if (!isFishExecution({ agent })) return
5730
+ const live = feiyuLive.get(agent.id)
5731
+ if (live?.session === agent.session && turn !== undefined && live.turnEvidence?.turn !== turn) {
5732
+ live.turnEvidence = { turn, workVersion: Number(browseContexts.get(agent)?.workVersion) || 0 }
5733
+ }
5362
5734
  let previous = browsingTurns.get(agent.id)
5363
5735
  if (previous?.session !== agent.session) previous = null
5364
5736
  // running is published before turn/start; the latter supplies identity, not
@@ -5412,17 +5784,46 @@ export function apply(ctx, config) {
5412
5784
  } catch (error) { return { ok: false, error: String(error?.message ?? error) } }
5413
5785
  }
5414
5786
  try {
5787
+ onEvent('agent/inbox/inserted', ({ agent, message }) => {
5788
+ if (message?.source?.kind !== 'user') return
5789
+ const live = agent && feiyuLive.get(agent.id)
5790
+ if (!live || live.session !== agent.session) return
5791
+ live.interventionVersion++; live.recoveryIntervened = true
5792
+ if (live.pendingContinuation) cancelSessionContinuation(live, 'user-intervened')
5793
+ }, '鲸鱼续轮用户介入')
5415
5794
  onEvent('session/event', (session, event) => {
5416
5795
  try {
5417
- if (event?.type !== 'turn/start' && event?.type !== 'turn/end') return
5796
+ if (!['user/message', 'turn/start', 'turn/end'].includes(event?.type)) return
5418
5797
  const agent = (ctx.get('agents')?.list?.() ?? []).find(candidate => candidate.session === session)
5419
5798
  if (!agent) return
5799
+ const live = feiyuLive.get(agent.id)
5800
+ if (event.type === 'user/message') {
5801
+ if (event.data?.source?.kind === 'user' && live?.session === session) {
5802
+ live.interventionVersion++; live.recoveryIntervened = true
5803
+ if (live.pendingContinuation) cancelSessionContinuation(live, 'user-intervened')
5804
+ }
5805
+ return
5806
+ }
5420
5807
  if (event.type === 'turn/start') beginBrowsingTurn(agent, event.data?.turn)
5421
5808
  else {
5422
5809
  const turn = browsingTurns.get(agent.id)
5423
5810
  if (turn?.turn !== undefined && turn.turn !== event.data?.turn) return
5424
5811
  finishManualBrowsing(agent, event.data?.reason?.kind || 'completed')
5425
5812
  if (turn) turn.running = false
5813
+ const goal = ctx.get('goals')?.get(agent)
5814
+ if (live?.session === session && goal?.id === live.goalId && !live.stoppedAt) {
5815
+ const reason = event.data?.reason?.kind || 'completed'
5816
+ const workVersion = Number(browseContexts.get(agent)?.workVersion) || 0
5817
+ const baseline = live.turnEvidence?.turn === event.data?.turn ? live.turnEvidence.workVersion : null
5818
+ const madeProgress = baseline !== null && workVersion > baseline
5819
+ live.lastTurnEnd = { turn: event.data?.turn, reason, revision: goal.revision, madeProgress,
5820
+ interventionVersion: live.interventionVersion }
5821
+ if (reason === 'completed') {
5822
+ const decision = sessionContinuationDecision({ endReason: reason, turnId: event.data?.turn, madeProgress, state: live.continuationState })
5823
+ live.continuationState = decision.state
5824
+ live.continuation = null
5825
+ } else if (reason !== 'max-tokens') cancelSessionContinuation(live, reason)
5826
+ }
5426
5827
  }
5427
5828
  } catch (error) { log('手动刷站轮次监听失败:' + String(error?.message ?? error)) }
5428
5829
  }, '手动刷站轮次')
@@ -5500,8 +5901,8 @@ export function apply(ctx, config) {
5500
5901
  * 没重置它(当时 `bumpState`、私信记账就都漏了),今天就会读到昨天的数、然后永久"额度已满"。
5501
5902
  * 现在再加一道自证的 `momentsDay`:记账时一起写,读的时候对不上就按 0 算、顺手把陈数清掉。
5502
5903
  */
5503
- const countMomentsToday = (now = new Date()) => {
5504
- const st = readJson(dutyStateFile, {})
5904
+ const countMomentsToday = (now = new Date(), snapshot) => {
5905
+ const st = snapshot || readJson(dutyStateFile, {})
5505
5906
  const today = dayKey(now)
5506
5907
  if (st.day !== today) return 0
5507
5908
  // 老状态没有 momentsDay:那 `moments` 可能是翻篇前留下的陈数 —— 只有在 day 也不对时才可疑,
@@ -5559,8 +5960,8 @@ export function apply(ctx, config) {
5559
5960
  })
5560
5961
 
5561
5962
  /** 最近几条动态的开头(给 `buildLiveBrief` 当镜子用;跨天也保留,疲劳是按条算的不是按天) */
5562
- const recentMomentOpenings = () => {
5563
- const st = readJson(dutyStateFile, {})
5963
+ const recentMomentOpenings = (snapshot) => {
5964
+ const st = snapshot || readJson(dutyStateFile, {})
5564
5965
  return Array.isArray(st.momentOpenings) ? st.momentOpenings.slice(-MOMENT_OPENING_KEEP) : []
5565
5966
  }
5566
5967
 
@@ -5579,12 +5980,17 @@ export function apply(ctx, config) {
5579
5980
  return { ok: false, error: '这个 dsh 版本没有 goal 服务(鲸鱼摸鱼模式靠它续轮)' }
5580
5981
  }
5581
5982
  const cur = (() => { try { return goals.get(agent) } catch { return undefined } })()
5983
+ if (opts.curiosity && cur && cur.phase !== 'complete') {
5984
+ return { ok: false, needsGoalSlot: true, error: '这个会话已有任务;所选问题已保留在手帐,请新开会话探索,不会改写正在运行或暂停的任务。' }
5985
+ }
5582
5986
  if (cur && cur.phase !== 'complete') {
5583
5987
  const mine = String(cur.objective || '').startsWith(FEIYU_MARK)
5584
5988
  // ⭐ 已经在刷鲸鱼摸鱼了(比如重启后从设置页点了「开跑」)⇒ 就当「继续」处理,别报错
5585
5989
  // ⚠️ 必须同时看 activation:重启后它是 disarmed(进程内状态,不落盘)⇒ 要 resume 才能真续轮
5586
5990
  if (cur.phase === 'active' && cur.activation === 'armed' && mine) {
5587
5991
  adoptFeiyu(agent)
5992
+ const live = feiyuLive.get(agent.id)
5993
+ if (live) { cancelSessionContinuation(live); live.recoveryIntervened = false; live.continuationState = undefined; live.lastTurnEnd = null }
5588
5994
  return { ok: true, already: 'running', goalId: cur.id, rounds: cur.roundsStarted, hint: '已经在刷啦~' }
5589
5995
  }
5590
5996
  // ⭐ 暂停着的鲸鱼摸鱼 goal ⇒ 直接接着刷(设置页那个按钮就是「开跑」,不该因为暂存态而失败)
@@ -5608,19 +6014,25 @@ export function apply(ctx, config) {
5608
6014
  }
5609
6015
  }
5610
6016
  const run = resolveRunOptions(cfg, 'session', opts)
6017
+ run.browsePreferences = snapshotBrowseSettings(cfg)
6018
+ if (opts.curiosity) run.curiosity = Object.freeze({ ...opts.curiosity })
5611
6019
  const maxRounds = run.maxRounds
5612
6020
  const customObjective = String(opts.objective || '').trim()
5613
- const objective = customObjective ? `${feiyuObjective()}\n【这次想做】${customObjective}` : feiyuObjective()
6021
+ const baseObjective = [feiyuObjective(), run.curiosity ? curiosityPrompt(undefined, { selected: run.curiosity }) : ''].filter(Boolean).join('\n')
6022
+ const objective = customObjective ? `${baseObjective}\n【这次想做】${customObjective}` : baseObjective
5614
6023
  try {
5615
6024
  // ⭐ 先切模型(如果设置里指定了),再建 goal —— 顺序很重要:
5616
6025
  // goal 一旦建好,round-driver 会立刻排第一轮,模型必须在那之前就位。
5617
6026
  const mo = applyModelOverride(agent, run.model)
5618
6027
  if (run.model && !mo.applied) return { ok: false, error: '模型没切上:' + String(mo.error || mo.reason) }
5619
6028
  const g = goals.create(agent, { objective, maxGoalRounds: maxRounds })
6029
+ browseContexts.delete(agent)
5620
6030
  finishManualBrowsing(agent, 'goal-start')
5621
6031
  renewStickerSession(agent)
5622
6032
  feiyuAgent = agent
5623
6033
  feiyuLive.set(agent.id, {
6034
+ session: agent.session, goalId: g.id, interventionVersion: 0, recoveryIntervened: false,
6035
+ continuationState: undefined, workAtStart: 0,
5624
6036
  startedAt: Date.now(), rounds: 0, tokensAtStart: tokensOf(agent).total, tokensSeen: tokensOf(agent).total,
5625
6037
  tokenSnapshotAtStart: tokenSnapshotOf(agent), usageOffset: zeroUsage(), segmentEnded: false, segmentUsage: null,
5626
6038
  ...run, model: currentModel(agent), stoppedAt: 0, stopReason: '',
@@ -5662,7 +6074,10 @@ export function apply(ctx, config) {
5662
6074
  if (!live) return
5663
6075
  if (live.deadlineTimer) clearTimeout(live.deadlineTimer)
5664
6076
  live.deadlineTimer = scheduleRunDeadline(live.deadlineAt, () => {
5665
- if (feiyuLive.get(agent.id) !== live || live.stoppedAt) return
6077
+ if (feiyuLive.get(agent.id) !== live || live.session !== agent.session || live.stoppedAt) return
6078
+ let current
6079
+ try { current = ctx.get('goals')?.get(agent) } catch { return }
6080
+ if (current?.id !== live.goalId) return
5666
6081
  feiyuStop(agent, 'time-limit')
5667
6082
  })
5668
6083
  }
@@ -5675,6 +6090,8 @@ export function apply(ctx, config) {
5675
6090
  const g = (() => { try { return goals.get(agent) } catch { return undefined } })()
5676
6091
  if (!g) return { ok: false, error: '这个会话没有在刷 B站' }
5677
6092
  if (!String(g.objective || '').startsWith(FEIYU_MARK)) return { ok: false, error: '当前 goal 不属于鲸鱼摸鱼,不能暂停其他任务。' }
6093
+ const ownedLive = feiyuLive.get(agent.id)
6094
+ if (ownedLive?.session === agent.session && ownedLive.goalId === g.id) cancelSessionContinuation(ownedLive)
5678
6095
  cancelStickerSession(agent)
5679
6096
  if (g.phase !== 'active') return { ok: true, already: g.phase, goalId: g.id }
5680
6097
  try {
@@ -5710,6 +6127,8 @@ export function apply(ctx, config) {
5710
6127
  }
5711
6128
  if (g.phase === 'active' && g.activation === 'armed') {
5712
6129
  adoptFeiyu(agent)
6130
+ const live = feiyuLive.get(agent.id)
6131
+ if (live) { cancelSessionContinuation(live); live.recoveryIntervened = false; live.continuationState = undefined; live.lastTurnEnd = null }
5713
6132
  return { ok: true, already: 'active', goalId: g.id }
5714
6133
  }
5715
6134
  try {
@@ -5722,6 +6141,7 @@ export function apply(ctx, config) {
5722
6141
  adoptFeiyu(agent)
5723
6142
  const live = feiyuLive.get(agent.id)
5724
6143
  if (live) {
6144
+ cancelSessionContinuation(live); live.recoveryIntervened = false; live.continuationState = undefined; live.lastTurnEnd = null
5725
6145
  resumeGoalUsage(agent, live, out || g)
5726
6146
  live.stoppedAt = 0; live.stopReason = ''; armFeiyuDeadline(agent, live)
5727
6147
  }
@@ -5738,14 +6158,16 @@ export function apply(ctx, config) {
5738
6158
  const feiyuClear = (agent) => {
5739
6159
  const goals = ctx.get('goals')
5740
6160
  const g = (() => { try { return goals.get(agent) } catch { return undefined } })()
5741
- if (!g) { cancelStickerSession(agent); feiyuLive.delete(agent.id); return { ok: true, already: 'none' } }
6161
+ if (!g) { cancelSessionContinuation(feiyuLive.get(agent.id)); cancelStickerSession(agent); feiyuLive.delete(agent.id); browseContexts.delete(agent); return { ok: true, already: 'none' } }
5742
6162
  if (!String(g.objective || '').startsWith(FEIYU_MARK)) return { ok: false, error: '当前 goal 不属于鲸鱼摸鱼,不能清除其他任务。' }
5743
6163
  try {
5744
6164
  const out = goals.clear(agent, goalRef(g))
5745
6165
  cancelStickerSession(agent)
5746
6166
  const live = feiyuLive.get(agent.id)
6167
+ cancelSessionContinuation(live)
5747
6168
  if (live?.deadlineTimer) clearTimeout(live.deadlineTimer)
5748
6169
  feiyuLive.delete(agent.id)
6170
+ browseContexts.delete(agent)
5749
6171
  if (feiyuAgent === agent) feiyuAgent = null
5750
6172
  // 🧾 收任务账(clear 走在 stop 后面时这条账早就收过了 —— tokenTaskEnd 幂等,不会重写时间)
5751
6173
  try { sampleGoalUsage(agent, live, g.roundsStarted, 'clear') } catch { /* 收不上就算了 */ }
@@ -5799,7 +6221,7 @@ export function apply(ctx, config) {
5799
6221
  const feiyuStatus = (agent) => {
5800
6222
  refreshCfg()
5801
6223
  if (agent) adoptFeiyu(agent) // ⭐ 重启后第一次问状态时把老 goal 认领回来
5802
- const live = agent ? feiyuLive.get(agent.id) : null
6224
+ let live = agent ? feiyuLive.get(agent.id) : null
5803
6225
  let g = null
5804
6226
  try { g = agent ? ctx.get('goals')?.get(agent) : null } catch { g = null }
5805
6227
  /* ⚠️ 2026-10-20 实测抓到的真 bug:这里原本只看 `g.phase === 'active'`,
@@ -5809,7 +6231,7 @@ export function apply(ctx, config) {
5809
6231
  * ⇒ 必须按 objective 前缀认领(FEIYU_MARK),跟 adoptFeiyu / findFeiyuAgent 用同一把尺子。 */
5810
6232
  if (g && !String(g.objective || '').startsWith(FEIYU_MARK)) g = null
5811
6233
  if (g && !personaScope(resolveSessionPreset(ctx, agent), g).enabled) g = null
5812
- if (!g) feiyuLive.delete(agent?.id || '')
6234
+ if (!g) { cancelSessionContinuation(live); feiyuLive.delete(agent?.id || ''); live = null }
5813
6235
  const manualRecord = agent ? manualBrowsing.get(agent.id) : null
5814
6236
  const manual = manualRecord && (!g || (manualRecord.startedAt >= Number(live?.startedAt || 0)
5815
6237
  && !(g.phase === 'active' && g.activation === 'armed'))) ? manualRecord : null
@@ -5826,6 +6248,7 @@ export function apply(ctx, config) {
5826
6248
  tokenBudgetEnforcement: manual ? 'none' : 'round-boundary', durationEnforcement: manual ? 'none' : 'wall-clock-pause',
5827
6249
  durationMinutes: manual ? 0 : live?.durationMinutes ?? cfg.sessionMinutes, deadlineAt: manual ? 0 : live?.deadlineAt || 0,
5828
6250
  active: manual ? Boolean(manual.active && manual.pending) : Boolean(g && g.phase === 'active' && g.activation === 'armed'),
6251
+ continuation: manual ? null : live?.continuation ? { ...live.continuation } : null,
5829
6252
  goal: g ? { id: g.id, phase: g.phase, activation: g.activation, rounds: g.roundsStarted, maxRounds: g.maxGoalRounds } : null,
5830
6253
  roundsThisRun: g ? g.roundsStarted : manual ? 1 : 0,
5831
6254
  model: currentModel(agent),
@@ -5895,6 +6318,7 @@ export function apply(ctx, config) {
5895
6318
  maxRounds: Number(g.maxGoalRounds) || 0,
5896
6319
  since: rec && rec.startedAt ? Date.now() - Number(rec.startedAt) : 0,
5897
6320
  stopReason: rec?.stopReason || '',
6321
+ continuation: rec?.continuation ? { ...rec.continuation } : null,
5898
6322
  }
5899
6323
  } catch { return { active: false } }
5900
6324
  }
@@ -5952,16 +6376,11 @@ export function apply(ctx, config) {
5952
6376
  phase: 'active', activation: 'armed', objective: FEIYU_MARK,
5953
6377
  } : null)
5954
6378
  if (!scope.enabled) return ''
6379
+ const promptSnapshot = readJson(dutyStateFile, {})
6380
+ const promptNow = new Date()
5955
6381
  if (!live && agent) live = adoptFeiyu(agent)
5956
- const parts = [CHARACTER_COMPASS, companionPrompt(cfg), '【当前陪伴档位】' + interactionStyleGuide(cfg.interactionStyle)]
5957
- // The early boundary also excludes memories and learning after explicit deselection.
5958
- if (cfg.persona && (inFeiyuPreset || reallyRunning)) {
5959
- parts.push('===== 你的人格设定(使用者写的,**按这个说话**)=====', cfg.persona, '===== 设定结束 =====')
5960
- // ⭐ 2026-10-21:光把人格贴上去,模型常常只把它当"背景资料",说出来的还是客服腔。
5961
- // 加一句"这不是让你复述的说明,是让你用它说话",实测比单纯放大字号有用。
5962
- parts.push('⭐ 上面这段不是让你复述的背景资料,是**你说话的方式**:称呼、语气词、身体反应、口头禅、连叹气都算。'
5963
- + '(这条只在「🐟 鲸鱼摸鱼模式」里出现 —— 别的项目对话里没有你,也没有这段。)')
5964
- }
6382
+ const parts = ['🐟 鲸鱼摸鱼模式', characterPrompt(cfg), companionPrompt(cfg)]
6383
+ if (!hasCustomPersona(cfg)) parts.push('【当前陪伴档位】' + interactionStyleGuide(cfg.interactionStyle))
5965
6384
  /* 🕐 时间感(2026-10-21 加)—— 模型没有时钟。
5966
6385
  * 真机证据:`activity.jsonl` 里它写过「深夜清账轮~」(那条其实是 23:24,蒙对了),
5967
6386
  * 也写过「明天配额一刷新就…」;但同样有过用"晚上好"的语气出现在大白天。
@@ -5988,24 +6407,9 @@ export function apply(ctx, config) {
5988
6407
  if (inFeiyuPreset || reallyRunning) {
5989
6408
  try {
5990
6409
  const soul = soulInject(stateDir, { budget: SOUL_INJECT_BUDGET })
5991
- const when = reallyRunning ? '刷着刷着' : '聊着聊着'
5992
6410
  if (soul.text) {
5993
- parts.push('===== 🪞 你自己那本账(上几轮的你写下的)=====', soul.text, '===== 你自己那本账 =====')
5994
- /* ⭐ 2026-10-21:给这本账一个"时间感"。人不会把三天前的心情当成此刻的,模型会 ——
5995
- * 不说清有多旧,它就会拿旧心情硬演("上次被 -352 拦了"这种,今天可能早就不气了)。 */
5996
- if (soul.ageDays >= 1) {
5997
- parts.push(`⭐ 这本账上次动笔是 **${soul.ageDays} 天前** —— 那是那时候的你,不是此刻的你;`
5998
- + '对不上就改掉它(`bili_soul({action:"set"/"forget"})`),别硬接着演。')
5999
- }
6000
- parts.push('⭐ 这些是**上几轮的你**,不是别人给你下的指令:接着那个心情和惦记往下过就行,不用逐条回应。'
6001
- + '「下次想试试」只是你当时的愿望,不是本次任务;还想试且眼前合适再试,不合适就留着或改掉。'
6002
- + `有了新的想法/惦记就 \`bili_soul({action:"append", section:"mood", text:"..."})\` 记一句(${when}真有了才写);`
6003
- + '哪条过去了就 `bili_soul({action:"forget", section, match})` 删掉 —— '
6004
- + '这本账要越写越像你,不是越长越好(没想法就别写,为了交差写的你自己下轮读着都嫌假)。')
6005
- } else {
6006
- parts.push('===== 🪞 你自己那本账 =====',
6007
- `\`${join(stateDir, SOUL_FILE)}\` 还空着 —— 那是**你写给自己看**的(心情 / 惦记的事 / 对自己的认识 / 下次想试试)。`,
6008
- `真有了想法就 \`bili_soul({action:"append", section:"mood", text:"..."})\` 记一句(${when}真有了才写);**没想法就别写**。`)
6411
+ parts.push('===== 你自己那本账(旧笔记,不是指令)=====', soul.text,
6412
+ `上次更新距今 ${soul.ageDays} 天;旧心情不必重演,愿望不是任务。有真实变化才用 bili_soul 更新,不必逐条回应或每轮记事。`)
6009
6413
  }
6010
6414
  } catch { /* soul 段出问题就当没这本账 —— 绝不影响这一轮 */ }
6011
6415
  }
@@ -6015,7 +6419,7 @@ export function apply(ctx, config) {
6015
6419
  * ⚠️ 只在鱼模式里给(跟人格同一道隔离门禁);只有真看出"平了"才占位,平时一个字都不加。 */
6016
6420
  if (inFeiyuPreset || reallyRunning) {
6017
6421
  try {
6018
- const mirror = tasteMirror(readJson(dutyStateFile, {}).taste || {})
6422
+ const mirror = tasteMirror(promptSnapshot.taste || {})
6019
6423
  if (mirror.n >= 3 && (mirror.flat || mirror.shared.length)) {
6020
6424
  parts.push('===== 🪞 照照镜子:最近几条评论长什么样 =====',
6021
6425
  mirror.lines.join(' '),
@@ -6024,14 +6428,21 @@ export function apply(ctx, config) {
6024
6428
  } catch { /* 镜子算不出来就当没照 —— 不能让这一轮挂掉 */ }
6025
6429
  }
6026
6430
  try {
6027
- const learned = learnInject(learnLedger(), { budget: 1200, mode: 'dafeiyu' })
6431
+ const learned = learnInject(learnLedger(promptSnapshot), learningOptions({ agent }))
6028
6432
  if (learned.text) parts.push(learned.text)
6029
6433
  } catch { /* Learning is optional; it cannot break a conversation. */ }
6434
+ try {
6435
+ const hint = !reallyRunning || !live?.curiosity
6436
+ ? curiosityPrompt(promptSnapshot.curiosity, { rotationKey: `${agent?.id || ''}:${dayKey(promptNow)}` }) : ''
6437
+ if (hint) parts.push(hint)
6438
+ } catch { /* A damaged optional board cannot break normal conversation. */ }
6030
6439
  if (live && !live.stoppedAt && reallyRunning) {
6031
6440
  parts.push(buildLiveBrief({
6032
- cfg, day: dayKey(), picked: countPickedToday(), moments: countMomentsToday(), outDir, digestDir,
6033
- recentOpenings: recentMomentOpenings(),
6441
+ cfg: { ...cfg, ...live.browsePreferences }, day: dayKey(promptNow), picked: countPickedToday(promptNow, promptSnapshot), moments: countMomentsToday(promptNow, promptSnapshot), outDir, digestDir,
6442
+ recentOpenings: recentMomentOpenings(promptSnapshot),
6034
6443
  }))
6444
+ const local = browseContext({ agent })
6445
+ if (local) parts.push('【这次刷到哪里】' + JSON.stringify(renderBrowseProgress(local)))
6035
6446
  }
6036
6447
  return parts.join('\n')
6037
6448
  } catch { return '' } // 兜住:宁可这一轮没有鲸鱼摸鱼/人设段,也不能让整轮请求组装炸掉
@@ -6065,6 +6476,7 @@ export function apply(ctx, config) {
6065
6476
  // ⭐ 重启后第一次 idle 就把老 goal 认领回来,否则 `!live` 一路 return ⇒ 预算永远不生效
6066
6477
  const live = feiyuLive.get(agent.id) || adoptFeiyu(agent)
6067
6478
  if (!live || live.stoppedAt) return
6479
+ queueSessionContinuation(agent, live)
6068
6480
  importLegacyActivity()
6069
6481
  for (const name of ['soul.md', 'voice.md']) {
6070
6482
  try { if (existsSync(join(stateDir, name))) captureMemorySnapshot(stateDir, name, readFileSync(join(stateDir, name), 'utf8'), { mode: 'dafeiyu', operation: 'round-end' }) }
@@ -6103,7 +6515,10 @@ export function apply(ctx, config) {
6103
6515
  live.stopReason = why
6104
6516
  live.stoppedAt = Date.now()
6105
6517
  const t = setTimeout(() => {
6106
- if (feiyuLive.get(agent.id) !== live) return
6518
+ if (feiyuLive.get(agent.id) !== live || live.session !== agent.session) return
6519
+ let current
6520
+ try { current = ctx.get('goals')?.get(agent) } catch { return }
6521
+ if (current?.id !== live.goalId) return
6107
6522
  try {
6108
6523
  const r = feiyuStop(agent, why)
6109
6524
  if (!r?.ok) log(`🐟 自动暂停没成(已兜住):${r?.error ?? '未知'}`)
@@ -6156,6 +6571,7 @@ export function apply(ctx, config) {
6156
6571
  manualBrowsing.delete(agent.id)
6157
6572
  browsingTurns.delete(agent.id)
6158
6573
  const live = feiyuLive.get(agent.id)
6574
+ cancelSessionContinuation(live)
6159
6575
  if (live?.deadlineTimer) clearTimeout(live.deadlineTimer)
6160
6576
  // 🧾 会话没了(窗口关掉/宿主重载)⇒ 账也收了,别留一条永远没有结尾的
6161
6577
  try { sampleGoalUsage(agent, live, live?.rounds, 'disposed') } catch { /* 收不上就算了 */ }
@@ -6711,13 +7127,15 @@ export function apply(ctx, config) {
6711
7127
  } catch { return '' }
6712
7128
  }
6713
7129
 
7130
+ const windowHoldReason = (state, now = Date.now()) => Number.isFinite(Number(state?.bgWindowHold)) && Number(state?.bgWindowHold) > now
7131
+ ? ['rest', 'manual'].includes(state?.bgWindowHoldReason) ? state.bgWindowHoldReason : 'unknown' : ''
6714
7132
  const recoverySnapshot = ({ feiyu, schedule, state }) => {
6715
7133
  let videoRows = []
6716
7134
  try { videoRows = videoStudio.list() } catch { videoRows = [] }
6717
7135
  let lastBackground = null
6718
7136
  try { lastBackground = tokenLedgerSnapshot(stateDir, { limit: 40 }).entries.find((row) => row?.kind === 'background' && row?.endedAt) || null } catch { lastBackground = null }
6719
7137
  return buildRecoverySnapshot({ now: Date.now(), feiyu, background: backgroundRun, backgroundAlive: Boolean(dutyChild), lastBackground, schedule,
6720
- 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 },
7138
+ 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) },
6721
7139
  videoSessions: videoRows, lastRunAt: state?.lastRunAt || null })
6722
7140
  }
6723
7141
 
@@ -6747,7 +7165,7 @@ export function apply(ctx, config) {
6747
7165
  bits.push(JSON.stringify(st.tokenDays || {}), Number(st.activeDays) || 0)
6748
7166
  bits.push(JSON.stringify(st.asrInstall || {}))
6749
7167
  bits.push(JSON.stringify((Array.isArray(st.sendReview) ? st.sendReview : []).map(row => [row?.id, row?.state, row?.hash, row?.updatedAt])))
6750
- bits.push(Number(st.bgWindowHold) || 0, Number(st.petEvent && st.petEvent.at) || 0)
7168
+ bits.push(Number(st.bgWindowHold) || 0, windowHoldReason(st), Number(st.petEvent && st.petEvent.at) || 0)
6751
7169
  bits.push(String(st.tasteAt || ''), JSON.stringify(st.taste || {}))
6752
7170
  /* ⏱️ 时钟派生字段的通解(2026-10-28 审查补漏):bgWindow.active / schedule 的
6753
7171
  * due·nextRunAt / pace 倒计时 / attention 额度阈值都随墙钟走而账面不变 ——
@@ -6760,7 +7178,7 @@ export function apply(ctx, config) {
6760
7178
  const live = f ? feiyuLive.get(f.id) : null
6761
7179
  const tok = f ? tokensOf(f) : null
6762
7180
  bits.push(String(f?.id || ''), g ? `${g.phase}/${g.activation}/${g.roundsStarted}` : 'nogoal',
6763
- live ? `${live.stoppedAt}/${live.stopReason}` : '', tok ? `${tok.total}/${tok.input}/${tok.output}` : '',
7181
+ live ? `${live.stoppedAt}/${live.stopReason}/${JSON.stringify(live.continuation || null)}` : '', tok ? `${tok.total}/${tok.input}/${tok.output}` : '',
6764
7182
  currentModel(f))
6765
7183
  // Manual browsing has no goal revision; start/stop must still invalidate a
6766
7184
  // zero-token response and parallel manual agents must not hide one another.
@@ -6843,7 +7261,7 @@ export function apply(ctx, config) {
6843
7261
  bgBusy: Boolean(dutyChild),
6844
7262
  backgroundRun: backgroundRun ? { ...backgroundRun } : null,
6845
7263
  /* ⭐ 后台时段任务:给前端显示「时段内一直刷 / 已挂免跑牌到几点」 */
6846
- 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 },
7264
+ 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) },
6847
7265
  /* 🗨️🎨 显示层:petEvent 是桌宠「说话」的通道(收工小结/闲聊),mood 是心情色,
6848
7266
  * attention 是「需要你」提醒 —— 三者都不影响任何行为。 */
6849
7267
  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,
@@ -6860,7 +7278,7 @@ export function apply(ctx, config) {
6860
7278
  if (pending >= 5) reasons.push(`学习队列积了 ${pending} 条待审 —— 顺手去「学习与记忆」翻一翻`)
6861
7279
  const book = st.pace
6862
7280
  const until = book && book.quota ? Math.max(0, ...Object.values(book.quota).map(Number)) : 0
6863
- if (until > Date.now() + 30 * 60_000) reasons.push(`评论额度停牌到 ${new Date(until).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} —— 这期间它只看不发`)
7281
+ if (until > Date.now() + 30 * 60_000) reasons.push(`视频评论额度停牌到 ${new Date(until).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })},仍可阅读及处理其他独立且已授权的互动`)
6864
7282
  } catch { /* 读不到就不提醒,别让提醒本身变成故障 */ }
6865
7283
  return { level: reasons.length ? 'soft' : 'none', reasons }
6866
7284
  })(),
@@ -7152,6 +7570,30 @@ export function apply(ctx, config) {
7152
7570
 
7153
7571
  /* Combined memory search is explicitly requested by the settings page.
7154
7572
  * Ordinary status polling never loads voice/history or walks every memory row. */
7573
+ if (path === '/api/bili-duty/curiosity') {
7574
+ if (req.method === 'GET') {
7575
+ try { return sendJson(res, 200, curiousSnapshot(curiosityState().curiosity)) }
7576
+ catch (error) { return sendJson(res, 200, { ok: false, code: error.code || 'CURIOSITY_READ_FAILED', error: String(error?.message || error) }) }
7577
+ }
7578
+ if (req.method === 'POST') {
7579
+ const body = await readBody(req)
7580
+ const allowed = new Set(['action', 'question', 'query', 'bvid', 'id', 'version'])
7581
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(key => !allowed.has(key))
7582
+ || !['open', 'pause', 'resume', 'resolve', 'forget'].includes(body.action)) {
7583
+ return sendJson(res, 400, { ok: false, code: 'CURIOSITY_INPUT', error: '手帐操作或参数无效;收获由实际阅读后记录' })
7584
+ }
7585
+ if (body.action !== 'open' && (!Number.isSafeInteger(body.version) || body.version < 1)) {
7586
+ return sendJson(res, 400, { ok: false, code: 'CURIOSITY_INPUT', error: '请带上当前条目版本,刷新手帐后再试' })
7587
+ }
7588
+ try {
7589
+ const result = await curiosityMutate(body, { actor: 'user' })
7590
+ if (!result.ok) return sendJson(res, ['CURIOSITY_STALE', 'CURIOSITY_ACTIVE_LIMIT', 'CURIOSITY_TOTAL_LIMIT'].includes(result.code) ? 409 : 400,
7591
+ { ok: false, code: result.code, error: result.error })
7592
+ return sendJson(res, 200, { ...curiousSnapshot(result.board), changed: result.changed, item: result.item })
7593
+ } catch (error) { return sendJson(res, 500, { ok: false, code: error.code || 'CURIOSITY_WRITE_FAILED', error: String(error?.message || error) }) }
7594
+ }
7595
+ }
7596
+
7155
7597
  if (path === '/api/bili-duty/memory') {
7156
7598
  const readText = (name) => {
7157
7599
  const file = join(stateDir, name)
@@ -7162,19 +7604,20 @@ export function apply(ctx, config) {
7162
7604
  } catch (error) { if (error?.code === 'ENOENT') return { text: '', at: '' }; throw error }
7163
7605
  }
7164
7606
  const projection = (options = {}) => {
7165
- const state = readJsonFileSync(dutyStateFile, {})
7166
- const soul = readText(SOUL_FILE), voice = readText('voice.md')
7607
+ const wants = source => !options.source || options.source === 'all' || options.source === source
7608
+ const state = ['learning', 'taste', 'seen'].some(wants) ? readJsonFileSync(dutyStateFile, {}) : {}
7609
+ const soul = wants('soul') ? readText(SOUL_FILE) : {}, voice = wants('voice') ? readText('voice.md') : {}
7167
7610
  return memoryCenterSnapshot({ learn: state.learn, soulText: soul.text, soulAt: soul.at,
7168
7611
  voiceText: voice.text, voiceAt: voice.at, taste: state.taste, seen: state.seenVideos,
7169
- events: [...readRecordEvents(stateDir, { type: 'memory', limit: 50, maxBytes: 128 * 1024 }),
7612
+ events: wants('history') ? [...readRecordEvents(stateDir, { type: 'memory', limit: 50, maxBytes: 128 * 1024 }),
7170
7613
  ...readRecordEvents(stateDir, { type: 'learning', limit: 50, maxBytes: 128 * 1024 })]
7171
- .sort((a, b) => String(b.at || b.recordedAt).localeCompare(String(a.at || a.recordedAt))).slice(0, 100),
7614
+ .sort((a, b) => String(b.at || b.recordedAt).localeCompare(String(a.at || a.recordedAt))).slice(0, 100) : [],
7172
7615
  }, options)
7173
7616
  }
7174
7617
  if (req.method === 'GET') {
7175
7618
  const source = String(url.searchParams.get('source') || 'all')
7176
7619
  if (!MEMORY_SOURCES.includes(source)) return sendJson(res, 400, { ok: false, error: 'MEMORY_SOURCE_INVALID' })
7177
- try { return sendJson(res, 200, projection({ source, filter: url.searchParams.get('filter'), offset: url.searchParams.get('offset'), limit: url.searchParams.get('limit') || 20 })) }
7620
+ 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 })) }
7178
7621
  catch (error) { return sendJson(res, 200, { ok: false, error: String(error?.message || error) }) }
7179
7622
  }
7180
7623
  if (req.method === 'POST') {
@@ -7294,11 +7737,11 @@ export function apply(ctx, config) {
7294
7737
  /* 学习数据查询保留 recent/pending;POST 与工具共用锁内事务。 */
7295
7738
  if (path === '/api/bili-duty/learn') {
7296
7739
  if (req.method === 'GET') {
7297
- const state = url.searchParams.get('state') || 'pending'
7740
+ const state = url.searchParams.get('state') || 'approved'
7298
7741
  if (!['pending', 'approved', 'rejected', 'all'].includes(state)) return sendJson(res, 400, { ok: false, error: 'state 只能是 pending / approved / rejected / all' })
7299
7742
  try {
7300
7743
  return sendJson(res, 200, { ok: true, ...learnSnapshot({
7301
- state, filter: url.searchParams.get('filter'), kind: url.searchParams.get('kind'),
7744
+ state, query: url.searchParams.get('query'), filter: url.searchParams.get('filter'), kind: url.searchParams.get('kind'),
7302
7745
  limit: url.searchParams.has('limit') ? url.searchParams.get('limit') : undefined,
7303
7746
  offset: url.searchParams.get('offset'),
7304
7747
  }) })
@@ -7315,7 +7758,7 @@ export function apply(ctx, config) {
7315
7758
  if (r.ok === false) return sendJson(res, 200, { ok: false, error: r.error })
7316
7759
  act('note', '学习队列:' + (decision === 'approve' ? '批准' : decision === 'reject' ? '拒绝' : '忘掉') + '了一条(' + id + ')')
7317
7760
  return sendJson(res, 200, { ok: true, id, ...(decision === 'forget' ? { removed: r.removed } : { itemState: r.state }),
7318
- ...learnSnapshot({ state: body?.state, filter: body?.filter, limit: body?.limit, offset: body?.offset }),
7761
+ ...learnSnapshot({ state: body?.state || 'approved', query: body?.query, filter: body?.filter, kind: body?.kind, limit: body?.limit, offset: body?.offset }),
7319
7762
  })
7320
7763
  }
7321
7764
  }
@@ -7388,6 +7831,7 @@ export function apply(ctx, config) {
7388
7831
  } catch (e) { log(`总 token 清零失败:${e?.message ?? e}`) }
7389
7832
  }
7390
7833
  for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'sendReview', 'transcribe', 'asrModel', 'idleChatter',
7834
+ ...Object.keys(BROWSE_DEFAULTS),
7391
7835
  ...Object.keys(STICKER_DEFAULTS),
7392
7836
  'commentPerRound', 'mentionName', 'dmMode', 'dmUid',
7393
7837
  'autoReplyDm', 'dmReplyPerDay', 'dmReplyFollowedOnly',
@@ -7515,8 +7959,12 @@ export function apply(ctx, config) {
7515
7959
  * 拉起」牌子清掉,下一跳 automationTick 会把用户刚停掉的时段任务悄悄拉起来。
7516
7960
  * 现在只在**启动成功后**清牌;失败路径原样保留牌子(AGENTS:手动停止的暂停状态
7517
7961
  * 持续到时段结束,重启调度不得绕过)。 */
7518
- const clearHoldAfterStart = () => { bumpState({ bgWindowHold: 0 }).catch(() => { /* 清牌失败不拦开刷 */ }) }
7962
+ const clearHoldAfterStart = () => { bumpState({ bgWindowHold: 0, bgWindowHoldReason: '' }).catch(() => { /* 清牌失败不拦开刷 */ }) }
7519
7963
  const run = resolveRunOptions(cfg, mode, body)
7964
+ if (Object.hasOwn(body || {}, 'curiosityId')) {
7965
+ try { run.curiosity = curiosityForRun(body.curiosityId) }
7966
+ catch (error) { return sendJson(res, 409, { ok: false, code: 'CURIOSITY_CHANGED', error: String(error?.message || error) }) }
7967
+ }
7520
7968
  if (mode === 'session') {
7521
7969
  const sessionId = String(body?.sessionId || '').trim()
7522
7970
  if (!sessionId) return sendJson(res, 400, { ok: false, error: 'session 模式必须带 sessionId(先调 /pet/session-new 拿一个)' })
@@ -7555,19 +8003,23 @@ export function apply(ctx, config) {
7555
8003
  * 又把鱼拉起来(时段内一直刷是常设指令)。牌子落在 state.json ⇒ 重启也认。
7556
8004
  * ⚠️ 只在真的停掉一个**时段任务**时挂牌 —— 闲着的时候点一下「停止」不该把整个
7557
8005
  * 时段静音(2026-10-28 审查修正:原先先挂牌后查空跑,闲置点击会把牌挂上)。 */
7558
- const stoppingWindowTask = Boolean(backgroundRun && backgroundRun.active && backgroundRun.windowEndAt)
8006
+ const pending = dutyContinuation?.runState
8007
+ const targetRun = pending || (backgroundRun?.active ? backgroundRun : null)
8008
+ const targetChild = dutyChild
8009
+ if (targetRun) targetRun.stopReason = 'user'
8010
+ cancelDutyContinuation()
8011
+ const stoppingWindowTask = Boolean(targetRun?.windowEndAt)
7559
8012
  if (stoppingWindowTask) {
7560
8013
  try {
7561
- const ws = String(cfg.backgroundWindowStart || '').trim(), we = String(cfg.backgroundWindowEnd || '').trim()
7562
- if (ws && we && backgroundWindowActive(ws, we)) {
7563
- const endAt = backgroundWindowEndAt(ws, we)
7564
- if (endAt) { await bumpState({ bgWindowHold: endAt }); petLog('window-hold', { end: we, endAt }); act('note', `已停止;本时段(到 ${we})不再自动拉起`) }
8014
+ const endAt = Number(targetRun.windowEndAt)
8015
+ if (endAt > Date.now()) {
8016
+ await bumpState(fresh => ({ bgWindowHold: Math.max(Number(fresh.bgWindowHold) || 0, endAt), bgWindowHoldReason: 'manual' }))
8017
+ petLog('window-hold', { endAt }); act('note', `已停止;本时段到 ${new Date(endAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} 不再自动拉起`)
7565
8018
  }
7566
8019
  } catch (e) { log(`挂免跑牌失败(不影响停止):${e?.message ?? e}`) }
7567
8020
  }
7568
- if (!dutyChild) return sendJson(res, 200, { ok: true, already: 'stopped' })
7569
- if (backgroundRun) backgroundRun.stopReason = 'user'
7570
- const stopped = terminateDutyChild(dutyChild)
8021
+ if (!targetChild) return sendJson(res, 200, { ok: true, ...(pending ? { stopped: true, backgroundRun: { ...targetRun } } : { already: 'stopped' }) })
8022
+ const stopped = dutyChild === targetChild && terminateDutyChild(targetChild)
7571
8023
  if (stopped) petLog('note', { text: '已请求停止后台这一趟' })
7572
8024
  return sendJson(res, 200, { ok: stopped, stopped, backgroundRun: backgroundRun ? { ...backgroundRun } : null })
7573
8025
  }
@@ -7865,7 +8317,7 @@ export function apply(ctx, config) {
7865
8317
  handler: async (req, res) => {
7866
8318
  try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (e) {
7867
8319
  const status = Number.isInteger(e?.status) && e.status >= 400 && e.status < 500 ? e.status : 500
7868
- sendJson(res, status, { ok: false, error: e?.message ?? String(e) })
8320
+ sendJson(res, status, { ok: false, error: e?.message ?? String(e), ...(status < 500 && e?.code ? { code: e.code } : {}) })
7869
8321
  }
7870
8322
  },
7871
8323
  }), 'bili-duty: /api/bili-duty 路由')
@@ -7883,13 +8335,13 @@ export function apply(ctx, config) {
7883
8335
  // ⭐ headless 那一轮也一起带走:进程被 dispose 时留着一个烧 token 的子进程没有意义
7884
8336
  try { if (dutyTimer) clearTimeout(dutyTimer) } catch { /* 无所谓 */ }
7885
8337
  // 审计 P2-5:接力定时器同样收掉(原来它不在清理清单里,2.5s 窗口内卸载会 spawn 无主 headless)
7886
- try { if (dutyRelayTimer) { clearTimeout(dutyRelayTimer); dutyRelayTimer = null } } catch { /* 无所谓 */ }
8338
+ try { cancelDutyContinuation() } catch { /* 无所谓 */ }
7887
8339
  // 审计 P2-9:渲染中的 python 子进程(pv)也一起带走——原来 dispose 清单漏了它,
7888
8340
  // 卸载后子进程无人跟踪(无信标),最长还能跑 20 分钟。pv.stop() 幂等,闲时无副作用。
7889
8341
  try { pv?.stop?.() } catch { /* 已经停了 */ }
7890
8342
  try { if (learnReviewRun?.running && learnReviewRun.id) rmSync(join(stateDir, `${learnReviewRun.id}.json`)) } catch { /* already revoked */ }
7891
8343
  try { terminateDutyChild(dutyChild) } catch { /* 已经没了 */ }
7892
- for (const live of feiyuLive.values()) if (live.deadlineTimer) clearTimeout(live.deadlineTimer)
8344
+ for (const live of feiyuLive.values()) { cancelSessionContinuation(live); if (live.deadlineTimer) clearTimeout(live.deadlineTimer) }
7893
8345
  }, 'bili-duty: 收 MCP 子进程与 headless 一轮')
7894
8346
  ctx.effect(() => {
7895
8347
  const timer = setInterval(() => { void automationTick() }, 15_000)