dsh-deepmoyu 2.7.2 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -0
- package/CHANGELOG.md +36 -0
- package/CONTRIBUTING.md +1 -0
- package/README.md +12 -3
- package/client.js +430 -293
- package/docs/ACTIVITY_TUNING.md +50 -0
- package/docs/CONTINUOUS_BROWSING.md +35 -0
- package/docs/DEFAULTS_2026_11_13.md +48 -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_2_8_1.md +29 -0
- package/docs/RELEASE_AUDIT.md +22 -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 +693 -241
- 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/learn-review.mjs +2 -2
- package/lib/learn-store.mjs +7 -0
- package/lib/learn.mjs +234 -69
- package/lib/mcp.mjs +5 -2
- package/lib/memory-center.mjs +21 -19
- package/lib/mode.mjs +23 -9
- package/lib/model-relay-adapter.mjs +1 -1
- package/lib/model-relay.mjs +15 -2
- package/lib/pace.mjs +15 -7
- package/lib/persona.mjs +44 -29
- package/lib/product-status.mjs +38 -2
- package/lib/prompts.mjs +54 -39
- package/lib/pv.py +26 -12
- package/lib/pv_editorial.py +5 -2
- package/lib/run-settings.mjs +5 -5
- package/lib/session-continuation.mjs +47 -0
- package/package.json +1 -1
- package/presets/deepmoyu.patch.yml +10 -11
- package/prompts/duty.md +35 -37
- package/vendor/bilibili-mcp/mcp_server.py +88 -15
- package/vendor/bilibili-mcp/runtime_guard.py +132 -25
package/lib/background-tools.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Adapt the host fish tools for a per-run MCP capability, not a general tool executor. */
|
|
2
2
|
export function backgroundToolNames(cfg = {}) {
|
|
3
|
-
const names = ['bili_whoami', 'bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info', 'bili_danmaku', 'bili_buzzwords', 'bili_rank', 'bili_hot_videos', 'bili_user_videos', 'bili_seen', 'bili_say', 'bili_pace', 'bili_my_notices', 'bili_my_dynamics', 'bili_opus_comments', 'bili_my_video_comments', 'bili_learn', 'bili_soul', 'bili_taste', 'bili_records', 'bili_sticker']
|
|
3
|
+
const names = ['bili_whoami', 'bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info', 'bili_danmaku', 'bili_buzzwords', 'bili_rank', 'bili_hot_videos', 'bili_user_videos', 'bili_seen', 'bili_say', 'bili_pace', 'bili_my_notices', 'bili_my_dynamics', 'bili_opus_comments', 'bili_my_video_comments', 'bili_learn', 'bili_soul', 'bili_taste', 'bili_records', 'bili_sticker', 'bili_curiosity']
|
|
4
4
|
if (cfg.autoComment || cfg.replyToComments) names.push('bili_reply', 'bili_reply_opus')
|
|
5
5
|
// 「自动评论视频」开着才给发新评论的通道;回复另算(replyToComments)
|
|
6
6
|
if (cfg.autoComment) names.push('bili_comment')
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** Explicit viewing preferences. Topics guide discovery; exclusions never grant tool permissions. */
|
|
2
|
+
export const BROWSE_TOPICS = Object.freeze(['科技', '知识', '生活', '美食', '游戏', '动画', '音乐', '影视', '动物', '运动', '旅行'])
|
|
3
|
+
export const BROWSE_DEFAULTS = Object.freeze({
|
|
4
|
+
browseTopics: Object.freeze([]), browseAvoidKeywords: Object.freeze([]), browseExplorePercent: 25,
|
|
5
|
+
browseMinMinutes: 0, browseMaxMinutes: 0, browseRecentDays: 7, browseSort: 'click', browseMinViews: 50_000,
|
|
6
|
+
})
|
|
7
|
+
export const BROWSE_LIMITS = Object.freeze({
|
|
8
|
+
browseExplorePercent: [0, 100], browseMinMinutes: [0, 1440], browseMaxMinutes: [0, 1440], browseRecentDays: [0, 365], browseMinViews: [0, 2_000_000_000],
|
|
9
|
+
})
|
|
10
|
+
const SORTS = ['mixed', 'pubdate', 'click', 'totalrank']
|
|
11
|
+
const own = (object, key) => Object.hasOwn(object || {}, key)
|
|
12
|
+
const phrases = value => Array.isArray(value) ? [...new Set(value.filter(item => typeof item === 'string').map(item => item.trim()).filter(Boolean))].slice(0, 30).map(item => item.slice(0, 80)) : []
|
|
13
|
+
const invalid = message => Object.assign(new Error(message), { status: 400, code: 'CONFIG_INPUT' })
|
|
14
|
+
|
|
15
|
+
/** Tolerate old persisted values on read; explicit writes go through validateBrowsePatch first. */
|
|
16
|
+
export function clampBrowseSettings(patch = {}) {
|
|
17
|
+
const out = { ...patch }
|
|
18
|
+
if (own(out, 'browseTopics')) out.browseTopics = phrases(out.browseTopics).filter(topic => BROWSE_TOPICS.includes(topic))
|
|
19
|
+
if (own(out, 'browseAvoidKeywords')) out.browseAvoidKeywords = phrases(out.browseAvoidKeywords)
|
|
20
|
+
if (own(out, 'browseSort')) out.browseSort = SORTS.includes(out.browseSort) ? out.browseSort : BROWSE_DEFAULTS.browseSort
|
|
21
|
+
for (const [key, [lo, hi]] of Object.entries(BROWSE_LIMITS)) if (own(out, key)) {
|
|
22
|
+
const n = Number(out[key])
|
|
23
|
+
out[key] = Number.isFinite(n) ? Math.max(lo, Math.min(hi, Math.floor(n))) : BROWSE_DEFAULTS[key]
|
|
24
|
+
}
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Validate against the current durable config inside its write lock as well as at the API boundary. */
|
|
29
|
+
export function validateBrowsePatch(patch = {}, current = {}) {
|
|
30
|
+
for (const key of ['browseTopics', 'browseAvoidKeywords']) if (own(patch, key)) {
|
|
31
|
+
if (!Array.isArray(patch[key]) || patch[key].length > 30 || patch[key].some(item => typeof item !== 'string' || !item.trim() || item.length > 80 || /[\r\n\x00-\x1f]/.test(item))) {
|
|
32
|
+
throw invalid(key + ' 需要最多 30 个非空短词,每个不超过 80 字')
|
|
33
|
+
}
|
|
34
|
+
if (key === 'browseTopics' && patch[key].some(topic => !BROWSE_TOPICS.includes(topic.trim()))) throw invalid('视频类别包含未知选项')
|
|
35
|
+
}
|
|
36
|
+
if (own(patch, 'browseSort') && !SORTS.includes(patch.browseSort)) throw invalid('视频排序需要选择综合探索、最新、播放量或平台综合')
|
|
37
|
+
for (const [key, [lo, hi]] of Object.entries(BROWSE_LIMITS)) if (own(patch, key)) {
|
|
38
|
+
if (typeof patch[key] !== 'number' || !Number.isSafeInteger(patch[key]) || patch[key] < lo || patch[key] > hi) throw invalid(`${key} 需要 ${lo} 至 ${hi} 之间的整数`)
|
|
39
|
+
}
|
|
40
|
+
if (own(patch, 'browseMinMinutes') || own(patch, 'browseMaxMinutes')) {
|
|
41
|
+
const cfg = { ...BROWSE_DEFAULTS, ...clampBrowseSettings(current), ...patch }
|
|
42
|
+
if (cfg.browseMaxMinutes > 0 && cfg.browseMinMinutes > cfg.browseMaxMinutes) throw invalid('视频最短时长不能大于最长时长;最长填 0 表示不限')
|
|
43
|
+
}
|
|
44
|
+
return patch
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function browseSettings(source = {}) {
|
|
48
|
+
const cfg = { ...BROWSE_DEFAULTS, ...clampBrowseSettings(source) }
|
|
49
|
+
// Historical hand-edited config can have reversed bounds; do not silently exclude every candidate.
|
|
50
|
+
if (cfg.browseMaxMinutes > 0 && cfg.browseMinMinutes > cfg.browseMaxMinutes) cfg.browseMaxMinutes = cfg.browseMinMinutes
|
|
51
|
+
return Object.fromEntries(Object.keys(BROWSE_DEFAULTS).map(key => [key, Array.isArray(cfg[key]) ? [...cfg[key]] : cfg[key]]))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A fresh snapshot has no shared arrays with a later settings edit. */
|
|
55
|
+
export function snapshotBrowseSettings(source = {}) {
|
|
56
|
+
const snapshot = { ...browseSettings(source), keywords: phrases(source.keywords) }
|
|
57
|
+
Object.freeze(snapshot.browseTopics); Object.freeze(snapshot.browseAvoidKeywords); Object.freeze(snapshot.keywords)
|
|
58
|
+
return Object.freeze(snapshot)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function browsePreferenceBrief(source = {}) {
|
|
62
|
+
const cfg = browseSettings(source), keywords = phrases(source.keywords)
|
|
63
|
+
return [
|
|
64
|
+
`【刷片偏好】类别:${cfg.browseTopics.join('、') || '随兴趣'};喜欢的词:${keywords.join('、') || '随兴趣'}。`,
|
|
65
|
+
`发布时间:${cfg.browseRecentDays ? `最近 ${cfg.browseRecentDays} 天` : '不限,旧片也可以'};时长:${cfg.browseMinMinutes ? `至少 ${cfg.browseMinMinutes} 分钟` : '无下限'},${cfg.browseMaxMinutes ? `最多 ${cfg.browseMaxMinutes} 分钟` : '无上限'}。`,
|
|
66
|
+
`排序:${({ mixed: '兼顾兴趣与新鲜感', pubdate: '最新优先', click: '播放量优先', totalrank: '保持平台综合顺序' })[cfg.browseSort]};约 ${cfg.browseExplorePercent}% 的探索机会留给平时少看的内容,这是倾向,不是逐条打卡配额。`,
|
|
67
|
+
`热度:${cfg.browseMinViews ? `播放量至少 ${cfg.browseMinViews.toLocaleString('en-US')};优先热门榜与高播放的新候选` : '不设最低播放量'}。播放量未知先用 bili_video_info 核对,不能把未知当热门;低于门槛就换片,不为凑候选降低门槛。`,
|
|
68
|
+
`避开关键词:${cfg.browseAvoidKeywords.join('、') || '未设置'}。类别和喜欢词用来挑方向,不要求条条命中;日期、时长或热度缺失先读视频详情核对,不把未知当不合格。候选不足先换来源或关键词,不擅自放宽本人设定。`,
|
|
69
|
+
].join('\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function videoDurationSeconds(value) {
|
|
73
|
+
if (typeof value === 'number') return Number.isFinite(value) && value > 0 ? value : null
|
|
74
|
+
if (typeof value !== 'string' || !value.trim()) return null
|
|
75
|
+
const text = value.trim()
|
|
76
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) return Number(text) > 0 ? Number(text) : null
|
|
77
|
+
if (!/^\d+:\d{2}(?::\d{2})?$/.test(text)) return null
|
|
78
|
+
const parts = text.split(':').map(Number)
|
|
79
|
+
if (parts.slice(1).some(part => part >= 60)) return null
|
|
80
|
+
const seconds = parts.reduce((total, part) => total * 60 + part, 0)
|
|
81
|
+
return seconds > 0 ? seconds : null
|
|
82
|
+
}
|
|
83
|
+
const publication = row => {
|
|
84
|
+
const value = Number(row?.pubdate ?? row?.created)
|
|
85
|
+
if (Number.isFinite(value) && value > 0) return value < 100_000_000_000 ? value * 1000 : value
|
|
86
|
+
if (typeof row?.pubdate_str === 'string' && /^\d{4}-\d{2}-\d{2}/.test(row.pubdate_str)) {
|
|
87
|
+
const date = Date.parse(row.pubdate_str)
|
|
88
|
+
if (Number.isFinite(date)) return date
|
|
89
|
+
}
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
const topicAliases = {
|
|
93
|
+
科技: ['科技', '数码', '计算机', '人工智能', 'deepseek', 'ai'], 知识: ['知识', '科普', '历史', '科学', '人文', '社科'],
|
|
94
|
+
生活: ['生活', '日常', '家居', '手工'], 美食: ['美食', '做饭', '料理', '烹饪', '吃播'], 游戏: ['游戏', '电竞', '单机', '手游'],
|
|
95
|
+
动画: ['动画', '动漫', '番剧', '二次元'], 音乐: ['音乐', '歌曲', '演奏', '翻唱'], 影视: ['影视', '电影', '电视剧', '影评'],
|
|
96
|
+
动物: ['动物', '宠物', '猫', '狗', '萌宠'], 运动: ['运动', '健身', '体育', '篮球', '足球'], 旅行: ['旅行', '旅游', '风景', '探店'],
|
|
97
|
+
}
|
|
98
|
+
const readable = value => String(value ?? '').replace(/<[^>]*>/g, '').toLowerCase()
|
|
99
|
+
const textOf = row => [row.title, row.description, row.tname, row.category, row.author].map(readable).join(' ')
|
|
100
|
+
/** Public APIs use both exact numbers and abbreviated display counts. Zero is known, not missing. */
|
|
101
|
+
export function videoViewCount(row) {
|
|
102
|
+
for (const raw of [row?.play, row?.stat?.view, row?.view, row?.views]) {
|
|
103
|
+
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.floor(raw)
|
|
104
|
+
if (typeof raw !== 'string') continue
|
|
105
|
+
const text = raw.trim().replace(/[,,\s]/g, '')
|
|
106
|
+
const match = /^(\d+(?:\.\d+)?)(万|亿|[wWkKmM])?(?:次(?:播放)?|播放)?$/.exec(text)
|
|
107
|
+
if (!match) continue
|
|
108
|
+
const multiplier = ({ 万: 1e4, 亿: 1e8, w: 1e4, W: 1e4, k: 1e3, K: 1e3, m: 1e6, M: 1e6 })[match[2]] || 1
|
|
109
|
+
const count = Number(match[1]) * multiplier
|
|
110
|
+
if (Number.isFinite(count)) return Math.floor(count)
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** All discovery sources share the same filters. Unknown metadata remains visible with a verification hint. */
|
|
116
|
+
export function filterBrowseCandidates(rows = [], source = {}, { now = Date.now() } = {}) {
|
|
117
|
+
const cfg = browseSettings(source), keywords = phrases(source.keywords).map(readable)
|
|
118
|
+
const excluded = [], unknown = [], candidates = []
|
|
119
|
+
const cutoff = now - cfg.browseRecentDays * 86400_000
|
|
120
|
+
for (const [index, row] of rows.entries()) {
|
|
121
|
+
if (!row || typeof row !== 'object') continue
|
|
122
|
+
const text = textOf(row), seconds = videoDurationSeconds(row.duration ?? row.length), published = publication(row), viewCount = videoViewCount(row)
|
|
123
|
+
const avoid = cfg.browseAvoidKeywords.find(word => text.includes(readable(word)))
|
|
124
|
+
const reason = avoid ? `避开关键词:${avoid}`
|
|
125
|
+
: seconds !== null && cfg.browseMinMinutes > 0 && seconds < cfg.browseMinMinutes * 60 ? '短于设置的最短时长'
|
|
126
|
+
: seconds !== null && cfg.browseMaxMinutes > 0 && seconds > cfg.browseMaxMinutes * 60 ? '长于设置的最长时长'
|
|
127
|
+
: published !== null && cfg.browseRecentDays > 0 && published < cutoff ? '超出设置的发布时间范围'
|
|
128
|
+
: viewCount !== null && viewCount < cfg.browseMinViews ? '低于设置的最低播放量' : ''
|
|
129
|
+
if (reason) { excluded.push({ bvid: row.bvid || '', reason }); continue }
|
|
130
|
+
const check = []
|
|
131
|
+
if (seconds === null && (cfg.browseMinMinutes || cfg.browseMaxMinutes)) check.push('duration')
|
|
132
|
+
if (published === null && cfg.browseRecentDays) check.push('pubdate')
|
|
133
|
+
if (viewCount === null && cfg.browseMinViews) check.push('views')
|
|
134
|
+
if (check.length) unknown.push({ bvid: row.bvid || '', fields: check })
|
|
135
|
+
const topicHits = cfg.browseTopics.filter(topic => topicAliases[topic].some(word => text.includes(word.toLowerCase())))
|
|
136
|
+
const keywordHits = keywords.filter(word => text.includes(word))
|
|
137
|
+
candidates.push({ row: { ...row, ...(viewCount !== null ? { view_count: viewCount } : {}), ...(check.length ? { preference_check: check, preference_hint: '日期、时长或播放量缺失,挑选前先读 bili_video_info 核对' } : {}) }, index, published, affinity: topicHits.length + keywordHits.length * 2 })
|
|
138
|
+
}
|
|
139
|
+
const compare = cfg.browseSort === 'pubdate' ? (a, b) => (b.published || 0) - (a.published || 0) || a.index - b.index
|
|
140
|
+
: cfg.browseSort === 'click' ? (a, b) => (videoViewCount(b.row) ?? -1) - (videoViewCount(a.row) ?? -1) || a.index - b.index
|
|
141
|
+
: (a, b) => a.index - b.index
|
|
142
|
+
let ordered = candidates.slice().sort(compare)
|
|
143
|
+
if (cfg.browseSort === 'mixed' && (cfg.browseTopics.length || keywords.length)) {
|
|
144
|
+
const preferred = ordered.filter(item => item.affinity > 0).sort((a, b) => b.affinity - a.affinity || a.index - b.index)
|
|
145
|
+
const exploring = ordered.filter(item => item.affinity === 0)
|
|
146
|
+
ordered = []
|
|
147
|
+
let p = 0, e = 0
|
|
148
|
+
for (let index = 0; index < candidates.length; index++) {
|
|
149
|
+
const explorationSlot = Math.floor((index + 1) * cfg.browseExplorePercent / 100) > Math.floor(index * cfg.browseExplorePercent / 100)
|
|
150
|
+
ordered.push(explorationSlot && e < exploring.length || p >= preferred.length ? exploring[e++] : preferred[p++])
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { items: ordered.map(item => item.row), excluded, unknown, settings: cfg }
|
|
154
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/** Small per-run working memory. Callers own storage and scope; no private text is retained. */
|
|
2
|
+
import { bvidOf } from './ledger.mjs'
|
|
3
|
+
|
|
4
|
+
const LIMIT = 24
|
|
5
|
+
const READ_RETRY_LIMIT = 3 // initial failure + at most two suggested retries
|
|
6
|
+
const DISCOVERY = new Set(['bili_search', 'bili_rank', 'bili_hot_videos', 'bili_user_videos'])
|
|
7
|
+
const millis = (value) => typeof value === 'number' && Number.isFinite(value) ? value : Date.parse(value) || 0
|
|
8
|
+
const unique = (list, newest = true) => {
|
|
9
|
+
const ids = [...new Set(list.map(bvidOf).filter(Boolean))]
|
|
10
|
+
return newest ? ids.slice(-LIMIT) : ids.slice(0, LIMIT)
|
|
11
|
+
}
|
|
12
|
+
const SEND_KINDS = ['comment', 'reply', 'opusReply', 'dm', 'moment']
|
|
13
|
+
const fingerprint = (value) => {
|
|
14
|
+
let code = 2166136261
|
|
15
|
+
for (const char of String(value)) code = Math.imul(code ^ char.charCodeAt(0), 16777619)
|
|
16
|
+
return (code >>> 0).toString(16)
|
|
17
|
+
}
|
|
18
|
+
const sourceOf = (tool, args, result) => ({ tool,
|
|
19
|
+
key: fingerprint(JSON.stringify([tool, args.keyword || '', args.uid || '', args.rid || '', args.order || '', args.days ?? ''])),
|
|
20
|
+
page: Number.isSafeInteger(Number(result.page ?? args.page)) ? Math.max(1, Number(result.page ?? args.page)) : 1,
|
|
21
|
+
hasMore: typeof result.has_more === 'boolean' ? result.has_more : null })
|
|
22
|
+
const dueReads = (state, now) => (state.deferredReads || [])
|
|
23
|
+
.filter(row => row.retryAt > 0 && row.retryAt <= millis(now) && row.attempts < READ_RETRY_LIMIT).map(row => row.bvid)
|
|
24
|
+
const semantic = ({ updatedAt, revision, ...rest }) => JSON.stringify(rest)
|
|
25
|
+
const sendKind = (tool, args) => tool === 'bili_comment' ? 'comment'
|
|
26
|
+
: tool === 'bili_reply' ? (args?.rpid && String(args.rpid) !== '0' ? 'reply' : 'comment')
|
|
27
|
+
: tool === 'bili_reply_opus' ? 'opusReply' : tool === 'bili_dm_reply' || tool === 'bili_deliver' ? 'dm'
|
|
28
|
+
: tool === 'bili_moment' ? 'moment' : ''
|
|
29
|
+
const blockedReason = (result) => result?.unknown || result?.guard_status === 'unknown' ? 'unknown-result'
|
|
30
|
+
: result?.quotaReached || result?.limit_reached ? 'quota'
|
|
31
|
+
: result?.pace || result?.riskCooldown ? (['cooldown', 'quota', 'burst', 'hour', 'gap'].includes(result?.reason) ? result.reason : 'pace')
|
|
32
|
+
: result?.already_replied ? 'already-sent' : 'unavailable'
|
|
33
|
+
const blockScope = reason => ['cooldown', 'burst'].includes(reason) ? 'all'
|
|
34
|
+
: ['unknown-result', 'already-sent'].includes(reason) ? 'target' : 'channel'
|
|
35
|
+
|
|
36
|
+
export function createBrowseProgress(now = Date.now()) {
|
|
37
|
+
return { phase: 'discover', startedAt: millis(now), updatedAt: millis(now), pendingBvids: [],
|
|
38
|
+
version: 2, revision: 0, readBvids: [], skippedBvids: [], deferredReads: [], lastBvid: '',
|
|
39
|
+
emptySources: 0, blockedAttempts: 0, blockedSendKinds: [], lastDiscovery: null }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function observeBrowseProgress(state, { tool, args = {}, result, now = Date.now() } = {}) {
|
|
43
|
+
const src = state && typeof state === 'object' ? state : createBrowseProgress(now)
|
|
44
|
+
const next = { ...createBrowseProgress(now), ...src, version: 2, updatedAt: millis(now),
|
|
45
|
+
pendingBvids: unique(Array.isArray(src.pendingBvids) ? src.pendingBvids : [], false),
|
|
46
|
+
readBvids: unique(Array.isArray(src.readBvids) ? src.readBvids : []),
|
|
47
|
+
skippedBvids: unique(Array.isArray(src.skippedBvids) ? src.skippedBvids : []),
|
|
48
|
+
deferredReads: (Array.isArray(src.deferredReads) ? src.deferredReads : []).slice(-LIMIT).map(row => ({ ...row })),
|
|
49
|
+
blockedSendKinds: (Array.isArray(src.blockedSendKinds) ? src.blockedSendKinds : []).map(row => ({ ...row })) }
|
|
50
|
+
const out = result && typeof result === 'object' ? result : {}
|
|
51
|
+
if (DISCOVERY.has(tool)) {
|
|
52
|
+
const rows = Array.isArray(out.fresh) ? out.fresh : out.videos || out.items || out.list || []
|
|
53
|
+
const known = new Set([...next.readBvids, ...next.skippedBvids, ...next.deferredReads.map(row => row.bvid)])
|
|
54
|
+
const candidates = (Array.isArray(rows) ? rows : [])
|
|
55
|
+
.filter(row => !row?.already_seen).map(bvidOf).filter(id => id && !known.has(id))
|
|
56
|
+
next.pendingBvids = unique([...next.pendingBvids, ...candidates], false)
|
|
57
|
+
const failed = Boolean(out.error || out.ok === false || out.success === false)
|
|
58
|
+
next.lastDiscovery = { ...sourceOf(tool, args, out), outcome: candidates.length ? 'found' : failed ? 'error' : 'empty' }
|
|
59
|
+
if (!failed) next.emptySources = candidates.length ? 0 : Math.min(3, (Number(next.emptySources) || 0) + 1)
|
|
60
|
+
next.phase = next.pendingBvids.length ? 'read' : 'discover'
|
|
61
|
+
} else if (tool === 'bili_subtitle') {
|
|
62
|
+
const bvid = bvidOf(args.bvid)
|
|
63
|
+
const content = !out.error && out.ok !== false && out.success !== false && Boolean(
|
|
64
|
+
String(out.text || out.full_text || out.content || '').trim())
|
|
65
|
+
if (bvid) {
|
|
66
|
+
next.pendingBvids = next.pendingBvids.filter(id => id !== bvid)
|
|
67
|
+
next.lastBvid = bvid
|
|
68
|
+
if (content) {
|
|
69
|
+
next.deferredReads = next.deferredReads.filter(row => row.bvid !== bvid)
|
|
70
|
+
next.skippedBvids = next.skippedBvids.filter(id => id !== bvid)
|
|
71
|
+
next.readBvids = unique([...next.readBvids, bvid])
|
|
72
|
+
next.phase = 'reflect'
|
|
73
|
+
} else {
|
|
74
|
+
const absent = out.preference_skipped === true || (!out.error && out.ok !== false && out.success !== false &&
|
|
75
|
+
(/没有字幕|无字幕|no (?:available )?subtitles?/i.test(String(out.message || ''))
|
|
76
|
+
|| (Number(out.segments) === 0 && typeof out.text === 'string' && !out.text.trim())))
|
|
77
|
+
if (absent) {
|
|
78
|
+
next.deferredReads = next.deferredReads.filter(row => row.bvid !== bvid)
|
|
79
|
+
next.skippedBvids = unique([...next.skippedBvids, bvid])
|
|
80
|
+
} else {
|
|
81
|
+
const previous = next.deferredReads.find(row => row.bvid === bvid)
|
|
82
|
+
const attempts = Math.min(READ_RETRY_LIMIT, (Number(previous?.attempts) || 0) + 1)
|
|
83
|
+
const reason = out.guard_status === 'pending' ? 'lease' : 'transport'
|
|
84
|
+
const waitMs = attempts === 1 ? 30_000 : 120_000
|
|
85
|
+
const retryAt = attempts < READ_RETRY_LIMIT ? Math.max(millis(now) + waitMs, millis(out.retryAt || out.retry_at)) : 0
|
|
86
|
+
next.deferredReads = [...next.deferredReads.filter(row => row.bvid !== bvid), { bvid, attempts, retryAt, reason }].slice(-LIMIT)
|
|
87
|
+
}
|
|
88
|
+
next.phase = next.pendingBvids.length ? 'read' : 'discover'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (tool === 'bili_pace' && out.kinds && typeof out.kinds === 'object') {
|
|
93
|
+
const rows = Object.entries(out.kinds).filter(([kind, row]) => SEND_KINDS.includes(kind) && row && typeof row === 'object')
|
|
94
|
+
for (const [kind, row] of rows) {
|
|
95
|
+
const previous = next.blockedSendKinds.find(block => block.kind === kind)
|
|
96
|
+
// An open rate limit does not resolve an uncertain prior send.
|
|
97
|
+
if (previous?.reason === 'unknown-result') continue
|
|
98
|
+
next.blockedSendKinds = next.blockedSendKinds.filter(block => block.kind !== kind)
|
|
99
|
+
if (Number(row.waitSec) > 0) next.blockedSendKinds.push({ kind, scope: blockScope(row.blockedBy),
|
|
100
|
+
reason: ['cooldown', 'quota', 'burst', 'hour', 'gap'].includes(row.blockedBy) ? row.blockedBy : 'pace',
|
|
101
|
+
retryAt: millis(row.nextAt) || millis(now) + Math.ceil(Number(row.waitSec)) * 1000 })
|
|
102
|
+
}
|
|
103
|
+
// A pace query describes sending availability; it must not discard a fresh reading reaction.
|
|
104
|
+
if (rows.length && rows.every(([, row]) => Number(row.waitSec) > 0)) {
|
|
105
|
+
next.blockedAttempts = Math.max(1, Number(next.blockedAttempts) || 0)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (tool === 'bili_seen' && args.action === 'mark' && out.ok === true) {
|
|
109
|
+
const marked = String(args.bvid || '').split(/[,,\s]+/).map(bvidOf).filter(Boolean)
|
|
110
|
+
next.pendingBvids = next.pendingBvids.filter(id => !marked.includes(id))
|
|
111
|
+
next.skippedBvids = unique([...next.skippedBvids, ...marked.filter(id => !next.readBvids.includes(id))])
|
|
112
|
+
next.deferredReads = next.deferredReads.filter(row => !marked.includes(row.bvid))
|
|
113
|
+
next.phase = next.pendingBvids.length ? 'read' : 'discover'
|
|
114
|
+
}
|
|
115
|
+
const kind = sendKind(tool, args)
|
|
116
|
+
if (kind) {
|
|
117
|
+
if (out.blocked || out.pace || out.unknown || out.retry_blocked || out.already_replied
|
|
118
|
+
|| out.ok === false || out.success === false || out.error) {
|
|
119
|
+
next.blockedAttempts = Math.min(999, (Number(next.blockedAttempts) || 0) + 1)
|
|
120
|
+
const reason = blockedReason(out)
|
|
121
|
+
const retryAt = millis(out.retryAt || out.retry_at) || (reason === 'unknown-result' ? 0 : millis(now) + 60_000)
|
|
122
|
+
next.blockedSendKinds = next.blockedSendKinds.filter(row => row.kind !== kind)
|
|
123
|
+
next.blockedSendKinds.push({ kind, reason, retryAt, scope: out.riskCooldown ? 'all' : blockScope(reason) })
|
|
124
|
+
next.phase = next.pendingBvids.length ? 'read' : 'discover'
|
|
125
|
+
} else if (out.ok === true || out.success === true || out.guard_status === 'sent') {
|
|
126
|
+
next.blockedAttempts = 0
|
|
127
|
+
next.phase = next.pendingBvids.length ? 'read' : 'discover'
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (semantic(next) === semantic(src)) return src
|
|
131
|
+
return { ...next, revision: (Number(src.revision) || 0) + 1 }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function browseProgressBrief(state, now = Date.now(), { compact = false } = {}) {
|
|
135
|
+
const src = state || createBrowseProgress(now)
|
|
136
|
+
const pending = unique([...(Array.isArray(src.pendingBvids) ? src.pendingBvids : []), ...dueReads(src, now)], false)
|
|
137
|
+
const deferred = (src.deferredReads || []).filter(row => !pending.includes(row.bvid))
|
|
138
|
+
const blocked = (Array.isArray(src.blockedSendKinds) ? src.blockedSendKinds : [])
|
|
139
|
+
.filter(row => row.reason === 'unknown-result' || !row.retryAt || row.retryAt > millis(now))
|
|
140
|
+
.map(row => ({ ...row, scope: blockScope(row.reason) === 'all' ? 'all' : row.scope || blockScope(row.reason) }))
|
|
141
|
+
let next
|
|
142
|
+
if (src.phase === 'reflect' && src.lastBvid) next = {
|
|
143
|
+
action: 'reflect-or-move-on', bvid: src.lastBvid,
|
|
144
|
+
hint: '刚读完这条。挑一个真实反应,也可以略过。按 blockedSendKinds 避让:scope=all 的整体风控冷却或连发暂停期间只读,停所有发送;只有局部通道或目标受限时,仍可处理原本独立且已授权的互动。不换措辞、接口或通道重试同一次被拦发送,未知结果的同一目标不重试。值得学的记成有适用场景的待审经验,再处理下一个候选。' }
|
|
145
|
+
else if (pending.length && Number(src.blockedAttempts) >= 1 && blocked.length) next = {
|
|
146
|
+
action: 'continue-reading', tool: 'bili_subtitle', args: { bvid: pending[0] },
|
|
147
|
+
hint: '先读这个候选。scope=all 的整体风控冷却或连发暂停期间只读,停所有发送;只有局部通道或目标受限时,才可继续新的阅读与独立且已授权的接话。不换措辞、接口或通道重试同一次被拦发送,未知结果的同一目标不重试。' }
|
|
148
|
+
else if (pending.length) next = { action: 'read-next', tool: 'bili_subtitle', args: { bvid: pending[0] }, hint: '继续处理剩余候选,无需重新搜索同一批。' }
|
|
149
|
+
else if (Number(src.emptySources) >= 3) next = { action: 'rest-or-change-source', hint: '连续三个发现请求没有新候选。先整理已看内容或休息;继续探索时换关键词或来源,不重复空查询。' }
|
|
150
|
+
else if (src.lastDiscovery?.tool === 'bili_search' && src.lastDiscovery.hasMore === true && src.lastDiscovery.page < 50) next = {
|
|
151
|
+
action: 'search-next-page', tool: 'bili_search', page: src.lastDiscovery.page + 1,
|
|
152
|
+
hint: `这页候选已经处理完。沿用刚才的关键词和筛选,翻到第 ${src.lastDiscovery.page + 1} 页,不重复第一页。` }
|
|
153
|
+
else if (src.lastDiscovery?.outcome === 'error') next = { action: 'change-source', hint: '刚才发现请求失败,不能据此说没有视频。先处理别的来源,不重复请求同一个失败入口。' }
|
|
154
|
+
else if (deferred.length && !Number(src.emptySources)) next = { action: 'discover-or-wait',
|
|
155
|
+
hint: '还有暂时读不到的候选。先找其他视频;达到建议重试时间后可补查,重试已用完的留给后续人工决定,不连续轮询。' }
|
|
156
|
+
else next = { action: Number(src.emptySources) ? 'change-source' : 'discover',
|
|
157
|
+
hint: Number(src.emptySources) ? '这个入口没有新候选。换关键词、分区或发现入口;连续空结果时整理后休息,不要重复同一查询。'
|
|
158
|
+
: '按这次兴趣找一小批候选。已完成的片翻篇,不为发言数凑任务。' }
|
|
159
|
+
if (compact) { const { hint, ...briefNext } = next; next = briefNext }
|
|
160
|
+
const retryTimes = deferred.map(row => row.retryAt).filter(at => at > millis(now))
|
|
161
|
+
return { phase: src.phase || 'discover', pendingBvids: compact ? pending.slice(0, 3) : pending, pendingCount: pending.length,
|
|
162
|
+
recentReadCount: (src.readBvids || []).length, recentSkippedCount: (src.skippedBvids || []).length,
|
|
163
|
+
deferredReadCount: deferred.length, nextReadRetryAt: retryTimes.length ? Math.min(...retryTimes) : 0,
|
|
164
|
+
blockedAttempts: Number(src.blockedAttempts) || 0, blockedSendKinds: blocked, next }
|
|
165
|
+
}
|
package/lib/comment.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Conservative offline checks for empty, excessively long or clearly boilerplate comments.
|
|
3
|
+
* Short conversational reactions do not need to prove substantive length.
|
|
3
4
|
* Signature words are optional. Flavor scoring is diagnostic unless strictFlavor is explicit.
|
|
4
|
-
*
|
|
5
|
+
* Quotes, timestamps, signature words and formal vocabulary are style advisories.
|
|
6
|
+
* Self-reference streaks and stacked marks are advisories; repeated actual bodies block.
|
|
5
7
|
* No network or storage access; the caller supplies recent texts.
|
|
6
8
|
*/
|
|
7
9
|
|
|
@@ -17,6 +19,9 @@ const COMMENT_MAX_LEN = 150
|
|
|
17
19
|
/** 回复正文上限:回复更该短 */
|
|
18
20
|
const REPLY_MAX_LEN = 100
|
|
19
21
|
|
|
22
|
+
/** A short reaction can be a word, laugh or meme; do not require extra explanatory prose. */
|
|
23
|
+
const SHORT_REACTION_MAX_LEN = 12
|
|
24
|
+
|
|
20
25
|
/** 鱼味分到这就算"有鱼味"(一个强标记就够) */
|
|
21
26
|
const FLAVOR_MIN = 2
|
|
22
27
|
|
|
@@ -320,8 +325,7 @@ export const templateCheck = (text, history = []) => {
|
|
|
320
325
|
* @param {{kind?:'comment'|'reply', strictFlavor?:boolean}} [opts]
|
|
321
326
|
* @returns {{ok:true} | {ok:false, reason:string, hint:string, substantive:number, threshold:number}}
|
|
322
327
|
*
|
|
323
|
-
*
|
|
324
|
-
* 多塞字段会让成功路径也拖一堆噪音。
|
|
328
|
+
* 放行时可带 warnings,供写作回顾使用;风格建议不要求重写或补口头禅。
|
|
325
329
|
*/
|
|
326
330
|
export const commentQuality = (text, opts = {}) => {
|
|
327
331
|
// 只认两种:回复(rpid≠0)门槛更低,新评论按评论级
|
|
@@ -336,12 +340,16 @@ export const commentQuality = (text, opts = {}) => {
|
|
|
336
340
|
const threshold = kind === 'reply' ? REPLY_MIN_SUBSTANTIVE : COMMENT_MIN_SUBSTANTIVE
|
|
337
341
|
const maxLen = kind === 'reply' ? REPLY_MAX_LEN : COMMENT_MAX_LEN
|
|
338
342
|
const len = Array.from(body).length
|
|
343
|
+
const conversationalText = body.replace(URL_RE, '')
|
|
344
|
+
.replace(/([))])[وノノงづっつ][✧♪♡♥]*$/u, '$1')
|
|
345
|
+
.replace(KAOMOJI_RE, '').replace(EMOJI_RE, '').replace(PUNCT_RE, '')
|
|
346
|
+
const hasText = /[\p{L}\p{N}]/u.test(conversationalText)
|
|
339
347
|
|
|
340
348
|
// 一、空的:只有标点 / 表情 / 空格
|
|
341
|
-
if (!body) {
|
|
349
|
+
if (!body || !hasText) {
|
|
342
350
|
return fail(
|
|
343
351
|
'这条是空的(或者只剩标点、表情)',
|
|
344
|
-
'
|
|
352
|
+
'想接话就用自己的话说,几个字也可以;没什么想说的就不发。',
|
|
345
353
|
n, threshold,
|
|
346
354
|
)
|
|
347
355
|
}
|
|
@@ -350,66 +358,42 @@ export const commentQuality = (text, opts = {}) => {
|
|
|
350
358
|
if (len > maxLen) {
|
|
351
359
|
return fail(
|
|
352
360
|
`这条 ${len} 个字,超过 ${maxLen} 字上限`,
|
|
353
|
-
'
|
|
361
|
+
'挑最想说的那一下,砍到一两句。更多内容可以留在笔记里,不用拆成多条发送。',
|
|
354
362
|
n, threshold,
|
|
355
363
|
)
|
|
356
364
|
}
|
|
357
365
|
|
|
358
|
-
//
|
|
359
|
-
if (n < threshold) {
|
|
366
|
+
// Short contextual reactions need no filler. Only a longer low-content string still asks to be shortened.
|
|
367
|
+
if (n < threshold && len > SHORT_REACTION_MAX_LEN) {
|
|
360
368
|
return fail(
|
|
361
|
-
|
|
362
|
-
'
|
|
363
|
-
+ '随口的反应也可以,比如「这个转场我盯了三遍」,不必归纳或打分。',
|
|
369
|
+
'这条主要是较长的一串重复语气或口号',
|
|
370
|
+
'收短成你想接的那一句就好,短梗、笑一下都可以;不用补细节、解释笑点或讲道理,也可以不发。',
|
|
364
371
|
n, threshold,
|
|
365
372
|
)
|
|
366
373
|
}
|
|
367
374
|
|
|
368
375
|
const lower = raw.toLowerCase()
|
|
369
376
|
const strong = strongMarksOf(lower)
|
|
377
|
+
const warnings = []
|
|
370
378
|
|
|
371
|
-
//
|
|
372
|
-
// 「684 赞那条才是重点」「13 赞那条长评被埋太深了」—— 这是解说员,不是来冲浪的鱼。
|
|
373
|
-
// 真人翻到这句只会划走:既没有自己的反应,也没有可接的话。
|
|
379
|
+
// Opening with somebody else's like count is often repetitive, but can be a valid response.
|
|
374
380
|
if (LIKE_BRAG_RE.test(body)) {
|
|
375
|
-
|
|
376
|
-
ok: false,
|
|
377
|
-
reason: '这条只是在复述别人的赞数(「NNN 赞那条…」)',
|
|
378
|
-
hint: '把「NNN 赞那条」整段删掉,直接说**你自己**看见的那一下:'
|
|
379
|
-
+ '「这句我念给室友听了」「原来是这样啊,我白看三遍了呜」。'
|
|
380
|
-
+ '想说别人的评论就直接回他,别在自己的评论里点评赞数。',
|
|
381
|
-
substantive: n,
|
|
382
|
-
threshold,
|
|
383
|
-
}
|
|
381
|
+
warnings.push('like-count-opening')
|
|
384
382
|
}
|
|
385
383
|
|
|
386
|
-
//
|
|
387
|
-
// 「[01:47]那句才是这段的骨头:『以前就是一勺半』」「UP 那句『求上得中』比骂人狠」——
|
|
388
|
-
// 使用者点名:评论别引用原视频文字片段,观感不好;自己组织语言,像鲸鱼摸鱼,不像普通路人。
|
|
384
|
+
// Avoid making every reaction a quoted transcript. A regex cannot judge originality.
|
|
389
385
|
const quoteHit = QUOTE_VIDEO_RE.test(body) || QUOTE_SAY_RE.test(body)
|
|
390
386
|
if (quoteHit) {
|
|
391
|
-
|
|
392
|
-
ok: false,
|
|
393
|
-
reason: '这条在引用原视频的文字片段(时间戳/「UP 那句」+ 引号原话)',
|
|
394
|
-
hint: '把引号里那段原话删掉,用你自己的话说那一下:'
|
|
395
|
-
+ '「凌晨四点人吵架狗赔命这段看得本鱼呆毛都竖不起来呜」比引用原文更有鱼味。'
|
|
396
|
-
+ '想夸哪一秒,就说那一秒你什么反应,别当字幕搬运工。',
|
|
397
|
-
substantive: n,
|
|
398
|
-
threshold,
|
|
399
|
-
}
|
|
387
|
+
warnings.push('source-quote')
|
|
400
388
|
}
|
|
401
389
|
|
|
402
390
|
// An @ mention is not a reason to demand signature vocabulary.
|
|
403
|
-
//
|
|
391
|
+
// A few words cannot determine whether a specific response is boilerplate.
|
|
404
392
|
const official = countWords(lower, OFFICIAL_TALK)
|
|
405
393
|
if (official >= OFFICIAL_MAX_TOLERATED && strong === 0) {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
+ '「这个转场我看了三遍」「原来是这个参数啊,我卡了一晚上」。',
|
|
410
|
-
n, threshold,
|
|
411
|
-
)
|
|
412
|
-
}
|
|
394
|
+
warnings.push('formal-wording')
|
|
395
|
+
}
|
|
396
|
+
if (body.includes('——')) warnings.push('em-dash')
|
|
413
397
|
|
|
414
398
|
if (needFlavor) {
|
|
415
399
|
// Only explicitly requested strict flavor mode requires signature vocabulary.
|
|
@@ -417,13 +401,12 @@ export const commentQuality = (text, opts = {}) => {
|
|
|
417
401
|
return {
|
|
418
402
|
ok: false,
|
|
419
403
|
reason: '这条里没有一点鲸鱼摸鱼的鱼味(太正经了,像通用 AI 在写影评)',
|
|
420
|
-
hint: '
|
|
421
|
-
+ '注意是用自己的话说那一下(2026-10-24 起引用视频原话会被上面那道闸拦),不用深刻,可爱就够了。',
|
|
404
|
+
hint: '当前显式启用了 strictFlavor。自然表达不需要固定自称或颜文字;自动浏览应使用默认宽松质量检查。',
|
|
422
405
|
substantive: n,
|
|
423
406
|
threshold,
|
|
424
407
|
}
|
|
425
408
|
}
|
|
426
409
|
}
|
|
427
410
|
|
|
428
|
-
return { ok: true }
|
|
411
|
+
return warnings.length ? { ok: true, warnings } : { ok: true }
|
|
429
412
|
}
|
package/lib/config-policy.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/** Model-authored arguments are preferences, never evidence of human authorization. */
|
|
2
2
|
export const MODEL_CONFIG_KEYS = Object.freeze([
|
|
3
|
-
'
|
|
3
|
+
'focus', 'keywords', 'reportLevel', 'perDay', 'momentStyle', 'interactionStyle',
|
|
4
4
|
'idleChatter', 'chatterFrequency', 'notificationLevel', 'focusMode',
|
|
5
5
|
'quietHoursEnabled', 'quietHoursStart', 'quietHoursEnd', 'petReducedMotion', 'petShow',
|
|
6
6
|
])
|
|
7
7
|
const permitted = new Set(MODEL_CONFIG_KEYS)
|
|
8
8
|
export function modelConfigDecision(patch) {
|
|
9
9
|
const keys = Object.keys(patch || {})
|
|
10
|
+
if (keys.includes('persona')) return { ok: false, code: 'PERSONA_MANUAL_ONLY', error: '全局人格只能由本人在工作台保存;角色工具不能改写或清空本人的人格设置。' }
|
|
10
11
|
if (keys.includes('sendReview')) return { ok: false, code: 'SEND_REVIEW_MANUAL_ONLY', error: '发送预览开关只能由本人在工作台 → 互动与投递中修改。' }
|
|
11
12
|
if (keys.includes('stickerReview')) return { ok: false, code: 'STICKER_REVIEW_MANUAL_ONLY', error: '表情人工审核开关只能由本人在工作台 → 记忆与素材中修改。' }
|
|
12
13
|
const blockedKeys = keys.filter(key => !permitted.has(key))
|