dsh-deepmoyu 2.7.0 → 2.7.2
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/CHANGELOG.md +25 -0
- package/SECURITY.md +4 -0
- package/client.js +89 -13
- package/docs/RELEASE_AUDIT.md +2 -2
- package/docs/RELEASING.md +15 -0
- package/docs/tool-evidence.json +1 -1
- package/index.js +296 -62
- package/lib/asr.py +22 -0
- package/lib/favops.py +38 -6
- package/lib/host-compat.mjs +123 -0
- package/lib/learn-review.mjs +11 -1
- package/lib/login.mjs +20 -3
- package/lib/mirror.mjs +15 -1
- package/lib/model-relay.mjs +6 -2
- package/lib/persona.mjs +3 -1
- package/lib/pet-session.mjs +9 -3
- package/lib/pv-session.mjs +16 -1
- package/lib/pv.mjs +18 -3
- package/lib/pv.py +142 -18
- package/lib/pv_cover.py +11 -1
- package/lib/pv_editorial.py +23 -2
- package/lib/pv_plan.py +20 -2
- package/lib/pv_verify.py +14 -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 +6 -1
- package/vendor/bilibili-mcp/mcp_server.py +116 -32
- package/vendor/bilibili-mcp/runtime_guard.py +32 -6
package/lib/asr.py
CHANGED
|
@@ -18,6 +18,7 @@ import argparse
|
|
|
18
18
|
import os
|
|
19
19
|
import re
|
|
20
20
|
import sys
|
|
21
|
+
import time
|
|
21
22
|
import uuid
|
|
22
23
|
|
|
23
24
|
# ⚠️ 临时目录必须待在家目录里:值班那一轮跑在沙箱里(只允许写工作区/cwd),
|
|
@@ -56,6 +57,25 @@ def cleanup_download(tmp: str, tag: str) -> None:
|
|
|
56
57
|
pass
|
|
57
58
|
|
|
58
59
|
|
|
60
|
+
def sweep_stale_downloads(tmp: str = _TMP, max_age_hours: float = 24.0) -> None:
|
|
61
|
+
"""P3-33: 异常中断(进程被杀/断电)会留下 tag.* 碎片,且只有成功路径会清 ——
|
|
62
|
+
启动时把 tmp/asr 下超过 24h 的下载碎片一并回收(当前这次的文件是新的,不受影响)。"""
|
|
63
|
+
cutoff = time.time() - max_age_hours * 3600
|
|
64
|
+
try:
|
|
65
|
+
with os.scandir(tmp) as entries:
|
|
66
|
+
for entry in entries:
|
|
67
|
+
try:
|
|
68
|
+
# 只回收普通文件与失效链接,目录一律不碰;删不掉就留给下一次。
|
|
69
|
+
if not (entry.is_file(follow_symlinks=False) or entry.is_symlink()):
|
|
70
|
+
continue
|
|
71
|
+
if entry.stat(follow_symlinks=False).st_mtime < cutoff:
|
|
72
|
+
os.remove(entry.path)
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
except OSError:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
59
79
|
def to_url(target: str) -> str:
|
|
60
80
|
t = target.strip()
|
|
61
81
|
if t.upper().startswith('BV') and len(t) <= 20:
|
|
@@ -99,6 +119,8 @@ def main() -> int:
|
|
|
99
119
|
ap.add_argument('--lang', default='zh', help='语言(默认 zh;不给就自动判断请传 auto)')
|
|
100
120
|
args = ap.parse_args()
|
|
101
121
|
prepare_runtime()
|
|
122
|
+
# P3-33: 每次启动顺手清扫超龄碎片(只在真的要下载时才需要目录,清扫本身静默失败无妨)
|
|
123
|
+
sweep_stale_downloads()
|
|
102
124
|
|
|
103
125
|
src = args.target
|
|
104
126
|
# ⚠️ 不用 mkdtemp:沙箱只允许写"子进程启动前就存在"的目录,新建的会被拒 ✗
|
package/lib/favops.py
CHANGED
|
@@ -11,6 +11,14 @@ import os
|
|
|
11
11
|
import re
|
|
12
12
|
import sys
|
|
13
13
|
|
|
14
|
+
# P3-31: Windows 管道默认 GBK —— 错误 JSON 里的中文/emoji 会把 print 打挂
|
|
15
|
+
#(UnicodeEncodeError 连 JSON 都出不来),先钉 UTF-8 + replace(写法照 lib/pv.py:41-46)
|
|
16
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
17
|
+
try:
|
|
18
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
|
|
14
22
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
15
23
|
|
|
16
24
|
# 凭证找两处:优先**当前工作目录**(宿主 spawn 时 cwd = 插件家,凭证就在那儿),
|
|
@@ -23,10 +31,32 @@ CRED_FILE = next((p for p in CRED_CANDIDATES if os.path.exists(p)), CRED_CANDIDA
|
|
|
23
31
|
|
|
24
32
|
|
|
25
33
|
def out(obj):
|
|
34
|
+
# P3-31: stdout 已钉 utf-8 + errors="replace",任何字符都出得去,不再 UnicodeEncodeError
|
|
26
35
|
print(json.dumps(obj, ensure_ascii=False))
|
|
27
36
|
sys.exit(0)
|
|
28
37
|
|
|
29
38
|
|
|
39
|
+
def _classify(error):
|
|
40
|
+
"""P3-32: 错误分类 —— code 取 bilibili_api 异常自带的 API code(ResponseCodeException.code),
|
|
41
|
+
kind 让宿主分得清「要重扫码」(auth = -101/-111)、「等风控」(risk = 412/-352)、
|
|
42
|
+
「已经收藏过」(dup,幂等成功)和其余失败(unknown)。"""
|
|
43
|
+
code = getattr(error, 'code', None)
|
|
44
|
+
try:
|
|
45
|
+
code = int(code) if code is not None else None
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
code = None
|
|
48
|
+
msg = str(error)
|
|
49
|
+
if code in (-101, -111):
|
|
50
|
+
kind = 'auth'
|
|
51
|
+
elif code in (412, -352):
|
|
52
|
+
kind = 'risk'
|
|
53
|
+
elif '已经' in msg or 'already' in msg.lower():
|
|
54
|
+
kind = 'dup'
|
|
55
|
+
else:
|
|
56
|
+
kind = 'unknown'
|
|
57
|
+
return code, kind, msg
|
|
58
|
+
|
|
59
|
+
|
|
30
60
|
def load():
|
|
31
61
|
from bilibili_api import Credential
|
|
32
62
|
if not os.path.exists(CRED_FILE):
|
|
@@ -65,11 +95,11 @@ async def add(bvid, fid):
|
|
|
65
95
|
try:
|
|
66
96
|
await favorite_list.FavoriteListResource(aid, type_=2, credential=cred).add(fid)
|
|
67
97
|
except Exception as error: # noqa: BLE001
|
|
68
|
-
msg =
|
|
69
|
-
#
|
|
70
|
-
if
|
|
71
|
-
out({'ok': True, 'already': True})
|
|
72
|
-
out({'ok': False, 'error': msg[:200]})
|
|
98
|
+
code, kind, msg = _classify(error)
|
|
99
|
+
# 已经收藏过不算失败:幂等判断保留子串兜底,但优先按 code 分好的类走
|
|
100
|
+
if kind == 'dup':
|
|
101
|
+
out({'ok': True, 'already': True, 'code': code, 'kind': kind})
|
|
102
|
+
out({'ok': False, 'error': msg[:200], 'code': code, 'kind': kind})
|
|
73
103
|
out({'ok': True})
|
|
74
104
|
|
|
75
105
|
|
|
@@ -87,7 +117,9 @@ def main():
|
|
|
87
117
|
else:
|
|
88
118
|
out({'ok': False, 'error': '子命令不认识:' + ' '.join(args)})
|
|
89
119
|
except Exception as error: # noqa: BLE001
|
|
90
|
-
|
|
120
|
+
# P3-32: 兜底出口同样带 code/kind,宿主不用再猜错误文本
|
|
121
|
+
code, kind, msg = _classify(error)
|
|
122
|
+
out({'ok': False, 'error': msg[:300], 'code': code, 'kind': kind})
|
|
91
123
|
|
|
92
124
|
|
|
93
125
|
if __name__ == '__main__':
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/host-compat.mjs —— 启动时的「宿主兼容性自检」
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* 🔴 为什么要有它(2026-11-10 真机事故):
|
|
5
|
+
* DSH 升到 0.12.1 之后,选「鲸鱼摸鱼模式」整个起不来,本轮直接
|
|
6
|
+
* `unknown prompt variable "{{cwd}}" in section "deployment:persona-suffix";
|
|
7
|
+
* registered variables: provider, model`
|
|
8
|
+
* 根因是 `presets/deepmoyu.patch.yml` 里 persona 的 `suffix` 写着
|
|
9
|
+
* `Your working directory is {{cwd}}.`:
|
|
10
|
+
* · `dsh-persona` 把 prefix/suffix 原样注册成提示词段(不做任何转义);
|
|
11
|
+
* · `dsh-system-prompt` 组装时会把段文本里所有 `{{名字}}` 当变量查表,
|
|
12
|
+
* **查不到就 throw**,而且这个 throw 发生在宿主每轮组装提示词的时候,
|
|
13
|
+
* 不是加载插件的时候 ⇒ 插件看着是好的,一开就炸。
|
|
14
|
+
*
|
|
15
|
+
* ⭐ 本模块只做一件很窄的事:**把这类错提前说清楚**。
|
|
16
|
+
* 插件打包时就把自己那两个 patch 文件里出现的 `{{...}}` 扫一遍,
|
|
17
|
+
* 只要出现白名单(宿主真正会注册的变量)以外的名字,就在 duty.log 里
|
|
18
|
+
* 写一条能照着改的告警 —— 而不是等使用者选了模式、看到一句英文堆栈。
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ 刻意**不用 YAML 解析器**:`yaml` 是 devDependency,不进 npm 包;
|
|
21
|
+
* 这里做的是纯文本正则扫描(patch 文件是我们自己写的,形状稳定)。
|
|
22
|
+
* 扫描只产生日志,永远不抛错、不阻断加载 —— 自检本身不能变成新的风险。
|
|
23
|
+
* ============================================================================
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
26
|
+
import { join } from 'node:path'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 宿主真正注册过的提示词变量。
|
|
30
|
+
* DSH 0.12.1 里由 `@deepseek-ai/dsh-agent-loop` 注册 `provider` / `model` 两个,
|
|
31
|
+
* 被扫过一遍整个 asar,官方没有任何别处用 `{{cwd}}` 之类的写法。
|
|
32
|
+
* 宿主再升版时**先改这里,再改 `tests/prompt-variables.test.mjs` 里的同名白名单**。
|
|
33
|
+
*/
|
|
34
|
+
export const HOST_PROMPT_VARIABLES = Object.freeze(['provider', 'model'])
|
|
35
|
+
|
|
36
|
+
/** 插件自己声明配置的那几个 patch 文件(相对插件根) */
|
|
37
|
+
export const PATCH_FILES = Object.freeze(['cordis.patch.yml', 'presets/deepmoyu.patch.yml'])
|
|
38
|
+
|
|
39
|
+
/** `{{名字}}` —— 与宿主 `GROUP_AT` 的"简单组"语义一致(中间不许再出现花括号) */
|
|
40
|
+
const VARIABLE = /\{\{([^{}]*)\}\}/g
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 扫一个文本里出现的提示词变量名。
|
|
44
|
+
* @param {string} text 文件正文
|
|
45
|
+
* @returns {string[]} 出现过的变量名(去重,保持出现顺序)
|
|
46
|
+
*/
|
|
47
|
+
export function promptVariablesIn(text) {
|
|
48
|
+
const seen = []
|
|
49
|
+
for (const match of String(text ?? '').matchAll(VARIABLE)) {
|
|
50
|
+
const name = match[1]
|
|
51
|
+
if (!seen.includes(name)) seen.push(name)
|
|
52
|
+
}
|
|
53
|
+
return seen
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 去掉 YAML 注释行(`#` 开头)。
|
|
58
|
+
*
|
|
59
|
+
* 为什么需要:注释里为了讲清楚规矩,会写「别在这里写任何 `{{变量}}`」这种话,
|
|
60
|
+
* 那本身就是一段带花括号的注释;不剔掉就会自己告警自己。
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ 取舍:这里不认 YAML 的块标量(`|` / `>` 里的 `#` 是正文,不是注释)。
|
|
63
|
+
* 人格正文是中文,几乎不会以 `#` 开头;真出现了也只是**少报**,不会误报停服务 ——
|
|
64
|
+
* 而真正把关的是 `tests/prompt-variables.test.mjs` 里那一条:它用 `parsePatch()`
|
|
65
|
+
* 把 YAML 解析成对象后只扫**值**,注释根本进不去。运行时自检是"多说一句"的辅助。
|
|
66
|
+
* @param {string} text
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
export function stripYamlComments(text) {
|
|
70
|
+
return String(text ?? '').split(/\r?\n/).filter(line => !/^\s*#/.test(line)).join('\n')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 扫插件目录下的 patch 文件,返回所有不在白名单里的变量。
|
|
75
|
+
* @param {string} pluginDir 插件根目录
|
|
76
|
+
* @param {readonly string[]} [allowed] 白名单(默认 {@link HOST_PROMPT_VARIABLES})
|
|
77
|
+
* @returns {Array<{file: string, name: string}>} 空数组 = 没问题
|
|
78
|
+
*/
|
|
79
|
+
export function unknownPromptVariables(pluginDir, allowed = HOST_PROMPT_VARIABLES) {
|
|
80
|
+
const allow = new Set(allowed)
|
|
81
|
+
const found = []
|
|
82
|
+
for (const rel of PATCH_FILES) {
|
|
83
|
+
const file = join(pluginDir, rel)
|
|
84
|
+
// 文件没了不是兼容性问题(打包形状变了),交给别的测试去管
|
|
85
|
+
if (!existsSync(file)) continue
|
|
86
|
+
let text = ''
|
|
87
|
+
try { text = readFileSync(file, 'utf8') } catch { continue }
|
|
88
|
+
for (const name of promptVariablesIn(stripYamlComments(text))) {
|
|
89
|
+
if (!allow.has(name)) found.push({ file: rel, name })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return found
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 给 duty.log 用的人话告警(没问题就返回空数组)。
|
|
97
|
+
* @param {string} pluginDir 插件根目录
|
|
98
|
+
* @returns {string[]}
|
|
99
|
+
*/
|
|
100
|
+
export function promptVariableWarnings(pluginDir) {
|
|
101
|
+
const found = unknownPromptVariables(pluginDir)
|
|
102
|
+
if (found.length === 0) return []
|
|
103
|
+
const allowed = HOST_PROMPT_VARIABLES.join(' / ')
|
|
104
|
+
return found.map(({ file, name }) => `⚠️ ${file} 里写了 {{${name}}},但这个宿主只注册 ${allowed} 两个提示词变量`
|
|
105
|
+
+ ' —— 宿主组装提示词时会直接抛 unknown prompt variable,整套模式就起不来。'
|
|
106
|
+
+ '工作目录由宿主自己以 runtime context 注入,段里不用再写;别的动态信息请写成纯文字。')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 宿主版本(拿得到就拿来写进日志,方便下次排障)。
|
|
111
|
+
* 桌面版在 `resources/app.asar` 里,从模块位置往上找不到就返回 ''。
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function hostVersion() {
|
|
115
|
+
const candidates = [
|
|
116
|
+
process.env.DSH_HOST_VERSION,
|
|
117
|
+
process.env.DSH_VERSION,
|
|
118
|
+
]
|
|
119
|
+
for (const value of candidates) {
|
|
120
|
+
if (value && String(value).trim()) return String(value).trim()
|
|
121
|
+
}
|
|
122
|
+
return ''
|
|
123
|
+
}
|
package/lib/learn-review.mjs
CHANGED
|
@@ -40,6 +40,11 @@ export function buildLearningReviewPrompt(items, { tokenBudget = 12000 } = {}) {
|
|
|
40
40
|
export function learningReviewRetryAllowed(previous = {}, { batchKey, model = '', now = Date.now() } = {}) {
|
|
41
41
|
const last = Number(previous.lastAttemptAt) || 0
|
|
42
42
|
if (now - last < 180_000) return false
|
|
43
|
+
/* 审计 P3-11(2026-11-12):人工停止(stopReason='user')以前与错误同样进退避——
|
|
44
|
+
* 180 秒后 automationTick 又把同一批拉起来,"点了停止"实际停不住。现在同一批
|
|
45
|
+
* (batchKey+model 都没变)被人工停过就不再自动重启;队列内容变化(batchKey 变)
|
|
46
|
+
* 或换了模型才算新任务。手动 startLearningReview('manual') 不走这里,不受影响。 */
|
|
47
|
+
if (previous.userStop && previous.userStop.batchKey === batchKey && String(previous.userStop.model || '') === model) return false
|
|
43
48
|
if (previous.batchKey !== batchKey || String(previous.model || '') !== model) return true
|
|
44
49
|
return now >= (Number(previous.nextRetryAt) || last + 180_000)
|
|
45
50
|
}
|
|
@@ -55,7 +60,12 @@ export function summarizeLearningReview(items, ledger, { code, error = '', stopR
|
|
|
55
60
|
const failures = complete ? 0 : (same ? Number(previous.failures) || 0 : 0) + 1
|
|
56
61
|
const delay = Math.min(30 * 60_000, 180_000 * 2 ** Math.min(4, Math.max(0, failures - 1)))
|
|
57
62
|
const lastError = redactRecordText(error || (stopReason ? `审核已停止:${stopReason}` : code !== 0 ? `审核进程退出码 ${code}` : remaining ? `进程已退出,但本批仍有 ${remaining} 条未审核;已保留待审,稍后重试` : ''), { maxLength: 800 })
|
|
58
|
-
|
|
63
|
+
// 审计 P3-11:人工停止记下本批指纹(batchKey+model),同批不再被 automationTick 自动重拉;
|
|
64
|
+
// 非人工停止时若本批没变,沿用之前的 userStop 标记(一次人工停止对同一批持续有效)。
|
|
65
|
+
const userStop = stopReason === 'user'
|
|
66
|
+
? { batchKey, model }
|
|
67
|
+
: (previous.userStop && previous.userStop.batchKey === batchKey ? previous.userStop : undefined)
|
|
68
|
+
return { approved, rejected, remaining, complete, failures, nextRetryAt: complete ? 0 : now + delay, lastError, userStop }
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
export function decideLearningReview(ledger, batch, args = {}) {
|
package/lib/login.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* ============================================================================
|
|
16
16
|
*/
|
|
17
17
|
import { spawn } from 'node:child_process'
|
|
18
|
-
import { existsSync, mkdirSync } from 'node:fs'
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
import { atomicWriteFileSync } from './record-storage.mjs'
|
|
21
21
|
import { randomUUID } from 'node:crypto'
|
|
@@ -93,14 +93,31 @@ export function saveCredential(stateDir, credential) {
|
|
|
93
93
|
|
|
94
94
|
/** 用 Python 的 qrcode 画一张 PNG,返回**文件路径**(拿不到就返回 null)
|
|
95
95
|
* ⭐ 2026-10-26:加 20 秒超时 —— python 挂住时("既不起来也不退出")这个 Promise 以前永远不 settle,
|
|
96
|
-
* `/login/start` 那个 HTTP 请求就永远挂着(probeCmd 那批同型问题修过,这里漏了)。
|
|
96
|
+
* `/login/start` 那个 HTTP 请求就永远挂着(probeCmd 那批同型问题修过,这里漏了)。
|
|
97
|
+
* 审计 P3-13(2026-11-12):失败/超时以前不清理半成品 PNG,成功路径也没人删——
|
|
98
|
+
* 每次登录尝试都在家目录留一个 qrcode_login.*.png(key 一次性且 180s 过期,无安全风险,纯卫生)。
|
|
99
|
+
* 现在失败即删;每次出码顺手清掉 24h 前的旧文件。 */
|
|
97
100
|
export function renderQrPng(pythonPath, stateDir, url, { timeoutMs = 20_000 } = {}) {
|
|
98
101
|
return new Promise((resolve) => {
|
|
99
102
|
const png = join(stateDir, `qrcode_login.${process.pid}.${randomUUID()}.png`)
|
|
103
|
+
try {
|
|
104
|
+
const cutoff = Date.now() - 24 * 3600_000
|
|
105
|
+
for (const entry of readdirSync(stateDir)) {
|
|
106
|
+
if (!/^qrcode_login\..+\.png$/.test(entry)) continue
|
|
107
|
+
const old = join(stateDir, entry)
|
|
108
|
+
try { if (statSync(old).mtimeMs < cutoff) rmSync(old, { force: true }) } catch { /* 占用中就留给下次 */ }
|
|
109
|
+
}
|
|
110
|
+
} catch { /* 扫不了目录就算了 */ }
|
|
100
111
|
const code = 'import sys, qrcode; qrcode.make(sys.argv[1]).save(sys.argv[2])'
|
|
101
112
|
let child
|
|
102
113
|
let settled = false
|
|
103
|
-
const finish = (v) => {
|
|
114
|
+
const finish = (v) => {
|
|
115
|
+
if (settled) return
|
|
116
|
+
settled = true
|
|
117
|
+
clearTimeout(timer)
|
|
118
|
+
if (!v) { try { rmSync(png, { force: true }) } catch { /* 半成品也留给 24h 清扫 */ } }
|
|
119
|
+
resolve(v)
|
|
120
|
+
}
|
|
104
121
|
const timer = setTimeout(() => {
|
|
105
122
|
try { child?.kill('SIGKILL') } catch { /* 已经没了 */ }
|
|
106
123
|
finish(null)
|
package/lib/mirror.mjs
CHANGED
|
@@ -44,8 +44,22 @@ export function resolveMirrorDir(stateDir, explicit = '') {
|
|
|
44
44
|
return ''
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** 审计 P3-9(2026-11-12):state.json 镜像里 `sendReview[].args.text`(bili_dm_reply)
|
|
48
|
+
* 是待审私信的明文正文——活动流水对私信整行剔除,镜像却原样带走,两处口径不一致。
|
|
49
|
+
* 镜像定位是"脱敏的只读副本",这里对齐:DM 待审正文以占位符替换,其余字段照常。 */
|
|
50
|
+
function stripPendingDmText(value) {
|
|
51
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
|
52
|
+
const rows = Array.isArray(value.sendReview) ? value.sendReview : null
|
|
53
|
+
if (!rows) return value
|
|
54
|
+
return { ...value, sendReview: rows.map((row) => {
|
|
55
|
+
if (!row || typeof row !== 'object' || row.tool !== 'bili_dm_reply') return row
|
|
56
|
+
if (!row.args || typeof row.args !== 'object' || typeof row.args.text !== 'string') return row
|
|
57
|
+
return { ...row, args: { ...row.args, text: '[私信正文已隐去——待审私信不进镜像]' } }
|
|
58
|
+
}) }
|
|
59
|
+
}
|
|
60
|
+
|
|
47
61
|
function mirrorText(fromDir, name, source) {
|
|
48
|
-
if (name.endsWith('.json')) return JSON.stringify(sanitizeRecord(JSON.parse(source), { maxKeys: 10000, maxLength: 16000, maxDepth: 10 }), null, 2) + '\n'
|
|
62
|
+
if (name.endsWith('.json')) return JSON.stringify(sanitizeRecord(stripPendingDmText(JSON.parse(source)), { maxKeys: 10000, maxLength: 16000, maxDepth: 10 }), null, 2) + '\n'
|
|
49
63
|
if (name === 'activity.jsonl') {
|
|
50
64
|
const tail = readActivityTail(fromDir, { maxBytes: MIRROR_MAX_BYTES, limit: 5000 })
|
|
51
65
|
if (tail.truncated) throw new Error('MIRROR_ACTIVITY_NEEDS_ROTATION')
|
package/lib/model-relay.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Local, per-run capability bridge to the parent's already configured LLM adapters.
|
|
2
2
|
* No credentials/profile copies, no route fallback, no public web endpoint. */
|
|
3
3
|
import { createServer } from 'node:http'
|
|
4
|
-
import { randomBytes
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
5
|
import { once } from 'node:events'
|
|
6
6
|
import { redactRecordText } from './record-privacy.mjs'
|
|
7
7
|
import { summarizeBackgroundDiagnostic } from './background-output.mjs'
|
|
@@ -56,8 +56,12 @@ export function createModelRelay({ getLlm, onError } = {}) {
|
|
|
56
56
|
// No browser origins, CORS or ambient cookie auth. Each child gets a revocable capability.
|
|
57
57
|
if (req.method !== 'POST' || req.url !== '/call' || req.headers.origin) return jsonReply(res, 403, { error: { code: 'RELAY_FORBIDDEN', message: '模型桥接拒绝此请求' } })
|
|
58
58
|
const token = String(req.headers.authorization || '').replace(/^Bearer /, '')
|
|
59
|
+
/* 审计 P3-25(2026-11-12):`grants.get(token)` 本身就是按 token 精确匹配——命中后
|
|
60
|
+
* 再 timingSafeEqual 比较的两个 buffer 恒等,这段比较从不起作用(错误长度的 token
|
|
61
|
+
* 走 `!grant` → 403 短路)。Map 键比较在此处是时间安全的(V8 字符串哈希桶查找,
|
|
62
|
+
* 键只在本进程内生成且不可枚举),直接删除冗余比较并留注说明。 */
|
|
59
63
|
const grant = grants.get(token)
|
|
60
|
-
if (!grant ||
|
|
64
|
+
if (!grant || Date.now() >= grant.expiresAt) return jsonReply(res, 403, { error: { code: 'RELAY_EXPIRED', message: '后台模型凭据已撤销或过期,请重新开跑' } })
|
|
61
65
|
const abort = new AbortController()
|
|
62
66
|
grant.controllers.add(abort)
|
|
63
67
|
const disconnected = () => { if (!res.writableEnded) abort.abort() }
|
package/lib/persona.mjs
CHANGED
|
@@ -170,7 +170,9 @@ export function ensureSoul(stateDir, log = () => {}) {
|
|
|
170
170
|
const file = soulFileOf(stateDir)
|
|
171
171
|
try {
|
|
172
172
|
if (existsSync(file)) return false
|
|
173
|
-
|
|
173
|
+
// 审计 P3-18(2026-11-12):初始铺模板也走原子写——裸 write 半篇留档会被 existsSync
|
|
174
|
+
// 永久当成"用户写过",残缺模板就再也修不回来了。
|
|
175
|
+
atomicWriteFileSync(file, soulTemplate(), 'utf8')
|
|
174
176
|
log(`已铺好:${file}`)
|
|
175
177
|
return true
|
|
176
178
|
} catch (e) {
|
package/lib/pet-session.mjs
CHANGED
|
@@ -62,10 +62,16 @@ function inheritPermissions(ctx, source, target) {
|
|
|
62
62
|
if (source.sandbox && source.sandbox !== before.sandbox) target.append('sandbox/mode', { mode: source.sandbox })
|
|
63
63
|
if (source.approval && source.approval !== before.approval) target.append('approval/policy', { policy: source.approval })
|
|
64
64
|
const after = permissionsOf(ctx, target)
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
// 审计 P3-22(2026-11-12):读回为 undefined(宿主缺 sessionProjections 等服务)时,
|
|
66
|
+
// 以前整个校验被跳过却返回"继承成功"——append 发出了但未经读回验证。现在把这种
|
|
67
|
+
// 情况显式标成 unverified,由调用方(HTTP 回执/日志)如实呈现。
|
|
68
|
+
let unverified = false
|
|
69
|
+
for (const key of ['sandbox', 'approval']) {
|
|
70
|
+
if (!source[key]) continue
|
|
71
|
+
if (after[key] === undefined) { unverified = true; continue }
|
|
72
|
+
if (after[key] !== source[key]) throw new Error(`会话已创建,但 ${key} 权限继承未生效;请手动检查该会话权限`)
|
|
67
73
|
}
|
|
68
|
-
return true
|
|
74
|
+
return unverified ? 'unverified' : true
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
/**
|
package/lib/pv-session.mjs
CHANGED
|
@@ -118,6 +118,13 @@ export function createVideoStudio({ ctx, pv, stateDir, config, publicSources = (
|
|
|
118
118
|
const current = pv.status(), row = sessionRow(sessionId) || (!sessionId && rows.find(item => item.productionId && item.productionId === current.id))
|
|
119
119
|
if (sessionId && !row) return { ok: true, stopped: false, hint: '没有找到这个制作会话' }
|
|
120
120
|
if (!row) return pv.stop()
|
|
121
|
+
/* 审计 P2-8(2026-11-12):终态不可逆——已交付回执(done + terminalDelivered)的会话
|
|
122
|
+
* 再收到 stop 时,以前会被无条件改写成 stopped,会话侧的「目标终态」丢失(磁盘成片
|
|
123
|
+
* 不受影响,但 status 从此显示"已手动停止")。现在对已交付终态的行直接原样返回。 */
|
|
124
|
+
if (row.stage === 'done' || row.terminalDelivered) {
|
|
125
|
+
return { ok: true, stopped: false, sessionId: row.sessionId, stage: row.stage,
|
|
126
|
+
hint: '该会话已交付终态(成片已完成且回执已送达),没有可停止的任务。' }
|
|
127
|
+
}
|
|
121
128
|
const before = row.stage
|
|
122
129
|
row.stage = 'stopped'; row.error = ''; remember(row) // Fence completion callbacks before cancellation.
|
|
123
130
|
const result = row.productionId && row.productionId === current.id ? pv.stop() : { ok: true, stopped: false }
|
|
@@ -132,7 +139,13 @@ export function createVideoStudio({ ctx, pv, stateDir, config, publicSources = (
|
|
|
132
139
|
const settled = async result => {
|
|
133
140
|
if (disposed || !result.id) return
|
|
134
141
|
const row = rows.find(item => item.productionId === result.id)
|
|
135
|
-
if (!row
|
|
142
|
+
if (!row) {
|
|
143
|
+
// 审计 P3-19(2026-11-12):行索引只留 60 条,被裁剪行的渲染完成事件以前无声丢失——
|
|
144
|
+
// 至少留一条可发现的记录(AGENTS:终态回传原会话一次;回传不了也要说清为什么)。
|
|
145
|
+
log(`⚠️ 渲染 ${result.id}(${result.date || '?'})完成,但对应的制作会话已被裁剪出列表——终态无法回传原会话,成片与回执见 productions 列表`)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (row.stage === 'stopped' || row.terminalDelivered === result.id || delivering.has(result.id)) return
|
|
136
149
|
delivering.add(result.id)
|
|
137
150
|
try {
|
|
138
151
|
const verified = result.ok === true && result.stage === 'done' && !result.verificationSkipped
|
|
@@ -214,6 +227,8 @@ export function createVideoStudio({ ctx, pv, stateDir, config, publicSources = (
|
|
|
214
227
|
} finally { creating = false }
|
|
215
228
|
}
|
|
216
229
|
const prepare = (args = {}, sessionId = '') => {
|
|
230
|
+
// 审计 P3-24:preview/render 都有 disposed 闸,prepare 漏了——卸载竞态窗口内仍可返回素材快照。
|
|
231
|
+
if (disposed) return { ok: false, error: '视频制作服务已关闭' }
|
|
217
232
|
const saved = sessionRow(sessionId)
|
|
218
233
|
const request = saved || videoRequest(args)
|
|
219
234
|
if (saved && args.date && args.date !== saved.date) throw new Error('日期与当前制作会话不一致')
|
package/lib/pv.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
4
|
import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
5
|
-
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
6
6
|
import { normalizeProduction } from './pv-production.mjs'
|
|
7
7
|
|
|
8
8
|
export const PV_PREFIX = '大肥鱼日报'
|
|
@@ -72,12 +72,23 @@ const usableVideo = (file) => {
|
|
|
72
72
|
/** Keep the historical date-indexed list compatible; studio jobs have productions(). */
|
|
73
73
|
export function listPv(outDir, legacyDirs = outDir) {
|
|
74
74
|
const byDate = new Map()
|
|
75
|
+
const verificationOk = (date, dir) => {
|
|
76
|
+
// 审计 P3-20(2026-11-12):legacy 列表以前只看「文件存在且 >1KB」——手工放进 out 的
|
|
77
|
+
// 任意 mp4 也会以成片身份进列表/播放端点(studio 的 productions() 严格要求校验回执)。
|
|
78
|
+
// 现在给每行带上 verified 标记:旁边有 ok:true 的 verification.json 才算验证过,
|
|
79
|
+
// 消费端(bili_pv list / 设置页)可以区分「校验过」与「只是个视频文件」。
|
|
80
|
+
try {
|
|
81
|
+
const receipt = JSON.parse(readFileSync(join(dateDir(dir, date), 'verification.json'), 'utf8'))
|
|
82
|
+
return receipt?.ok === true
|
|
83
|
+
} catch { return false }
|
|
84
|
+
}
|
|
75
85
|
const add = (date, file, canonical) => {
|
|
76
86
|
if (!pvDateOk(date) || !usableVideo(file)) return
|
|
77
87
|
const old = byDate.get(date)
|
|
78
88
|
if (!old || (!old.canonical && canonical)) {
|
|
79
89
|
const s = statSync(file)
|
|
80
|
-
byDate.set(date, { date, file, size: s.size, at: s.mtime.toISOString(), canonical
|
|
90
|
+
byDate.set(date, { date, file, size: s.size, at: s.mtime.toISOString(), canonical,
|
|
91
|
+
verified: verificationOk(date, canonical ? outDir : dirname(file)) })
|
|
81
92
|
}
|
|
82
93
|
}
|
|
83
94
|
try {
|
|
@@ -170,7 +181,11 @@ const productionRow = (outDir, id) => {
|
|
|
170
181
|
export function createPv(o) {
|
|
171
182
|
const { stateDir, pluginDir, python, dirs, env = null } = o
|
|
172
183
|
const say = (s) => { try { o.log?.(s) } catch { /* host logger */ } }
|
|
173
|
-
|
|
184
|
+
/* 审计 P2-12(2026-11-12):默认任务总时限 20 分钟 < py 侧 GSV 批处理自己的 60 分钟预算
|
|
185
|
+
* (pv.py:776 timeout=3600,且 pv_gsv.py 强制 CPU 推理,模型加载就要 30-50s)——长批处理
|
|
186
|
+
* 到点被 JS 强杀整树,py 的预算形同虚设。默认提到 75 分钟(> py 60 分钟 + 模型加载余量),
|
|
187
|
+
* py 侧超时先到、报错更有指向性;显式传 timeoutMs 的调用方不受影响。 */
|
|
188
|
+
const timeoutMs = Number(o.timeoutMs) > 0 ? Number(o.timeoutMs) : 75 * 60_000
|
|
174
189
|
const gsvOf = () => { try { return o.gsv?.() || null } catch { return null } }
|
|
175
190
|
let job = null
|
|
176
191
|
const settledListeners = new Set()
|