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