dsh-bili-scroll 2.0.4 → 2.1.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 CHANGED
@@ -42,12 +42,14 @@
42
42
 
43
43
  ## 仓库结构(改代码时看这里)
44
44
 
45
- - `index.js` —— host 半端:11 个工具 + 谷时定时器 + HTTP 接口 + 配置(`config.json` 与 DSH 原生设置页**逐字段对账**:见 `loadCfg` / `saveCfgHost`)+ 值班轮的工具白名单(`mcpWhitelist` → `BILI_MCP_TOOLS`)
45
+ - `index.js` —— host 半端:13 个工具 + 谷时定时器 + HTTP 接口 + 配置(`config.json` 与 DSH 原生设置页**逐字段对账**:见 `loadCfg` / `saveCfgHost`)+ 值班轮的工具白名单(`mcpWhitelist` → `BILI_MCP_TOOLS`)
46
46
  - `client.js` —— 浏览器半端:设置页(注册在 `settings.section`)
47
47
  - `lib/` —— 状态目录与家目录 / 极小的 MCP stdio 客户端 / 扫码登录 / **`hostdeps.mjs`(从宿主树取 `@deepseek-ai/*`,拿不到就退到自己依赖 / 本地等价实现)** / **`verify.mjs`(评论发出后复核)**
48
48
  - `prompts/duty.md` —— 一轮要做的事(判定口径 + 红线,**用户可以改**);升级规矩见 `lib/state.mjs` 的 `ensureCharter`(用户改过 ⇒ 保留他那份、新版落 `duty.md.new`)
49
49
  - `vendor/bilibili-mcp/` —— 内置的 Python MCP(MIT,AGI_Ananas)
50
50
  - `selfcheck.mjs` + `tools/` —— 本地预检:`node selfcheck.mjs`(**现在不用解析钩子了**;`tools/register.mjs` 那条还留着当备份)
51
+ · `tools/check-vision.py` —— 看图纯函数的回归检查(不联网)。动了 `_https` / `_cover_url` / `_snapshot_entries` / `_crop_tile` 就跑一次:
52
+ `<家目录>/.venv/Scripts/python.exe tools/check-vision.py`(用插件自己的 venv,里面才有 Pillow)
51
53
 
52
54
  ## 约定
53
55
 
package/CONTRIBUTORS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # 外部贡献与致谢
2
2
 
3
3
  这个插件的一些修复,最初的判断或实现来自社区。**这里只列代码/修法被采纳、并写进提交 `Co-authored-by` 的人**
4
- (只有这几位会计入 GitHub 的贡献者名单);其余只提了问题或线索的朋友,我在各条 PR / issue 下另行道谢,不列在这里。
4
+ (整包合入的 PR 作者由 git 历史自动计入);其余只提了问题或线索的朋友,我在各条 PR / issue 下另行道谢,不列在这里。
5
5
 
6
6
  | 人 | 采纳了什么 |
7
7
  |---|---|
@@ -9,6 +9,10 @@
9
9
  | [@Rookiexg](https://github.com/Rookiexg) | headless overlay 要补 `agent-default-model`,否则账号登录的机器会 `MISSING_CREDENTIAL`(PR #50) |
10
10
  | [@383891598](https://github.com/383891598) | yt-dlp 的进度条会污染 stdout,导致"输出非空 = 成功"恒真(PR #23,部分采纳) |
11
11
 
12
+ | [@Annoias](https://github.com/Annoias) | **看画面**:bili_cover / bili_frames 两个工具(用 B站 故事板裁帧、不下视频)+ 章程「什么时候该看画面」+ 看图纯函数自检(**PR #92,已整包合入**) |
13
+ | [@Anguis-L](https://github.com/Anguis-L) | **同源检查要比端口**(只比 hostname 会让本机别的端口的页面驱动写接口)+ 栅栏入参按契约传 headers(PR #94 的思路,已在自己的实现里采纳) |
14
+ | [@LUOXI23333](https://github.com/LUOXI23333) | **值班轮最小权限 profile + 窄口 IO 工具**的整体思路(issue #96 / PR #97)——这版按这个思路独立实现,取舍写在 #96 的回复里 |
15
+
12
16
  ## 关于"为什么不整包合 PR"
13
17
 
14
18
  这个仓库的做法是**一条一条读、把修法自己在 main 上实现**,而不是整包合并 —— 这样发布节奏在自己手上、出问题好定位回滚。
package/README.md CHANGED
@@ -21,7 +21,8 @@
21
21
  ## 它做什么
22
22
 
23
23
  - 按你填的关键词(留空则用内置那几组)搜近几天的视频
24
- - **逐条读字幕原文再判断**(不看摘要);没字幕的直接跳过 —— 除非你开了「本地转写」
24
+ - **逐条读字幕原文再判断**(不看摘要);没字幕的**演示类**视频会先从 B站 自带的故事板**抽几帧看画面**;
25
+ 实在读不到内容才跳过 —— 或者你开了「本地转写」
25
26
  - 写一份 Markdown 报告到 `out/<日期>.md`:每条含「为什么值得」+「它自己的一句话」;每条的段首固定写成 `### BV号` —— 插件数今天挑了几条,数的就是这些标题行
26
27
  - 开着「自动评论」时,每轮发一条评论、**@ 你**一次(默认每轮 1 条,设置页可调到每轮最多 20 条)
27
28
  - 可以在**峰谷时段自动跑**(DeepSeek 的优惠时段),省 token
@@ -115,9 +116,18 @@ dsh plugin --profile web add github:c1overzz/dsh-bili-scroll
115
116
  - 那一轮的**工作目录 = 插件自己的家目录**(它被沙箱关着,只写得了自己那一亩三分地);
116
117
  ⭐ 你填的**工作区里的 `AGENTS.md` 由插件读进来、内联进提示词** ⇒ **它按你的设定说话**,
117
118
  而不是用一套通用人格
118
- - ⭐ **那一轮拿不到整台工具**:只给它**四个只读的**(搜视频 / 读字幕 / 读评论 / 看视频信息),
119
+ - ⭐ **那一轮拿不到整台工具**:只给它**六个只读的**(搜视频 / 读字幕 / 读评论 / 看视频信息 / 看封面 / 看画面帧),
119
120
  开了「自动评论」或「回复评论」才多给一个**发评论** —— 上传视频、发动态、私信、抓全站那些**根本不注册**,
120
121
  模型连它们的名字都看不到
122
+ - 🔒 **那一轮没有文件系统、没有命令、没有联网**(2026-10-24 起):它跑在一个**专用 profile** `bili-duty` 上 ——
123
+ 工具只留 B站 MCP + 几只"窄口"的(写日报 / 记判重表 / 查判重表 / 写归档 / 转写),
124
+ **路径全部由插件定死,不接受路径参数**。这样即便哪天视频标题、评论、弹幕里塞了奇怪的东西,
125
+ 它也**读不到**你这台电脑上的别的东西 —— 以前那个 profile 是带文件系统**读**权限的
126
+ (DSH 的沙箱只限制写、不限制读,这是真事)。
127
+ 万一专用 profile 铺不上,会**明确退回普通 headless 并在日志里说一句**,不会静默降级。
128
+ - **看封面 / 看画面帧需要宿主支持图片**:宿主得提供 `attachments`(存图)和 `llm`(查模型能不能看图)。
129
+ 缺任何一个就**退化成文字说明**(返回里 `image_attached: false` + `image_skip_reason`),
130
+ 其余功能照常 —— 所以这两个服务不在插件的 `inject` 里。
121
131
  - ⭐ **值班章程(`prompts/duty.md`)升级的规矩**:插件记着「上次铺的是哪一版」(`charter.sha256`)——
122
132
  你**没改过**就跟着插件自动升级;**改过**就保留你那份、把新版另存成 `duty.md.new`(日志里会说一句),自己合一下。
123
133
  ⚠️ **从 0.1.5 及以前升上来的第一次没有账本可比** ⇒ 保守地当成「你改过」:新版落成 `duty.md.new`;
package/client.js CHANGED
@@ -687,6 +687,11 @@ function WatchTab() {
687
687
  placeholder: '想让它怎么说话就写在这儿 —— 比如「叫我某某,说话简短点,别用 emoji」。也可以直接填一个文件路径(如 D:\\deepseek\\AGENTS.md,或它所在的那个目录),每次运行都会重新读那个文件。留空 = 按中性说话(不扮演任何人)。',
688
688
  value: persona ?? '', onChange: (e) => setPersona(e.target.value),
689
689
  }),
690
+ // ⭐ 2.0.5(issue #89):把人设字数亮出来 —— 超 40000 只送前 40000、超 60000 保存就截,得让用户自己看得见
691
+ h('div', { 'data-bd-note': '' },
692
+ '字数:' + String(persona ?? '').length
693
+ + (String(persona ?? '').length > 40000 ? '(⚠️ 超过 40000,超出的部分不会生效)' : '')
694
+ + (String(persona ?? '').length > 60000 ? '(⚠️ 超过 60000,保存时会被截断)' : '')),
690
695
  h('div', { 'data-bd-note': '' },
691
696
  (st.personaFromWs && String(persona || '').trim() === '')
692
697
  ? '你现在还是用「工作区」里那份 AGENTS.md 说话的(照旧生效)。想把它搬到这儿改,就把上面的路径填成本机那个目录。'
package/index.js CHANGED
@@ -23,7 +23,7 @@ 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, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
26
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
27
27
  import { join, dirname } from 'node:path'
28
28
  import { createRequire } from 'node:module'
29
29
  import { fileURLToPath } from 'node:url'
@@ -36,6 +36,8 @@ import { loadCred as dmCred, listSessions as dmList, fetchMsgs as dmFetch } from
36
36
  import { ensureUv } from './lib/uvfetch.mjs'
37
37
 
38
38
  export const name = 'bili-scroll'
39
+ // attachments / llm 故意**不**列进 inject:它们只在看图时才用 ctx.get 取,
40
+ // 取不到就把图降级成文字说明;列进来会让没有这两个服务的宿主直接装不上。
39
41
  export const inject = ['webServer', 'tools', 'settings']
40
42
 
41
43
  /**
@@ -77,7 +79,8 @@ export const Config = !z ? undefined : z.object({
77
79
  /** dsh 的 bin.js(不填就试自动探测,再退化成 shell 里的 dsh) */
78
80
  dshBin: z.string().default('').extra('volatile', true),
79
81
  /** headless 那一轮用哪个 profile */
80
- headlessProfile: z.string().default('headless').extra('volatile', true),
82
+ // ⭐ 2.0.5:默认换成"最小权限 profile"(只有 B站 MCP + 窄口 IO,没有文件系统/命令/联网)
83
+ headlessProfile: z.string().default('bili-duty').extra('volatile', true),
81
84
  /** 给那一轮叠一份插件自己的 patch(把 B站 MCP 挂上)—— 用户 profile 一个字都不用改 */
82
85
  overlayMcp: z.boolean().default(true).extra('volatile', true),
83
86
  /** 跑 headless 时的工作目录 */
@@ -269,7 +272,15 @@ function reportMeta() {
269
272
  const p = { ...patch }
270
273
  if ('perDay' in p) p.perDay = Math.max(0, Math.min(Number(p.perDay) || 0, 50))
271
274
  if ('reportLevel' in p) p.reportLevel = ['full', 'brief', 'off'].includes(String(p.reportLevel)) ? String(p.reportLevel) : 'full'
272
- if ('personaPrompt' in p) p.personaPrompt = String(p.personaPrompt || '').slice(0, 8000)
275
+ // ⭐ 2.0.5(issue #89):人设上限 8000 → 60000;**截断时要出声**(以前静默砍,用户以为写进去了 ✗)
276
+ if ('personaPrompt' in p) {
277
+ const raw = String(p.personaPrompt || '')
278
+ const LIMIT = 60000
279
+ if (raw.length > LIMIT) {
280
+ log('⚠️ 人设提示词 ' + raw.length + ' 字符,超过保存上限 ' + LIMIT + ' ⇒ 只存前 ' + LIMIT + '(截掉 ' + (raw.length - LIMIT) + ' 字)')
281
+ p.personaPrompt = raw.slice(0, LIMIT)
282
+ } else p.personaPrompt = raw
283
+ }
273
284
  if ('dmWatch' in p) p.dmWatch = Boolean(p.dmWatch)
274
285
  if ('dmWatchMinutes' in p) p.dmWatchMinutes = Math.max(1, Math.min(120, Number(p.dmWatchMinutes) || 1))
275
286
  if ('dmReplyPerDay' in p) p.dmReplyPerDay = Math.max(0, Math.min(20, Number(p.dmReplyPerDay) || 0))
@@ -495,6 +506,12 @@ function reportMeta() {
495
506
  return client.call(toolName, args, opts)
496
507
  }
497
508
 
509
+ /** 同 call,但保留 MCP 返回里的图片块(封面/画面要用)—— 见 lib/mcp.mjs 的 callRaw */
510
+ const callRaw = async (toolName, args, opts) => {
511
+ const client = await ensureMcp()
512
+ return client.callRaw(toolName, args, opts)
513
+ }
514
+
498
515
  const hasCredential = () => existsSync(join(stateDir, 'bili_credential.json'))
499
516
 
500
517
  /* ─────────────── 工具 ─────────────── */
@@ -518,7 +535,7 @@ function reportMeta() {
518
535
 
519
536
  const tool = (spec) => ctx.tools.register(defineTool({
520
537
  ...spec,
521
- output: textOutput,
538
+ output: spec.output || textOutput,
522
539
  async execute(args, exec) { return safe(await spec.execute(args, exec)) },
523
540
  }))
524
541
 
@@ -573,6 +590,109 @@ function reportMeta() {
573
590
  },
574
591
  })
575
592
 
593
+ /* ─────────────── 看画面:把图交给模型 ───────────────
594
+ * 图片在工具结果里是附件引用 { type:'image', attachment },不是 image_url;
595
+ * 字节先存进 ctx.get('attachments'),由 render 拼进返回内容。
596
+ * 模型不支持图片输入时退化成文字(查 llm.resolveModelInfo 的 inputModalities)。
597
+ */
598
+ const imageOutput = {
599
+ schema: { type: 'object', properties: {}, additionalProperties: true },
600
+ render: (_args, value) => {
601
+ const src = value && typeof value === 'object' ? value : { value }
602
+ const { image, images, ...rest } = src
603
+ const parts = [{ type: 'text', text: JSON.stringify(rest, null, 2) }]
604
+ const refs = [...(image ? [image] : []), ...(Array.isArray(images) ? images : [])]
605
+ for (const ref of refs) parts.push({ type: 'image', attachment: ref })
606
+ return parts
607
+ },
608
+ }
609
+
610
+ /** 把 MCP 回来的图存成附件引用,返回 { ok, refs } 或 { ok:false, reason, hint } */
611
+ const attachImages = async (exec, images) => {
612
+ const attachments = ctx.get('attachments')
613
+ if (!attachments?.saveImages) {
614
+ return { ok: false, reason: 'attachments', hint: '宿主的图片服务不可用(ctx.get("attachments") 是空的)' }
615
+ }
616
+
617
+ const llm = ctx.get('llm')
618
+ const routed = exec?.agent?.session?.requestHeader?.()?.config
619
+ const provider = routed?.provider ?? exec?.agent?.options?.provider
620
+ const model = routed?.model ?? exec?.agent?.options?.model
621
+ if (!llm?.resolveModelInfo) {
622
+ return { ok: false, reason: 'model_probe', hint: `查不到模型能力(provider=${provider} model=${model})` }
623
+ }
624
+ let modalities
625
+ try {
626
+ modalities = (await llm.resolveModelInfo(provider, model, exec?.signal))?.inputModalities
627
+ } catch (e) {
628
+ return { ok: false, reason: 'model_probe', hint: `查模型能力失败(${provider}/${model}):${String(e?.message ?? e)}` }
629
+ }
630
+ if (!Array.isArray(modalities)) {
631
+ return { ok: false, reason: 'model_probe', hint: `模型 ${provider}/${model} 没声明输入模态` }
632
+ }
633
+ if (!modalities.includes('image')) {
634
+ return { ok: false, reason: 'model_no_image', hint: `模型 ${provider}/${model} 不支持图片输入(${modalities.join(',') || '无'})` }
635
+ }
636
+
637
+ try {
638
+ const refs = await attachments.saveImages(
639
+ images.map((i) => ({ data: Buffer.from(i.data, 'base64'), mediaType: i.mimeType })),
640
+ )
641
+ return { ok: true, refs }
642
+ } catch (e) {
643
+ return { ok: false, reason: 'save_failed', hint: '存图片失败:' + String(e?.message ?? e) }
644
+ }
645
+ }
646
+
647
+ tool({
648
+ name: 'bili_cover',
649
+ description: '看一个视频的封面(等比缩到 512px 宽)。最便宜的一档"看一眼":'
650
+ + '能看出风格、标题美术、大致题材,但看不出视频里具体发生了什么。',
651
+ parameters: { bvid: { type: 'string', description: 'BV号' } },
652
+ output: imageOutput,
653
+ async execute(args, exec) {
654
+ const { json, images } = await callRaw('bili_cover', { bvid: String(args.bvid ?? '') }, { signal: exec?.signal })
655
+ const meta = json && typeof json === 'object' ? json : { raw: json }
656
+ if (!meta.ok) return meta
657
+ const img = images[0]
658
+ if (!img) return { ...meta, ok: false, error: 'MCP 没返回图片' }
659
+
660
+ const a = await attachImages(exec, [img])
661
+ return a.ok
662
+ ? { ...meta, image_attached: true, image: a.refs[0] }
663
+ : { ...meta, image_attached: false, image_skip_reason: a.reason, hint: a.hint }
664
+ },
665
+ })
666
+
667
+ tool({
668
+ name: 'bili_frames',
669
+ description: '看视频里的画面(从 B 站自带的故事板裁 1-6 帧,每帧 480×270)。\n'
670
+ + '想知道"这条视频到底拍了什么"就用它 —— 字幕告诉你说什么,画面告诉你拍的是什么。\n'
671
+ + '默认按时长均匀取 3 帧;想盯某个时刻(比如字幕里提到的那段)就传 at。',
672
+ parameters: {
673
+ bvid: { type: 'string', description: 'BV号' },
674
+ num: { type: 'number', description: '取几帧,1-6,默认 3' },
675
+ at: { type: 'array', items: { type: 'number' }, description: '指定时间点(秒)列表;传了就以它为准,忽略 num' },
676
+ },
677
+ output: imageOutput,
678
+ async execute(args, exec) {
679
+ const at = Array.isArray(args.at) ? args.at.map(Number).filter(Number.isFinite) : undefined
680
+ const { json, images } = await callRaw('bili_frames', {
681
+ bvid: String(args.bvid ?? ''),
682
+ num: Math.max(1, Math.min(Number(args.num) || 3, 6)),
683
+ ...(at && at.length ? { at } : {}),
684
+ }, { signal: exec?.signal })
685
+ const meta = json && typeof json === 'object' ? json : { raw: json }
686
+ if (!meta.ok) return meta
687
+ if (!images.length) return { ...meta, ok: false, error: 'MCP 没返回图片' }
688
+
689
+ const a = await attachImages(exec, images)
690
+ return a.ok
691
+ ? { ...meta, image_attached: true, images: a.refs }
692
+ : { ...meta, image_attached: false, image_skip_reason: a.reason, hint: a.hint }
693
+ },
694
+ })
695
+
576
696
  tool({
577
697
  name: 'bili_reply',
578
698
  description: '在视频下发评论(或回复某条评论)。这就是"@ 本人"那一下:短、干净、纯文本 @ 写在正文里。\n'
@@ -630,9 +750,6 @@ function reportMeta() {
630
750
  dmMode: cfg.dmMode,
631
751
  dmUid: Number(cfg.dmUid) || 0,
632
752
  transcribe: cfg.transcribe,
633
- // ⭐ 2.0.4:认人改成"只认显式 UID / 登录账号自己"之后,**用小号登录、大号聊天**的人会静默失去私信回复 ✗
634
- // ⇒ 这里只给一条**提示**(不自动改!),界面上让他一键确认 —— 自动把"私信对象"当主人等于把安全修复撤销 ✗
635
- ownerInfo: ownerInfo(),
636
753
  loggedIn: hasCredential(),
637
754
  home: stateDir,
638
755
  report: join(outDir, `${dayKey(now)}.md`),
@@ -756,6 +873,20 @@ function reportMeta() {
756
873
  return st.day === dayKey(now) ? Number(st.count || 0) : 0
757
874
  }
758
875
 
876
+ // ⭐⭐ 2.0.5(2026-10-24 用户实证,群友「羽涅」):一键升级以前把 profile 写死成 web ✗
877
+ // 桌面版用户的 profile 是 desktop ⇒ 升级失败,反而把 0.1.7 装进 web 里,越修越乱 ✓
878
+ // 现在从"自己这个模块的真实路径"里取 profile 名:挂载点是 ~/.dsh/profiles/<名字>/node_modules/<包>/
879
+ const ownProfileName = (() => {
880
+ try {
881
+ const u = String(import.meta.url || '').replace(/\\/g, '/')
882
+ const m = u.match(/\/profiles\/([^/]+)\/node_modules\//)
883
+ return m ? m[1] : ''
884
+ } catch { return '' }
885
+ })()
886
+ const profileArg = ownProfileName || 'web'
887
+ // ⭐ 顺带:把"我是从哪个 profile 加载的"报出来,排障时一眼看得出
888
+ try { log('插件加载自 profile:' + profileArg + (ownProfileName ? '' : '(没识别到,按默认 web 处理)')) } catch { /* 忽略 */ }
889
+
759
890
  // ⭐ 一键升级:由宿主自己跑 `dsh plugin --profile web add dsh-bili-scroll`
760
891
  // ⚠️ 装完必须重启 dsh 才生效;⚠️ Windows 上"替换正在运行的插件"可能被文件占用挡住(我们踩过)
761
892
  let upgrading = false
@@ -794,17 +925,17 @@ function reportMeta() {
794
925
  // ⚠️ 2026-10-19:找不到 dsh 时不再硬跑,直接把要做的事说清楚
795
926
  if (dsh.missing) {
796
927
  appendInstall('=== 没法自动升级:找不到 dsh。' + dsh.hint + ' ===\n')
797
- appendInstall('=== 也可以自己手动跑一次:dsh plugin --profile web add dsh-bili-scroll ===\n')
928
+ appendInstall('=== 也可以自己手动跑一次:dsh plugin --profile ' + profileArg + ' add dsh-bili-scroll ===\n')
798
929
  upgrading = false
799
930
  return
800
931
  }
801
932
  let cmd = dsh.command || 'dsh'
802
- let args = [...(dsh.args || []), 'plugin', '--profile', 'web', 'add', spec]
933
+ let args = [...(dsh.args || []), 'plugin', '--profile', profileArg, 'add', spec]
803
934
  if (dsh.shell) { args = ['/c', cmd, ...args]; cmd = 'cmd.exe' }
804
935
  appendInstall('$ ' + cmd + ' ' + args.join(' ') + '\n')
805
936
  const code = await spawnTo(cmd, args, { sink: appendInstall })
806
937
  if (code === 0) appendInstall('=== 升级完成 —— 重启一次 dsh 才生效 ===\n')
807
- else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile web add dsh-bili-scroll ===\n')
938
+ else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile ' + profileArg + ' add dsh-bili-scroll ===\n')
808
939
  } catch (e) { appendInstall('升级崩了:' + (e?.stack ?? e) + '\n') } finally { upgrading = false }
809
940
  })()
810
941
  return { ok: true, started: true, log: installLogFile, hint: '正在升级 —— 完成后重启一次 dsh 才生效' }
@@ -993,16 +1124,28 @@ function reportMeta() {
993
1124
  }
994
1125
  }
995
1126
  if (persona) {
1127
+ // ⭐ 2.0.5(issue #89):6000 → 40000;超了要写一行日志,别让用户以为"规则生效了" ✗
1128
+ const PERSONA_LIMIT = 40000
1129
+ const personaText = (() => {
1130
+ const raw = String(persona)
1131
+ if (raw.length > PERSONA_LIMIT) {
1132
+ log('⚠️ 人设提示词 ' + raw.length + ' 字符,超过上限 ' + PERSONA_LIMIT + ' ⇒ 只送前 ' + PERSONA_LIMIT + '(截掉 ' + (raw.length - PERSONA_LIMIT) + ' 字)')
1133
+ return raw.slice(0, PERSONA_LIMIT)
1134
+ }
1135
+ return raw
1136
+ })()
996
1137
  ownerDoc = '\n\n===== 使用者的人设' + (personaFromFile ? '(读自 ' + personaFromFile + ')' : '提示词') + '(**按这个来**)=====\n'
997
- + persona.slice(0, 6000)
1138
+ + personaText
998
1139
  + '\n===== 人设结束 =====\n'
999
1140
  }
1000
1141
  const wsDir = String(cfg.workspace || '').trim()
1001
1142
  const agents = (!persona && wsDir) ? join(wsDir, 'AGENTS.md') : ''
1002
1143
  if (agents && existsSync(agents)) {
1003
1144
  const text = readFileSync(agents, 'utf8')
1145
+ const AG_LIMIT = 40000
1146
+ if (text.length > AG_LIMIT) log('⚠️ 工作区的 AGENTS.md ' + text.length + ' 字符,超过上限 ' + AG_LIMIT + ' ⇒ 只送前 ' + AG_LIMIT + '(截掉 ' + (text.length - AG_LIMIT) + ' 字)')
1004
1147
  ownerDoc = '\n\n===== 使用者的 AGENTS.md(他写给自己 AI 的人设与准则,**按这个来**)=====\n'
1005
- + text.slice(0, 6000)
1148
+ + text.slice(0, AG_LIMIT)
1006
1149
  + '\n===== 设定结束 =====\n'
1007
1150
  }
1008
1151
  } catch { /* 读不到就算了,别因此跑不成 */ }
@@ -1099,7 +1242,12 @@ function reportMeta() {
1099
1242
  * 要读全就手工改这个列表(或者直接跑 mcp_server.py,不设这个变量 = 全放)。
1100
1243
  */
1101
1244
  const mcpWhitelist = () => {
1102
- const list = ['bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info']
1245
+ const list = ['bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info',
1246
+ 'bili_cover', 'bili_frames', 'bili_danmaku'] // 看封面 / 画面帧 / 弹幕:只读,不需要额外的闸
1247
+ // ⭐⭐ 2.0.5(issue #96 安全加固):值班轮改成最小权限 profile(没有 fs / shell / web)⇒
1248
+ // 写日报 / 判重表 / 归档、以及无字幕转写,全走这几只**固定路径的窄口工具** ✓
1249
+ list.push('duty_paths', 'duty_report_append', 'duty_seen_append', 'duty_digest_append', 'duty_seen_recent')
1250
+ if (cfg.asrInstalled !== false) list.push('bili_transcribe')
1103
1251
  // ⭐⭐ 2.0.4(第三方体检第 3 条):**"只发私信"时评论通道必须硬关** —— 以前只看 autoComment,
1104
1252
  // 用户以前开过自动评论、后来切到"只私信",那个 true 还在 config 里 ⇒ 评论工具照样下发 ✗
1105
1253
  const dmOnly = String(cfg.dmMode || '').trim() === 'only'
@@ -1111,6 +1259,34 @@ function reportMeta() {
1111
1259
  return list
1112
1260
  }
1113
1261
 
1262
+
1263
+ // ⭐⭐ 2.0.5(issue #96 / PR #97 思路):把「值班轮专用 profile」铺到 <DSH_HOME>/profiles/bili-duty/
1264
+ // · 组成 = dsh-base + dsh-headless + 我们那层 cordis.patch.yml(关掉 fs / shell / web / subagent / skill…)
1265
+ // · 铺不上就明确退回 headless,并在日志里说清"这一轮不是最小权限"(失败要出声)
1266
+ let dutyProfileState = 'unknown' // ok / missing / failed
1267
+ const dutyProfileDir = join(dirname(stateDir), 'profiles', 'bili-duty')
1268
+ const ensureDutyProfile = () => {
1269
+ try {
1270
+ const src = join(pluginDir, 'profile-template', 'bili-duty')
1271
+ if (!existsSync(src)) { dutyProfileState = 'missing'; return false }
1272
+ mkdirSync(dirname(dutyProfileDir), { recursive: true })
1273
+ // 每次启动都覆盖(模板很小;这是安全边界,用户改它也会被覆盖)
1274
+ cpSync(src, dutyProfileDir, { recursive: true, force: true })
1275
+ dutyProfileState = existsSync(join(dutyProfileDir, 'cordis.patch.yml')) ? 'ok' : 'failed'
1276
+ return dutyProfileState === 'ok'
1277
+ } catch (e) {
1278
+ dutyProfileState = 'failed'
1279
+ log('⚠️ 最小权限 profile 没铺成功(' + String(e?.message ?? e) + ')⇒ 这一轮会退回 headless')
1280
+ return false
1281
+ }
1282
+ }
1283
+ // 真正用的 profile:用户手填了别的就尊重;还是老默认 has headless ⇒ 用最小权限那个
1284
+ const effectiveProfile = () => {
1285
+ const want = String(cfg.headlessProfile || '').trim()
1286
+ if (want && want !== 'headless' && want !== 'bili-duty') return want
1287
+ return dutyProfileState === 'ok' ? 'bili-duty' : (want || 'headless')
1288
+ }
1289
+
1114
1290
  const ensureOverlay = () => {
1115
1291
  const file = join(stateDir, 'headless-overlay.yml')
1116
1292
  const script = join(stateDir, 'mcp_server.py')
@@ -1249,7 +1425,7 @@ function reportMeta() {
1249
1425
  return { started: false, why, error: msg }
1250
1426
  }
1251
1427
  const overlay = ensureOverlay()
1252
- const args = [...dsh.args, '--profile', cfg.headlessProfile || 'headless']
1428
+ const args = [...dsh.args, '--profile', effectiveProfile()]
1253
1429
  if (overlay) args.push('--patch', overlay)
1254
1430
  args.push(promptFor())
1255
1431
  // ⭐ 这一轮的工作目录 = 使用者(或别的用户)的**工作区** —— 只有这样它的 `AGENTS.md` 才会自动加载。
@@ -1404,6 +1580,46 @@ function reportMeta() {
1404
1580
  } catch { return null }
1405
1581
  }
1406
1582
 
1583
+ // ⭐⭐ 2.0.5(有人反馈"私信没有上下文" —— 属实):以前每条私信都是一次全新会话 ⇒ 它永远"初次见面" ✗
1584
+ // 现在固定**续同一个会话**:headless 支持 `--session-id <id>`,历史由 DSH 自己带着、超上限它自己压缩 ✓
1585
+ // 第一次跑完把开出来的会话 id 记到 dm-session.json;之后每次都带上;会话失效就丢掉重开(不会卡死)✓
1586
+ const dmSessionFile = join(stateDir, 'dm-session.json')
1587
+ const readDmSession = () => {
1588
+ try {
1589
+ const j = JSON.parse(readFileSync(dmSessionFile, 'utf8'))
1590
+ return j && typeof j.id === 'string' && j.id ? j.id : ''
1591
+ } catch { return '' }
1592
+ }
1593
+ const writeDmSession = (id) => { try { writeFileSync(dmSessionFile, JSON.stringify({ id, at: Date.now() }, null, 2), 'utf8') } catch { /* 写不了下次再认 */ } }
1594
+ // 跑完之后,从 $DSH_HOME/sessions/*/<id>/ 里找"这次新出现的那个"(mtime 晚于启动时刻)
1595
+ const discoverDmSession = (sinceTs) => {
1596
+ try {
1597
+ const root = join(process.env.DSH_HOME || join(process.env.USERPROFILE || '', '.dsh'), 'sessions')
1598
+ if (!existsSync(root)) return ''
1599
+ // ⚠️ 只认"这一轮工作目录 = 插件家"那个 workspace(否则会误认到自己聊天那条会话 ✗)
1600
+ // 命名规则:--<盘符去掉冒号>-<路径用横线拼>--(如 --C-Users-99496-.dsh-bili-duty--)
1601
+ const wantWs = '--' + stateDir.replace(/:/g, '').replace(/[\\/]/g, '-') + '--'
1602
+ let best = ''
1603
+ let bestAt = 0
1604
+ const scanWs = [wantWs].filter((w) => existsSync(join(root, w)))
1605
+ const list = scanWs.length ? scanWs : readdirSync(root)
1606
+ for (const ws of list) {
1607
+ const wsDir = join(root, ws)
1608
+ let dirs = []
1609
+ try { dirs = readdirSync(wsDir) } catch { continue }
1610
+ for (const d of dirs) {
1611
+ const full = join(wsDir, d)
1612
+ try {
1613
+ const st = statSync(full)
1614
+ if (!st.isDirectory()) continue
1615
+ if (st.mtimeMs >= sinceTs && st.mtimeMs > bestAt) { bestAt = st.mtimeMs; best = d }
1616
+ } catch { /* 跳过 */ }
1617
+ }
1618
+ }
1619
+ return best
1620
+ } catch { return '' }
1621
+ }
1622
+
1407
1623
  function dmPromptFor(fromUid, text) {
1408
1624
  const raw = String(text || '').slice(0, 600)
1409
1625
  const bv = (raw.match(/BV[0-9A-Za-z]{10}/) || [])[0] || ''
@@ -1431,10 +1647,14 @@ function reportMeta() {
1431
1647
  const dsh = resolveDsh()
1432
1648
  if (dsh.missing) { log('私信回复没跑:找不到 dsh(' + dsh.hint + ')'); return { ok: false } }
1433
1649
  const overlay = ensureOverlay()
1434
- const args = [...dsh.args, '--profile', cfg.headlessProfile || 'headless']
1435
- if (overlay) args.push('--patch', overlay)
1436
- args.push(dmPromptFor(fromUid, text))
1437
- log(`私信回复开跑:来自 uid=${fromUid}`)
1650
+ const base = [...dsh.args, '--profile', effectiveProfile()]
1651
+ if (overlay) base.push('--patch', overlay)
1652
+ if (dmReplyRunning) { log('私信回复:上一条还在跑 ⇒ 这条等下一跳'); return { ok: false, busy: true } }
1653
+ dmReplyRunning = true
1654
+ const savedSid = readDmSession()
1655
+ const args = savedSid ? [...base, '--session-id', savedSid, dmPromptFor(fromUid, text)] : [...base, dmPromptFor(fromUid, text)]
1656
+ log(`私信回复开跑:来自 uid=${fromUid}` + (savedSid ? '(续会话 ' + savedSid.slice(0, 20) + '…)' : '(开新会话)'))
1657
+ const startedAt = Date.now()
1438
1658
  const child = spawn(dsh.command, args, {
1439
1659
  cwd: stateDir, shell: dsh.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
1440
1660
  env: uvEnv({ BILI_DUTY_HOME: stateDir, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) }),
@@ -1443,13 +1663,31 @@ function reportMeta() {
1443
1663
  const pick = (buf) => { try { appendFileSync(logFile, toText(buf), 'utf8') } catch { /* 丢日志也不能抛 */ } }
1444
1664
  child.stdout?.on('data', pick)
1445
1665
  child.stderr?.on('data', pick)
1446
- child.on('exit', (code) => { log(`私信回复结束(code=${code})`); rotateLogIfBig() })
1666
+ child.on('exit', (code) => {
1667
+ dmReplyRunning = false
1668
+ log(`私信回复结束(code=${code})`)
1669
+ rotateLogIfBig()
1670
+ // ① 本来没有会话 id:跑完把它开出来的那个认领下来,下一条就能接着聊
1671
+ if (!savedSid && code === 0) {
1672
+ const id = discoverDmSession(startedAt)
1673
+ if (id) { writeDmSession(id); log('私信会话已记录:' + id.slice(0, 24) + '…(以后私信都接着这个会话)') }
1674
+ else log('⚠️ 没找到这次的会话 id ⇒ 下一条私信还是新会话(不影响回复,只是没上下文)')
1675
+ }
1676
+ // ② 带了 id 却失败(会话被删/迁移过)⇒ 丢掉它,下一条重开,别卡死在坏 id 上
1677
+ if (savedSid && code !== 0) {
1678
+ writeDmSession('')
1679
+ log('⚠️ 续会话失败(' + savedSid.slice(0, 16) + '…)⇒ 清掉 id,立刻用新会话重试一次(这条不能没回)')
1680
+ setTimeout(() => { try { runDmReply(fromUid, text) } catch { /* 兜住 */ } }, 800)
1681
+ }
1682
+ })
1447
1683
  return { ok: true }
1448
1684
  } catch (e) { log('私信回复出错:' + String(e?.message ?? e)); return { ok: false } }
1449
1685
  }
1450
1686
 
1451
1687
  let dmTimer = null
1452
1688
  let dmBusy = false
1689
+ // ⭐ 2.0.5:一条私信回复没跑完时,不要让下一条进来(不然两个进程抢同一个会话,DSH 会报 "already owned by an active write handle")
1690
+ let dmReplyRunning = false
1453
1691
  async function dmTick() {
1454
1692
  if (dmBusy) return
1455
1693
  dmBusy = true
@@ -2087,6 +2325,8 @@ function reportMeta() {
2087
2325
  pickedToday: countPickedToday(), // 今天挑到几条(从报告里数)
2088
2326
  lastRunAt: st.lastRunAt || null,
2089
2327
  lastWhy: st.lastWhy || null,
2328
+ // ⭐ 2.0.4:界面上"它会认谁当主人"那行用这个(用小号登录、大号聊天的会看错)
2329
+ ownerInfo: ownerInfo(),
2090
2330
  loggedIn: hasCredential(),
2091
2331
  venvReady: venvReadyNow(),
2092
2332
  version: myVersion(),
@@ -2403,7 +2643,7 @@ function reportMeta() {
2403
2643
  '版本:' + myVersion(),
2404
2644
  '平台:' + process.platform + ' ' + process.arch + ' · node ' + process.versions.node + (process.versions.electron ? ' · electron ' + process.versions.electron : ''),
2405
2645
  'dsh:' + (() => { try { const d = resolveDsh(); return d.missing ? '没找到(' + d.hint + ')' : d.command + ' ' + d.args.join(' ') } catch (e) { return '探测失败:' + e?.message } })(),
2406
- 'headless profile:' + (cfg.headlessProfile || 'headless'),
2646
+ 'headless profile:' + effectiveProfile() + '(最小权限:' + dutyProfileState + ')',
2407
2647
  'pythonPath:' + cfg.pythonPath,
2408
2648
  'venv 就绪:' + (venvReadyNow() ? '是' : '否') + (missingVenvDeps().length ? '(缺:' + missingVenvDeps().join(' / ') + ')' : ''),
2409
2649
  '转写环境:' + (existsSync(join(stateDir, '.venv-asr')) ? '在' : '没装'),
@@ -2516,37 +2756,101 @@ function reportMeta() {
2516
2756
  return sendJson(res, 404, { ok: false, error: `unknown ${req.method} ${path}` })
2517
2757
  }
2518
2758
 
2519
- try {
2520
2759
  const sameOriginOk = (req) => {
2521
2760
  try {
2522
2761
  const host = String(req.headers.host || '')
2523
2762
  if (!host) return true // 没有 Host 头 = 不是浏览器/不是外来的 HTTP 请求
2524
2763
  if (!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host)) return false
2525
2764
  const pick = String(req.headers.origin || req.headers.referer || '')
2526
- if (!pick) return true // 不是浏览器发的(本机脚本/宿主内部)—— 本机进程本来就有权限
2765
+ if (!pick) return true // 不是浏览器发的(本机脚本/宿主内部)
2527
2766
  try {
2528
2767
  const u = new URL(pick)
2529
- return /^(127\.0\.0\.1|localhost|\[::1\])$/i.test(u.hostname)
2768
+ // ⭐ 2.0.5(采纳 PR #94):**端口也要比** —— 只比 hostname 的话,
2769
+ // 本机上另一个端口的页面(dev server / Jupyter / 有 XSS 的本地页)也能驱动这些写接口 ✗
2770
+ const originAuthority = String(u.host || '').toLowerCase()
2771
+ const hostAuthority = String(host).toLowerCase()
2772
+ if (originAuthority && originAuthority === hostAuthority) return true
2773
+ const defaultPort = u.protocol === 'https:' ? '443' : '80'
2774
+ const withPort = (u.hostname + ':' + (u.port || defaultPort)).toLowerCase()
2775
+ return withPort === hostAuthority
2530
2776
  } catch { return false }
2531
2777
  } catch { return false }
2532
2778
  }
2779
+
2780
+ // ⭐⭐ 2.0.5(issue #88):**走 DSH 自己的信任栅栏** —— 隧道把 Host 改写成回环时,
2781
+ // 我们那层"本机同源"会被绕过;connection.requestRejection 才是真正的鉴权(认 cookie/token)✓
2782
+ // 取法照 DSH 内部写法:Reflect.get(ctx, "connection") ⇒ requestRejection(req)
2783
+ // 返回 undefined = 放行;返回数字 = 回这个状态码(通常 401)
2784
+ // ⚠️ 栅栏不可用时不静默放行:仍要求过"本机同源",两道都不成 ⇒ 拒 ✓
2785
+ let fenceWarned = false
2786
+ // ⭐ 取 connection 的三种写法都试(实测:Reflect.get 在插件 ctx 上拿不到 ⇒ 2026-10-23 真机验证后发现)
2787
+ let nodeConnCached = null
2788
+ let nodeConnTried = false
2789
+ const getConnection = () => {
2790
+ if (nodeConnTried) return nodeConnCached
2791
+ nodeConnTried = true
2792
+ const tries = [
2793
+ ['ctx.get("connection")', () => (typeof ctx.get === 'function' ? ctx.get('connection') : null)],
2794
+ ['Reflect.get(ctx, "connection")', () => Reflect.get(ctx, 'connection')],
2795
+ ['ctx.root.connection', () => (ctx.root && typeof ctx.root === 'object' ? ctx.root.connection : null)],
2796
+ ]
2797
+ for (const [how, fn] of tries) {
2798
+ try {
2799
+ const c = fn()
2800
+ if (c && typeof c.requestRejection === 'function') {
2801
+ nodeConnCached = c
2802
+ log('信任栅栏:用 ' + how + ' 拿到了 connection 服务 ✓')
2803
+ return c
2804
+ }
2805
+ } catch { /* 换下一个 */ }
2806
+ }
2807
+ log('⚠️ 三种写法都拿不到 connection.requestRejection ⇒ 退回"本机同源"这一道')
2808
+ return null
2809
+ }
2810
+ const fenceReject = (req, res) => {
2811
+ const conn = getConnection()
2812
+ if (!conn) {
2813
+ if (!fenceWarned) { fenceWarned = true; log("⚠️ 拿不到 DSH 信任栅栏(connection.requestRejection)⇒ 退回本机同源这一道") }
2814
+ return false
2815
+ }
2816
+ try {
2817
+ // ⭐ 2.0.5(采纳 PR #94):契约入参是 `{ headers }`(ConnectionTrustRequest),不是原始 IncomingMessage;
2818
+ // 两种形状都给一遍,谁的实现都能接住(实测传原始 req 也能过,但按契约来更稳)
2819
+ const rejection = conn.requestRejection({ headers: req.headers })
2820
+ if (rejection === undefined) return false
2821
+ res.statusCode = typeof rejection === "number" ? rejection : 403
2822
+ try { res.end() } catch { /* 忽略 */ }
2823
+ return true
2824
+ } catch (err) {
2825
+ log("信任栅栏调用出错,按不通过处理:" + String(err?.message ?? err))
2826
+ res.statusCode = 403
2827
+ try { res.end() } catch { /* 忽略 */ }
2828
+ return true
2829
+ }
2830
+ }
2831
+
2832
+ try {
2533
2833
  ctx.effect(() => ctx.webServer.register({
2534
2834
  kind: 'prefix',
2535
2835
  path: '/api/bili-duty',
2536
- // ⭐ 2026-10-23 安全加固(审计点名"未鉴权的控制接口"):**只接受本机同源请求** ——
2537
- // 别的网页在你浏览器里也能打到 127.0.0.1,所以要比对 Host 与本机、Origin/Referer 与同源 ✓
2538
2836
  handler: async (req, res) => {
2539
- if (!sameOriginOk(req)) {
2540
- try { sendJson(res, 403, { ok: false, error: '只接受本机同源请求' }) } catch { /* 忽略 */ }
2541
- return
2542
- }
2543
- try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (e) {
2544
- sendJson(res, 500, { ok: false, error: e?.message ?? String(e) })
2837
+ // ① 先过 DSH 的信任栅栏(真鉴权)
2838
+ if (fenceReject(req, res)) return
2839
+ // ② 再过本机同源(栅栏拿不到时的兜底,也挡浏览器里别的网页)
2840
+ if (!sameOriginOk(req)) {
2841
+ try { sendJson(res, 403, { ok: false, error: '只接受本机同源请求' }) } catch { /* 忽略 */ }
2842
+ return
2843
+ }
2844
+ try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (err) {
2845
+ sendJson(res, 500, { ok: false, error: err?.message ?? String(err) })
2545
2846
  }
2546
2847
  },
2547
2848
  }), 'bili-duty: /api/bili-duty 路由')
2548
2849
  } catch (e) { log(`HTTP 路由没挂上:${e?.message ?? e}`) }
2549
2850
 
2550
2851
  ctx.effect(() => () => { try { mcp?.stop() } catch { /* 收摊 */ } }, 'bili-duty: 收 MCP 子进程')
2852
+ try { ensureDutyProfile() } catch { /* 上面已记日志 */ }
2853
+ if (dutyProfileState === 'ok') log('值班轮用最小权限 profile:bili-duty(无文件系统 / 无命令 / 无联网)')
2854
+ else log('⚠️ 最小权限 profile 不可用(' + dutyProfileState + ')⇒ 值班轮仍用 headless(这一轮能力偏大)')
2551
2855
  log(`已挂载(家=${stateDir},python=${cfg.pythonPath},峰谷=${cfg.peakValley ? '开' : '关'})`)
2552
2856
  }