dsh-bili-scroll 0.1.5 → 0.1.6

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/cordis.patch.yml CHANGED
@@ -1,20 +1,20 @@
1
- # ============================================================================
2
- # dsh-bili-duty —— DSH bundle patch(插件**自带**的挂载声明)
3
- # ============================================================================
4
- # 这就是"标准 DSH 插件"的样子:包自己声明要往 profile 的配置树里插什么。
5
- # 装上之后,profile 只要在 `dsh.profile.bundles` 里列出本包名即生效,
6
- # 不用再手改 profile 自己的 cordis.patch.yml:
7
- #
8
- # dsh plugin --profile web add dsh-bili-scroll
9
- # (本地开发时用:dsh plugin --profile web add link:<你自己的插件目录>)
10
- #
11
- # 组成顺序:每个 bundle 的 patch 按 `dsh.profile.bundles` 顺序叠
12
- # → 再叠 profile 自己那一层的 cordis.patch.yml(只有那层是热重载)
13
- # ============================================================================
14
-
15
- - insert:
16
- - id: bili-scroll
17
- name: dsh-bili-scroll
18
- config:
19
- # 真正的用户配置在插件自己的 config 里(设置页可改),这里只放默认值
20
- enabled: true
1
+ # ============================================================================
2
+ # dsh-bili-scroll —— DSH bundle patch(插件**自带**的挂载声明)
3
+ # ============================================================================
4
+ # 这就是"标准 DSH 插件"的样子:包自己声明要往 profile 的配置树里插什么。
5
+ # 装上之后,profile 只要在 `dsh.profile.bundles` 里列出本包名即生效,
6
+ # 不用再手改 profile 自己的 cordis.patch.yml:
7
+ #
8
+ # dsh plugin --profile web add dsh-bili-scroll
9
+ # (本地开发时用:dsh plugin --profile web add link:<你自己的插件目录>)
10
+ #
11
+ # 组成顺序:每个 bundle 的 patch 按 `dsh.profile.bundles` 顺序叠
12
+ # → 再叠 profile 自己那一层的 cordis.patch.yml(只有那层是热重载)
13
+ # ============================================================================
14
+
15
+ - insert:
16
+ - id: bili-scroll
17
+ name: dsh-bili-scroll
18
+ config:
19
+ # 真正的用户配置在插件自己的 config 里(设置页可改),这里只放默认值
20
+ enabled: true
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-bili-duty —— host 半端
2
+ * dsh-bili-scroll —— host 半端
3
3
  * ============================================================================
4
4
  * 它是什么:每天替你刷一轮 B站 的插件版。
5
5
  * 1. **插件内部起那份 Python MCP**(`vendor/bilibili-mcp/mcp_server.py`),把它的能力
@@ -23,12 +23,12 @@ const hostDeps = await loadHostDeps()
23
23
  const z = hostDeps.z
24
24
  const defineTool = hostDeps.defineTool
25
25
  import { spawn } from 'node:child_process'
26
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
26
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
27
27
  import { join, dirname } from 'node:path'
28
28
  import { fileURLToPath } from 'node:url'
29
29
  import { McpClient } from './lib/mcp.mjs'
30
30
  import { verifyRecent, appendVerifyToReport } from './lib/verify.mjs'
31
- import { WINDOWS, stateDirOf, readJson, writeJson, ensureHomeFiles, inWindow, dayKey } from './lib/state.mjs'
31
+ import { WINDOWS, stateDirOf, readJson, writeJson, ensureHomeFiles, ensureCharter, charterPath, inWindow, dayKey } from './lib/state.mjs'
32
32
  import { loginStart, loginPoll, saveCredential, renderQrPng } from './lib/login.mjs'
33
33
  import { ensureUv } from './lib/uvfetch.mjs'
34
34
 
@@ -39,7 +39,7 @@ export const inject = ['webServer', 'tools', 'settings']
39
39
  * 🔴 每个字段都要 `.extra('volatile', true)`:DSH 的 settings.update() 只认 volatile 字段
40
40
  * (`volatileForm(schema)`,见 dsh-settings),没有这个标记写设置时会抛
41
41
  * "Plugin entry … has no volatile fields"。另外两个条件是:
42
- * **loader entry 的 id 必须等于命名空间名**(我们 patch 里就是 `bili-duty`)+ **本模块导出 Config**。
42
+ * **loader entry 的 id 必须等于命名空间名**(我们 patch 里就是 `bili-scroll`)+ **本模块导出 Config**。
43
43
  */
44
44
  // ⚠️ schemastery 彻底拿不到时,这里会是 undefined —— **插件照常加载**,只是设置页降级
45
45
  // (宁可少一个设置页,也不能因为一个 import 让整条插件不激活 ✗)
@@ -57,7 +57,9 @@ export const Config = !z ? undefined : z.object({
57
57
  transcribe: z.boolean().default(false).extra('volatile', true),
58
58
  /** 本地转写用哪个 Whisper 模型(tiny / base / small / medium) */
59
59
  asrModel: z.string().default('small').extra('volatile', true),
60
- /** ⭐ 每轮最多发几条评论(每条里 @ 一次下面那个人) */
60
+ /** ⭐ 每轮最多发几条评论(每条里 @ 一次下面那个人);**有效范围 0–20,口径见 clampPatch**(0 = 不发评论)
61
+ * ⚠️ 这里故意**不加 `.max(20)`**:schema 校验不过 ⇒ cordis 拒收这条 entry ⇒ **整条插件不激活** ✗
62
+ * (跟 schemastery 拿不到时的降级同一个道理)—— 范围一律由 clampPatch 在**写**和**读**两头钳。 */
61
63
  commentPerRound: z.natural().default(1).extra('volatile', true),
62
64
  /** ⭐ 要 @ 谁(B站 昵称);**留空 = 不发评论** —— 评论存在的意义就是 @ 那一下 */
63
65
  mentionName: z.string().default('').extra('volatile', true),
@@ -165,24 +167,108 @@ export function apply(ctx, config) {
165
167
  }
166
168
  return out
167
169
  }
168
- const cfg = normalize(readJson(cfgFile, {}), normalize(config, { ...DEFAULTS }))
169
- const saveCfg = (patch) => { Object.assign(cfg, normalize(patch, cfg)); writeJson(cfgFile, cfg) }
170
-
171
- ensureHomeFiles(pluginDir, stateDir, log)
170
+ /* ── 配置从哪来:DSH 自带的「插件设置」页 > 插件自己的 config.json > 默认值 ──
171
+ * 老代码是 `normalize(readJson(cfgFile, {}), normalize(config, DEFAULTS))` —— config.json 一刀切盖住宿主,
172
+ * 后果:用户在 DSH 自带的插件设置页里改了值,插件照旧读 config.json 的旧值 ⇒ **静默不生效** ✗。
173
+ * 判据用 describe() 给的 `user` 层(= 用户在原生设置页**显式设过**的键):
174
+ * 设过的键 ⇒ 听宿主的;没设过 ⇒ 还是 config.json 说了算(老用户零回归 ✓)。
175
+ * 拿不到设置服务就完全退回老行为。
176
+ */
177
+ /* ⭐ 「设置里能写什么范围」收在一处 —— HTTP 接口、bili_config 工具、**读配置**三处都走它,
178
+ * 所以 perDay 0–50 / commentPerRound 0–20 这些数字**只有这一份**(schema 里不再写一遍)。
179
+ * (2026-10-16 审查修:原来只有 HTTP 路径钳制,工具路径连 perDay 都能写任意值;
180
+ * commentPerRound 两条路径都没钳,直接 POST 能写 9999)
181
+ * (2026-10-17 复查补:DSH 原生设置页那条路我们钳不到**写** —— 用户能在那儿直接填 9999 ⇒
182
+ * loadCfg 读回来也过一遍,写进去和读出来一个口径) */
183
+ const clampPatch = (patch) => {
184
+ const p = { ...patch }
185
+ if ('perDay' in p) p.perDay = Math.max(0, Math.min(Number(p.perDay) || 0, 50))
186
+ if ('reportLevel' in p) p.reportLevel = ['full', 'brief', 'off'].includes(String(p.reportLevel)) ? String(p.reportLevel) : 'full'
187
+ if ('replyToComments' in p) p.replyToComments = Boolean(p.replyToComments)
188
+ if ('replyPerRound' in p) p.replyPerRound = Math.max(1, Math.min(Number(p.replyPerRound) || 1, 10))
189
+ if ('commentPerRound' in p) p.commentPerRound = Math.max(0, Math.min(Number(p.commentPerRound) || 0, 20))
190
+ return p
191
+ }
172
192
 
173
- /* ── 设置命名空间:让 DSH 自带的「插件设置」能编辑这份 Config ──
174
- * 三个条件缺一不可(抄 qq-mode-console 的血泪注释):
175
- * ① loader entry 的 id 必须**等于命名空间名** —— 我们 cordis.patch.yml 里就是 `bili-duty` ✓
176
- * ② 插件模块导出 `Config` ✓ ③ Config 里至少一个字段标 volatile ✓(我们都标了)
193
+ const hostCfg = () => normalize(config, { ...DEFAULTS }) // 宿主那份(schema 校验过、默认值已填)
194
+ const savedCfg = () => normalize(readJson(cfgFile, {}), { ...DEFAULTS })
195
+ const CFG_SIG = ['perDay', 'mentionName', 'replyToComments', 'overlayMcp'] // 靠这几个键认出"哪一行是我们"
196
+ let settingsRow = { at: 0, row: null }
197
+ /**
198
+ * 我们那一行设置表单(拿不到就返回 null ⇒ 一切按 config.json 来)。
199
+ * ⚠️ DSH 自带的「插件设置」页**不需要注册** —— 老代码那句 `ctx.settings.register(...)` 是个不存在的方法,
200
+ * 每次都抛 TypeError、被 catch 吞掉(2026-10-17 读宿主源码 @deepseek-ai/dsh-settings 才明白):
201
+ * describe() 自己扫 profile 里**带 volatile 字段**的条目列出来(lib/index.js:413-452),
202
+ * 命名空间 ns **等于 loader 条目的 id**(我们 cordis.patch.yml 里是 `bili-scroll`,不是老注释写的 bili-duty)。
203
+ * 另外两条同样要紧(都是读宿主源码得来的,别再想当然):
204
+ * ① `apply(ctx, config)` 收到的 config 就是 fiber.config 本体,volatile 字段一改宿主**就地**改它
205
+ * (cordis-plugin-loader/src/config/entry.ts:`this.fiber._config = …` + `_commitVolatile()`)⇒
206
+ * 启动时快照的 cfg 永远看不到设置页的改动,用之前得 refreshCfg()。
207
+ * ② describe() 每行给三层:value(生效值)/ base(继承层)/ user(用户在设置页显式设过的那层)。
177
208
  */
178
- try {
179
- if (typeof ctx.settings?.register === 'function') {
180
- ctx.settings.register('bili-duty', Config, { base: {}, applies: 'live' })
181
- log('已注册设置命名空间 bili-duty')
209
+ const settingsDesc = (fresh = false) => {
210
+ const t = Date.now()
211
+ if (!fresh && t - settingsRow.at < 3000) return settingsRow.row // 挡 UI 轮询:describe() 要扫所有条目
212
+ let row = null
213
+ try {
214
+ const rows = ctx.settings?.describe?.() || []
215
+ row = rows.find((r) => r?.value && CFG_SIG.every((k) => k in r.value)) || null
216
+ } catch (e) { log(`读 DSH 设置表单失败(不影响跑):${e?.message ?? e}`) }
217
+ settingsRow = { at: t, row }
218
+ return row
219
+ }
220
+ const loadCfg = () => {
221
+ const saved = savedCfg()
222
+ const row = settingsDesc()
223
+ // ⭐ 读回来统一过一遍范围:原生设置页那边我们是钳不到写的(见 clampPatch 的注释)
224
+ if (!row) return clampPatch(saved)
225
+ const user = row.user || {}
226
+ const out = { ...hostCfg() }
227
+ for (const k of Object.keys(DEFAULTS)) if (user[k] === undefined) out[k] = saved[k]
228
+ return clampPatch(out)
229
+ }
230
+ const cfg = loadCfg()
231
+ // ⭐ 配置是"随时可能被设置页就地改掉"的东西 ⇒ 每个入口用之前重算一遍(cfg 这个对象要一直指向同一份)
232
+ const refreshCfg = () => { Object.assign(cfg, loadCfg()) }
233
+ const saveCfg = (patch) => { Object.assign(cfg, normalize(patch, cfg)); writeJson(cfgFile, cfg) }
234
+ /**
235
+ * ⭐ 同时写一份到宿主:不写的话会出现死结 —— 某个键一旦在原生设置页设过就由宿主说了算,
236
+ * 插件页再改那个键就成了"改了没反应" ✗。写失败不算致命(config.json 那份还在),把话带回给用户。
237
+ */
238
+ const saveCfgHost = async (patch) => {
239
+ const attempt = async (fresh) => {
240
+ const row = settingsDesc(fresh)
241
+ if (!row?.ns || typeof ctx.settings?.update !== 'function') {
242
+ throw Object.assign(new Error('DSH 这边没有可写的设置表单(值只落在插件自己的 config.json 里)'), { noForm: true })
243
+ }
244
+ await ctx.settings.update(row.ns, patch, row.revision)
245
+ return row.ns
246
+ }
247
+ try { return { ok: true, ns: await attempt(false) } }
248
+ catch (e) {
249
+ if (!e?.noForm) { // revision 过期(刚有别人改过)⇒ 重取一次再写
250
+ try { return { ok: true, ns: await attempt(true), retried: true } } catch (e2) { e = e2 }
251
+ }
252
+ log(`写 DSH 设置失败:${e?.message ?? e}`)
253
+ return { ok: false, error: String(e?.message ?? e) }
182
254
  }
183
- } catch (e) {
184
- if (!/already registered/i.test(String(e?.message ?? e))) log(`注册设置命名空间失败:${e?.message ?? e}`)
185
255
  }
256
+ /**
257
+ * ⭐ 落配置的**唯一入口**:插件自己那份 config.json + DSH 原生设置页那份,钳完范围一起写。
258
+ * (2026-10-17 复查修:`saveCfg` + `await saveCfgHost` 这对义务原来在三个调用点各抄一遍 ——
259
+ * 漏掉任何一处,正好就是「设置页改了没反应」那个 bug 本身 ⇒ 收成一个函数。)
260
+ */
261
+ const persistCfg = async (patch) => {
262
+ const clean = clampPatch(patch)
263
+ saveCfg(clean)
264
+ const host = await saveCfgHost(clean)
265
+ return { clean, host }
266
+ }
267
+
268
+ ensureHomeFiles(pluginDir, stateDir, log)
269
+
270
+ // ⭐ 值班章程单独记账(跟 mcp_server.py 不一样:用户改过就得留着 —— 见 state.mjs 的 ensureCharter)
271
+ ensureCharter(pluginDir, stateDir, log)
186
272
 
187
273
  /* ─────────────── MCP(内部起那个 Python MCP) ─────────────── */
188
274
 
@@ -323,12 +409,14 @@ export function apply(ctx, config) {
323
409
  description: '看它今天的状态:跑了没跑、挑了几条、额度剩多少、报告在哪、登录过没有、下次窗口什么时候开。',
324
410
  parameters: {},
325
411
  async execute() {
412
+ refreshCfg()
326
413
  const st = readJson(join(stateDir, 'state.json'), {})
327
414
  const now = new Date()
328
415
  return {
329
416
  ok: true,
330
417
  today: dayKey(now),
331
418
  ranToday: st.day === dayKey(now) ? Number(st.count || 0) : 0,
419
+ pickedToday: countPickedToday(now),
332
420
  perDay: cfg.perDay,
333
421
  windows: Object.fromEntries(Object.entries(WINDOWS).map(([k, w]) => [k, {
334
422
  label: w.label, start: w.start, end: w.end,
@@ -399,8 +487,12 @@ export function apply(ctx, config) {
399
487
  + '例:`{"perDay":5,"peakValley":true,"noon":true,"night":true,"autoComment":false,"transcribe":false,"keywords":["DeepSeek Harness"]}`',
400
488
  parameters: { patch: { type: 'object', additionalProperties: true, description: '要改的字段(可省)' } },
401
489
  async execute(args) {
402
- if (args?.patch && typeof args.patch === 'object' && !Array.isArray(args.patch)) saveCfg(args.patch)
403
- return { ok: true, config: cfg, windows: WINDOWS, home: stateDir, venvReady: venvReadyNow() }
490
+ let host = null
491
+ if (args?.patch && typeof args.patch === 'object' && !Array.isArray(args.patch)) {
492
+ // ⭐ 设置页显式设过的键以宿主为准 ⇒ 必须跟着写一份,否则"改了没反应"(钳范围也在这里)
493
+ host = (await persistCfg(args.patch)).host
494
+ }
495
+ return { ok: true, config: cfg, windows: WINDOWS, home: stateDir, venvReady: venvReadyNow(), host }
404
496
  },
405
497
  })
406
498
 
@@ -410,11 +502,43 @@ export function apply(ctx, config) {
410
502
  const bumpState = (patch) => {
411
503
  const st = readJson(dutyStateFile, {})
412
504
  const today = dayKey()
413
- if (st.day !== today) { st.day = today; st.count = 0; st.windows = {} }
505
+ if (st.day !== today) { st.day = today; st.count = 0; st.picked = 0; st.windows = {} }
414
506
  writeJson(dutyStateFile, { ...st, ...patch })
415
507
  return st
416
508
  }
417
509
 
510
+ /* ⭐ 「定量刷」承诺的是**每天挑几条**(2026-10-16 审查修 H1:原来 st.count 从没自增过,
511
+ * tick 那道 `count >= perDay` 的闸形同虚设 ⇒ noon + night 两个窗口各跑满一轮,
512
+ * 后一轮还会把前一轮的报告覆盖掉)。
513
+ * 数哪儿?数**今天的报告**里那些 `### BV号` 标题行(章程写死了这个格式)。
514
+ * ⚠️ 为什么不数「报告里出现的所有 BV」(2026-10-17 复查修的):报告里还会出现**没挑中**的 BV ——
515
+ * 「因无字幕跳过 N 条」那行、评论区里别人问起的视频、"判重表读不到"的说明 —— 全算进去的话,
516
+ * perDay 默认 5 ⇒ 一轮里出现 5 个 BV 就把当天额度吃光 ⇒ **傍晚那轮直接不跑** ✗。
517
+ * ⚠️ 围栏 ``` 里的内容一律不看:那一轮没写报告时 stubReportIfMissing 会把 duty.log 尾部贴进报告,
518
+ * 那里面的 BV 是"看过的",不是"挑出来的"。
519
+ * ⚠️ 数不出来(章程那条格式没照做)就当 0 条 —— 宁可少算(顶多多跑一轮、额度也写在提示词里),
520
+ * 也别把"提到过"算成"挑中"(那会让使用者当天再也拿不到内容)✓。 */
521
+ const PICK_RE = /^#{2,3}[ \t]*(BV[0-9A-Za-z]{10})/gm
522
+ const reportFileOf = (now = new Date()) => join(outDir, `${dayKey(now)}.md`)
523
+ const countPicks = (file) => {
524
+ try {
525
+ const text = readFileSync(file, 'utf8').replace(/```[\s\S]*?```/g, '')
526
+ const ids = new Set()
527
+ for (const m of text.matchAll(PICK_RE)) ids.add(m[1])
528
+ return ids.size
529
+ } catch { return 0 }
530
+ }
531
+ // 报告是事实来源;state 兜住"报告被删 / 手改"那一下(取两者里大的)
532
+ const countPickedToday = (now = new Date()) => {
533
+ const st = readJson(dutyStateFile, {})
534
+ const fromState = st.day === dayKey(now) ? Number(st.picked || 0) : 0
535
+ return Math.max(fromState, countPicks(reportFileOf(now)))
536
+ }
537
+ const roundsToday = (now = new Date()) => {
538
+ const st = readJson(dutyStateFile, {})
539
+ return st.day === dayKey(now) ? Number(st.count || 0) : 0
540
+ }
541
+
418
542
  // ⭐ 一键升级:由宿主自己跑 `dsh plugin --profile web add dsh-bili-scroll`
419
543
  // ⚠️ 装完必须重启 dsh 才生效;⚠️ Windows 上"替换正在运行的插件"可能被文件占用挡住(我们踩过)
420
544
  let upgrading = false
@@ -448,18 +572,28 @@ export function apply(ctx, config) {
448
572
  '/usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
449
573
  '/usr/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
450
574
  ].filter(Boolean)
575
+ // ⭐ 2026-10-16(issue #5 / #6 / #7):**桌面版是 Electron 安装包,不是 npm -g 装的** ⇒ 上面几条全找不到
576
+ // ⇒ 老逻辑退到 `dsh`(PATH 里没有这个命令)⇒ 那一轮必败、而且只留下一句 exit 1 ✗
577
+ // 桌面版把 dsh 打在应用里 ⇒ 按 `process.resourcesPath` 猜几个位置(找到就用;找不到就**明说**)
578
+ if (process.resourcesPath) {
579
+ for (const rel of [
580
+ ['app.asar.unpacked', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'],
581
+ ['app', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'],
582
+ ['node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'],
583
+ ]) {
584
+ const p = join(process.resourcesPath, ...rel)
585
+ if (existsSync(p)) return { command: process.execPath, args: [p], shell: false }
586
+ }
587
+ }
451
588
  for (const g of guesses) if (existsSync(g)) return { command: process.execPath, args: [g], shell: false }
589
+ // ⚠️ 走到这儿就是真找不到 —— 一定要出声(静默失败比报错更坏)
590
+ log('⚠️ 找不到 dsh:桌面版多半不是 npm 全局安装的 ⇒ 请在设置里把「dsh 路径」指到 <应用目录>/…/@deepseek-ai/dsh/lib/bin.js,或改用 npm 装的 dsh')
452
591
  return { command: 'dsh', args: [], shell: true }
453
592
  }
454
593
 
455
594
  const promptFor = () => {
456
- // ⭐ 章程取哪一份:**插件里那份(永远跟版本一致)**;但家里那份如果**比它新**,说明使用者自己改过 ⇒ 听使用者的。
457
- const shipped = join(pluginDir, 'prompts', 'duty.md')
458
- const homeCopy = join(stateDir, 'duty.md')
459
- let duty = shipped
460
- try {
461
- if (existsSync(homeCopy) && statSync(homeCopy).mtimeMs > statSync(shipped).mtimeMs) duty = homeCopy
462
- } catch { /* 读不到就用 shipped 那份 */ }
595
+ // ⭐ 章程取哪一份:家里那份优先(ensureCharter 保证铺好;他改过的话就是他那份,升级的事由 ensureCharter 记账处理)
596
+ const duty = charterPath(pluginDir, stateDir)
463
597
  const kws = Array.isArray(cfg.keywords) ? cfg.keywords.filter(Boolean) : []
464
598
  // ⭐ 开场先让它读**工作区**的 AGENTS.md —— 这一轮的工作目录就是使用者的工作区,
465
599
  // AGENTS.md 本来就会自动注入;这里再点一句,保证它真按使用者的设定来(别人的设定同理)。
@@ -467,25 +601,41 @@ export function apply(ctx, config) {
467
601
  // 所以"使用者的设定"由**我们自己读进来**喂给它 —— 它在宿主进程里跑,没有沙箱限制 ✓
468
602
  let ownerDoc = ''
469
603
  try {
470
- const wsDir = String(cfg.workspace || '').trim() || process.cwd()
471
- const agents = join(wsDir, 'AGENTS.md')
472
- if (existsSync(agents)) {
604
+ // ⚠️ 2026-10-16 用户反馈("只要给人设就会走偏"):留空时我们以前退到 `process.cwd()`,
605
+ // 也就是 **dsh 进程自己的工作目录** —— 那对多数人根本不是他的工作区 ✗
606
+ // ⇒ 读错人设、甚至把别的目录的设定读进来 ✓
607
+ // ⭐ 现在的规矩:**留空 = 不读设定**(宁可按中性说话,也别猜错 ✓);要按人设就自己填工作区 ✓。
608
+ const wsDir = String(cfg.workspace || '').trim()
609
+ const agents = wsDir ? join(wsDir, 'AGENTS.md') : ''
610
+ if (agents && existsSync(agents)) {
473
611
  const text = readFileSync(agents, 'utf8')
474
612
  ownerDoc = '\n\n===== 使用者的 AGENTS.md(他写给自己 AI 的人设与准则,**按这个来**)=====\n'
475
613
  + text.slice(0, 6000)
476
614
  + '\n===== 设定结束 =====\n'
477
615
  }
478
616
  } catch { /* 读不到就算了,别因此跑不成 */ }
617
+ // ⭐ 「每天最多挑 N 条」是**全天**的账:上一轮已经挑过的要扣掉,这一轮只给剩下的额度
618
+ const quota = Math.max(0, Number(cfg.perDay) || 0)
619
+ const already = countPickedToday()
620
+ const left = Math.max(0, quota - already)
479
621
  return `你是这个账号的 B站 助手。`
480
622
  + `下面是使用者的设定(如果有)—— **按它来**,别用你默认那套人格。`
481
623
  + ownerDoc
624
+ + (ownerDoc
625
+ ? ''
626
+ : '⭐ 这一轮**没有**拿到使用者的设定(他没填「工作区」,或那个目录里没有 `AGENTS.md`)⇒ **按中性说话**:别扮演任何人、也别自己编一套设定 ✓。')
482
627
  + `然后读章程 ${duty},按里面的要求刷一轮 B站,`
483
628
  + (cfg.reportLevel === 'brief'
484
629
  ? `日报写成薄版:每条一两行(值不值得 + 链接)就够,别写长摘要。`
485
630
  : cfg.reportLevel === 'off'
486
631
  ? `这一轮不要写日报文件,也别生成任何报告;只有真发了评论就一句话记下。`
487
632
  : `把报告写到 ${join(outDir, `${dayKey()}.md`)}。`)
488
- + `今天最多挑 ${cfg.perDay} 条;关键词${kws.length ? `用这些:${kws.join('、')}` : '用章程里的默认那几组'}。`
633
+ + (cfg.reportLevel === 'off' ? '' : `⚠️ 今天那份报告里可能已经有上一轮写的东西 —— 有就**追加到末尾**,别覆盖(配额按全天算)。`)
634
+ + (cfg.reportLevel === 'off' ? '' : '⭐ 报告里**每挑中一条**,第一行写成 `### BV号`(例:`### BV1xxxxxxx`)—— 宿主**就数这些标题行**算今天挑了几条:漏写当成没挑中,给只是提到 / 跳过的视频加上标题则当成挑中。')
635
+ + (already > 0
636
+ ? `今天已经挑过 ${already} 条了 ⇒ **这一轮最多再挑 ${left} 条**(「每天最多 ${quota} 条」是全天总量,不是每轮);`
637
+ : `今天最多挑 ${quota} 条;`)
638
+ + `关键词${kws.length ? `用这些:${kws.join('、')}` : '用章程里的默认那几组'}。`
489
639
  + `本地转写:${cfg.transcribe
490
640
  ? (asrReadyNow()
491
641
  ? '开着,而且装好了(没字幕的视频用家里那份 asr.py 转,命令见章程)'
@@ -494,6 +644,9 @@ export function apply(ctx, config) {
494
644
  + `⭐ 不管哪种情况,**报告里都要写一行「因无字幕跳过 N 条」**(N=0 也写)—— 别让它悄悄少几条。`
495
645
  + `判重表 = ${join(stateDir, 'seen.md')}(**就是这个绝对路径**,别写到家目录 ~、也别写到别处);`
496
646
  + `⚠️ 它读不出来或写不进去,**必须在报告里明说** —— 那一轮宁可不发评论,也别当成"没有记录"重发一遍。`
647
+ + `⭐ 另外把这一轮**看过的每一条视频(挑中的和跳过的都算)**追加进归档:${join(digestDir, `${dayKey().slice(0, 7)}.md`)}`
648
+ + ` —— 一行一条:日期 · BV 号 · 标题 · UP · 一句话结论 · 链接(跳过的那条在后面注明理由);`
649
+ + `这是**给以后检索用**的(报告是给人看的,归档是给"回头翻"用的),别省略、也别只写挑中的。`
497
650
  + `自动评论并 @ ${cfg.mentionName || '(还没填要 @ 谁)'} 是${cfg.autoComment && cfg.mentionName
498
651
  ? `开着的(每轮最多 ${cfg.commentPerRound} 条评论,每条在正文里纯文本 @ ${cfg.mentionName},150 字内)`
499
652
  : '关着的(只写报告,别发评论)'}。`
@@ -509,6 +662,21 @@ export function apply(ctx, config) {
509
662
  * (headless 里那两条 B站 MCP 就是当初手写进去的)。分发给别人时不能要求人家改 profile,
510
663
  * 所以插件自己生成一份、跑的时候 `--patch` 带上(dsh 支持可重复的 `--patch`)。
511
664
  */
665
+ /**
666
+ * ⭐ 值班轮能看到哪些 MCP 工具(2026-10-17 审查里的 H3)
667
+ * 起因:overlay 把整台 mcp_server.py 挂成 stdio server ⇒ 那一轮的模型手里握着**原生全套**工具
668
+ * (bili_reply / bili_crawl / bili_send_opus / bili_upload_video / bili_login…)——
669
+ * 宿主这边 autoComment / mentionName 两道闸只拦得住我们自己的 bili_reply 工具,拦不住它 ✗。
670
+ * 做法:白名单写进 overlay 的 env(mcp-client 会把它并进子进程环境),
671
+ * mcp_server.py 那头**不在白名单里的工具根本不注册** —— 连名字都不给它看见 ✓。
672
+ * 要读全就手工改这个列表(或者直接跑 mcp_server.py,不设这个变量 = 全放)。
673
+ */
674
+ const mcpWhitelist = () => {
675
+ const list = ['bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info']
676
+ if (cfg.autoComment || cfg.replyToComments) list.push('bili_reply') // 只有真要发评论才给这只
677
+ return list
678
+ }
679
+
512
680
  const ensureOverlay = () => {
513
681
  if (!cfg.overlayMcp) return null
514
682
  const file = join(stateDir, 'headless-overlay.yml')
@@ -524,6 +692,8 @@ export function apply(ctx, config) {
524
692
  ` command: '${posix(cfg.pythonPath || 'python')}'`,
525
693
  ' args:',
526
694
  ` - '${posix(script)}'`,
695
+ ' env:',
696
+ ` BILI_MCP_TOOLS: '${mcpWhitelist().join(',')}'`,
527
697
  '',
528
698
  ].join('\n')
529
699
  try { writeFileSync(file, yml, 'utf8'); return file } catch (e) { log(`写 overlay 失败:${e?.message ?? e}`); return null }
@@ -584,6 +754,7 @@ export function apply(ctx, config) {
584
754
 
585
755
  const runDuty = (why = 'window') => {
586
756
  try {
757
+ refreshCfg() // ⭐ 设置页刚改过的值要立刻生效(config 是就地改的,快照会过期)
587
758
  const dsh = resolveDsh()
588
759
  const overlay = ensureOverlay()
589
760
  const args = [...dsh.args, '--profile', cfg.headlessProfile || 'headless']
@@ -603,18 +774,35 @@ export function apply(ctx, config) {
603
774
  stdio: ['ignore', 'pipe', 'pipe'],
604
775
  // ⭐ PYTHONUTF8 / PYTHONIOENCODING:Windows 上 Python 默认按 GBK 编解码,
605
776
  // 中文 / emoji 打进管道会炸(用户反馈过 UnicodeDecodeError),一律按 UTF-8 来
606
- env: pyEnv({ BILI_DUTY_HOME: stateDir }),
777
+ // ⭐ ELECTRON_RUN_AS_NODE:**桌面版(Electron)里 spawn 出去的 node 必须带这个**,
778
+ // 否则它当成"再开一个 Electron 应用"、什么都不干就以 0 退出(issue #5:0.17 秒、code 0、什么都没做)
779
+ env: pyEnv({ BILI_DUTY_HOME: stateDir, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) }),
607
780
  })
608
- const pick = (buf) => appendFileSync(logFile, String(buf), 'utf8')
781
+ // ⚠️ 2026-10-16 issue #1(用户 `miaoxingyunduo` 贴了崩溃日志):`duty.log` 被别的进程占着时
782
+ // (`EBUSY`) `appendFileSync` 会抛异常 —— 而这里是 **stream 的 data 回调**,没人接
783
+ // ⇒ **未捕获异常 ⇒ 整个 DSH 宿主被杀**(所有会话一起没)。跟 10-14 定时器那次是同一类 ✗
784
+ // ⇒ ⭐ 规矩:**插件里任何写日志/写文件的地方都包好 try/catch**,再错也只该"这一轮没跑成" ✓
785
+ const pick = (buf) => {
786
+ try { appendFileSync(logFile, String(buf), 'utf8') } catch { /* 写不进去就丢,绝不能抛 */ }
787
+ }
609
788
  child.stdout?.on('data', pick)
610
789
  child.stderr?.on('data', pick)
611
790
  child.on('exit', (code) => {
612
791
  log(`刷B站结束(${why},code=${code})`)
613
792
  stubReportIfMissing(code) // ⭐ 没写报告就补一份占位 —— 别让使用者什么都看不到
793
+ rotateLogIfBig() // ⭐ 日志别无声地长(三天 797KB ⇒ 一年约 100MB)
794
+ // ⭐ 这一轮挑了几条:从今天的报告里数(上一轮挑的还在同一份文件里 ⇒ 数出来就是全天的账)
795
+ try {
796
+ const st = readJson(dutyStateFile, {})
797
+ const prev = st.day === dayKey() ? Number(st.picked || 0) : 0
798
+ const picked = countPickedToday() // 报告是事实来源、state 只是兜底(跟闸门同一个算法)
799
+ if (picked > prev) { bumpState({ picked }); log(`今天挑到 ${picked} 条(上限 ${Number(cfg.perDay) || 0} 条)`) }
800
+ } catch (e) { log(`数挑了几条时出错(不影响这一轮):${e?.message ?? e}`) }
614
801
  // ⭐ 发完评论之后**过一会儿再核一次**(2026-10-16 加的「评论发出后复核」):
615
802
  // 接口回"发送成功"≠ 公开可见(新号/风控会进审核);而"不公开"时连 @ 通知都不会推 ⇒
616
803
  // 插件会以为发了、下一轮不再发 ⇒ 一条都没到人。等 90 秒再查,避免把"还在审核"误判成"没发出去"。
617
- setTimeout(() => { Promise.resolve(runVerify('round')).catch((e) => log('复核异常:' + (e?.message ?? e))) }, 90_000)
804
+ const verifyTimer = setTimeout(() => { Promise.resolve(runVerify('round')).catch((e) => log('复核异常:' + (e?.message ?? e))) }, 90_000)
805
+ verifyTimer.unref?.() // 别为了一次复核把宿主进程的退出拖住 90 秒
618
806
  })
619
807
  child.on('error', (e) => log(`刷B站起不来:${e?.message ?? e}`))
620
808
  return { started: true, why, profile: cfg.headlessProfile, cwd, log: logFile }
@@ -632,18 +820,21 @@ export function apply(ctx, config) {
632
820
  */
633
821
  const tick = () => {
634
822
  try {
823
+ refreshCfg() // ⭐ 定时器活得比设置页久:每一跳都重新读一遍配置
635
824
  if (!cfg.peakValley) return
636
825
  const now = new Date()
637
826
  const st = readJson(dutyStateFile, {})
638
827
  const today = dayKey(now)
639
- const count = st.day === today ? Number(st.count || 0) : 0
640
- if (count >= Number(cfg.perDay || 0)) return
828
+ // ⭐ 闸门看的是「今天已经**挑到几条**」(从今天的报告里数出来),不是「跑了几轮」
829
+ if (countPickedToday(now) >= Number(cfg.perDay || 0)) return
830
+ // ⭐ 「不写日报」那档没有报告可数 ⇒ 数不出条数就保守一点:一天只跑一轮
831
+ if (String(cfg.reportLevel || 'full') === 'off' && roundsToday(now) >= 1) return
641
832
  for (const k of ['noon', 'night']) {
642
833
  if (!cfg[k]) continue
643
834
  if (!inWindow(now, WINDOWS[k])) continue
644
835
  const key = `${today}:${k}`
645
836
  if (st.windows?.[key]) continue
646
- bumpState({ windows: { ...(st.windows || {}), [key]: true }, lastRunAt: now.toISOString(), lastWhy: k })
837
+ bumpState({ windows: { ...(st.windows || {}), [key]: true }, count: roundsToday(now) + 1, lastRunAt: now.toISOString(), lastWhy: k })
647
838
  runDuty(`window:${k}`)
648
839
  return
649
840
  }
@@ -666,9 +857,13 @@ export function apply(ctx, config) {
666
857
  }
667
858
  const readBody = (req) => new Promise((resolve) => {
668
859
  let data = ''
669
- req.on('data', (c) => { data += c; if (data.length > 1_000_000) req.destroy() })
670
- req.on('end', () => { try { resolve(JSON.parse(data || '{}')) } catch { resolve({}) } })
671
- req.on('error', () => resolve({}))
860
+ let done = false
861
+ // ⚠️ 超过 1MB 得**先把 Promise 收掉**再掐断:只 destroy 的话它永远不 settle,
862
+ // 这个请求就挂在那儿(不回响应、也不释放)
863
+ const finish = (v) => { if (!done) { done = true; resolve(v) } }
864
+ req.on('data', (c) => { data += c; if (data.length > 1_000_000) { data = ''; finish({}); req.destroy() } })
865
+ req.on('end', () => { try { finish(JSON.parse(data || '{}')) } catch { finish({}) } })
866
+ req.on('error', () => finish({}))
672
867
  })
673
868
 
674
869
  const venvPython = () => process.platform === 'win32'
@@ -691,15 +886,18 @@ export function apply(ctx, config) {
691
886
 
692
887
  let uvOk = null
693
888
  const haveUv = async () => {
694
- if (uvOk !== null) return uvOk
695
- uvOk = await new Promise((resolve) => {
889
+ if (uvOk) return true
890
+ const ok = await new Promise((resolve) => {
696
891
  try {
697
892
  const c = spawn('uv', ['--version'], { windowsHide: true, stdio: 'ignore' })
698
893
  c.on('exit', (code) => resolve(code === 0))
699
894
  c.on('error', () => resolve(false))
700
895
  } catch { resolve(false) }
701
896
  })
702
- return uvOk
897
+ // ⭐ 只缓存"有":把"没有"也缓存住的话,这一轮里刚下好的 uv(或用户刚装上的)会被一直当成没有,
898
+ // 非重启 dsh 不可 ✗(调用点只有装依赖那两处,多探一次几毫秒)
899
+ if (ok) uvOk = true
900
+ return ok
703
901
  }
704
902
 
705
903
  /** 探一下某个命令在不在(用于"这台机器有没有 python")—— 陌生人第一条坎就卡在这儿 */
@@ -753,6 +951,25 @@ export function apply(ctx, config) {
753
951
  ].join('\n'), 'utf8')
754
952
  }
755
953
  } catch { /* 建不了也别拦着插件加载 */ }
954
+
955
+ // ⭐ 归档目录:那一轮**被沙箱关着**,只写得了"启动前就已存在"的目录 ⇒ 宿主预建 ✓
956
+ // (2026-10-16 主人要的"它看过什么"清单:报告是给人看的,归档是给检索用的 ✓)
957
+ const digestDir = join(stateDir, 'digest')
958
+ try { mkdirSync(digestDir, { recursive: true }) } catch { /* 建不了也别拦着插件加载 */ }
959
+
960
+ /**
961
+ * ⭐ 日志轮转(2026-10-16 量出来的:`duty.log` 三天就 797 KB、没有轮转 ⇒ 一年约 100 MB ✗)
962
+ * 超过 2MB 就滚成 `duty.log.1`(只留一份),别让它在 C 盘上无声地长。
963
+ */
964
+ const rotateLogIfBig = (limit = 2 * 1024 * 1024) => {
965
+ try {
966
+ if (!existsSync(logFile) || statSync(logFile).size < limit) return
967
+ const bak = logFile + '.1'
968
+ try { rmSync(bak, { force: true }) } catch { /* 删不掉就算了 */ }
969
+ renameSync(logFile, bak)
970
+ appendFileSync(logFile, `${new Date().toISOString()} 日志超过 2MB,旧的滚到 duty.log.1\n`, 'utf8')
971
+ } catch { /* 轮转失败不该影响跑一轮 */ }
972
+ }
756
973
  let installingAsr = false
757
974
 
758
975
  /** 找 uv:系统里的 → 插件家里下好的 → 自己下一份(给"两样都没有"的人);主/转写两套安装共用 */
@@ -852,7 +1069,10 @@ export function apply(ctx, config) {
852
1069
  let code = 1
853
1070
  if (uvCmd) code = await spawnLogged(uvCmd, ['pip', 'install', '--python', venvPy, '-i', mirror, '-r', reqFile])
854
1071
  if (code !== 0) code = await spawnLogged(venvPy, ['-m', 'pip', 'install', '-i', mirror, '-r', reqFile])
855
- if (code === 0 && existsSync(venvPy)) { saveCfg({ pythonPath: venvPy }); appendInstall(`已把 pythonPath 设为 ${venvPy}\n`) }
1072
+ if (code === 0 && existsSync(venvPy)) {
1073
+ await persistCfg({ pythonPath: venvPy }) // ⭐ 不然设置页里显式设过 pythonPath 的话,这里就白写了
1074
+ appendInstall(`已把 pythonPath 设为 ${venvPy}\n`)
1075
+ }
856
1076
  appendInstall(`=== 结束(exit ${code},venvReady=${venvReadyNow()})===\n`)
857
1077
  log(`装依赖结束:exit=${code} venvReady=${existsSync(venvPy)}`)
858
1078
  } catch (e) {
@@ -887,6 +1107,7 @@ export function apply(ctx, config) {
887
1107
  let updateCache = null // { at, latest } —— 检查更新的结果缓存
888
1108
 
889
1109
  const status = () => {
1110
+ refreshCfg()
890
1111
  const st = readJson(dutyStateFile, {})
891
1112
  const today = dayKey()
892
1113
  return {
@@ -895,7 +1116,8 @@ export function apply(ctx, config) {
895
1116
  config: cfg,
896
1117
  windows: WINDOWS,
897
1118
  today,
898
- ranToday: st.day === today ? Number(st.count || 0) : 0,
1119
+ ranToday: st.day === today ? Number(st.count || 0) : 0, // 今天跑了几轮
1120
+ pickedToday: countPickedToday(), // 今天挑到几条(从报告里数)
899
1121
  lastRunAt: st.lastRunAt || null,
900
1122
  lastWhy: st.lastWhy || null,
901
1123
  loggedIn: hasCredential(),
@@ -919,6 +1141,7 @@ export function apply(ctx, config) {
919
1141
  }
920
1142
 
921
1143
  const handle = async (req, res, url) => {
1144
+ refreshCfg()
922
1145
  const path = url.pathname
923
1146
  if (req.method === 'GET' && path === '/api/bili-duty/status') return sendJson(res, 200, status())
924
1147
 
@@ -934,13 +1157,10 @@ export function apply(ctx, config) {
934
1157
  'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace', 'reportLevel', 'replyToComments', 'replyPerRound']) {
935
1158
  if (k in body) patch[k] = body[k]
936
1159
  }
937
- if ('perDay' in patch) patch.perDay = Math.max(0, Math.min(Number(patch.perDay) || 0, 50))
938
- if ('reportLevel' in patch) patch.reportLevel = ['full', 'brief', 'off'].includes(String(patch.reportLevel)) ? String(patch.reportLevel) : 'full'
939
- if ('replyToComments' in patch) patch.replyToComments = Boolean(patch.replyToComments)
940
- if ('replyPerRound' in patch) patch.replyPerRound = Math.max(1, Math.min(Number(patch.replyPerRound) || 1, 10))
941
- saveCfg(patch)
942
- log(`设置已更新:${JSON.stringify(patch)}`)
943
- return sendJson(res, 200, { ok: true, config: cfg })
1160
+ // ⭐ 范围钳制 + 两份都写(config.json / DSH 原生设置页),统一走 persistCfg —— 见文件上方
1161
+ const { clean, host } = await persistCfg(patch)
1162
+ log(`设置已更新:${JSON.stringify(clean)}(DSH 那边${host.ok ? '已同步' : '没写成:' + host.error})`)
1163
+ return sendJson(res, 200, { ok: true, config: cfg, host })
944
1164
  }
945
1165
 
946
1166
  if (req.method === 'POST' && path === '/api/bili-duty/run') {