dsh-deepmoyu 2.3.0 → 2.4.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.
Files changed (89) hide show
  1. package/AGENTS.md +91 -153
  2. package/CHANGELOG.md +27 -0
  3. package/CONTRIBUTING.md +51 -0
  4. package/CONTRIBUTORS.md +3 -7
  5. package/NOTICE.md +3 -3
  6. package/README.md +141 -81
  7. package/SECURITY.md +21 -0
  8. package/assets/screenshots/memory.png +0 -0
  9. package/assets/screenshots/menu.png +0 -0
  10. package/assets/screenshots/workspace.png +0 -0
  11. package/client.js +1348 -441
  12. package/cordis.patch.yml +1 -1
  13. package/docs/ASSETS.md +25 -0
  14. package/docs/DEPLOYMENT.md +102 -0
  15. package/docs/PRIVACY.md +32 -0
  16. package/docs/RELEASE_AUDIT.md +70 -0
  17. package/docs/RELEASING.md +94 -0
  18. package/docs/TEST_MATRIX.md +77 -0
  19. package/docs/assets-permissions.json +20 -0
  20. package/docs/tool-evidence.json +196 -0
  21. package/index.js +953 -270
  22. package/lib/asr.py +41 -35
  23. package/lib/background-output.mjs +64 -17
  24. package/lib/background-tools.mjs +1 -0
  25. package/lib/companion.mjs +43 -0
  26. package/lib/config-policy.mjs +20 -0
  27. package/lib/favops.py +9 -2
  28. package/lib/ids.mjs +41 -0
  29. package/lib/login-session.mjs +34 -0
  30. package/lib/login.mjs +5 -4
  31. package/lib/mcp.mjs +3 -2
  32. package/lib/memory-center.mjs +74 -0
  33. package/lib/mode.mjs +6 -7
  34. package/lib/model-relay.mjs +5 -4
  35. package/lib/open-directory.mjs +93 -0
  36. package/lib/pace.mjs +92 -12
  37. package/lib/persona.mjs +46 -0
  38. package/lib/product-status.mjs +123 -0
  39. package/lib/prompts.mjs +40 -5
  40. package/lib/pv-production.mjs +280 -0
  41. package/lib/pv-session.mjs +316 -0
  42. package/lib/pv.mjs +398 -306
  43. package/lib/pv.py +84 -22
  44. package/lib/pv_cover.py +108 -26
  45. package/lib/pv_editorial.py +336 -0
  46. package/lib/pv_verify.py +267 -0
  47. package/lib/python-env.mjs +61 -0
  48. package/lib/reply.mjs +10 -3
  49. package/lib/run-settings.mjs +6 -0
  50. package/lib/sticker-automation.mjs +5 -4
  51. package/lib/sticker.mjs +135 -25
  52. package/lib/tool-scope.mjs +65 -0
  53. package/lib/uvfetch.mjs +165 -153
  54. package/lib/verify.mjs +111 -127
  55. package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
  56. package/package.json +23 -12
  57. package/presets/deepmoyu.patch.yml +1 -1
  58. package/prompts/duty.md +4 -3
  59. package/screenshots.json +5 -0
  60. package/selfcheck.mjs +21 -6
  61. package/vendor/bilibili-mcp/requirements.in +9 -0
  62. package/vendor/bilibili-mcp/requirements.txt +1936 -11
  63. package/vendor/bilibili-mcp/runtime_guard.py +35 -10
  64. package/lib/__pycache__/pv.cpython-310.pyc +0 -0
  65. package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
  66. package/tests/automation-routes.test.mjs +0 -183
  67. package/tests/automation.test.mjs +0 -79
  68. package/tests/background-tools.test.mjs +0 -182
  69. package/tests/background-window.test.mjs +0 -75
  70. package/tests/dup-cross-channel.test.mjs +0 -158
  71. package/tests/manual-browsing.test.mjs +0 -671
  72. package/tests/mcp-restart.test.mjs +0 -131
  73. package/tests/memory-chain.test.mjs +0 -413
  74. package/tests/model-relay.test.mjs +0 -116
  75. package/tests/persona.test.mjs +0 -67
  76. package/tests/pet-run-routes.test.mjs +0 -127
  77. package/tests/pet-run-settings.test.mjs +0 -38
  78. package/tests/pet-session.test.mjs +0 -125
  79. package/tests/pv-stage1.test.mjs +0 -203
  80. package/tests/sticker-routes.test.mjs +0 -141
  81. package/tests/sticker-upload-routes.test.mjs +0 -97
  82. package/tests/sticker-upload.test.mjs +0 -122
  83. package/tests/sticker.test.mjs +0 -169
  84. package/tests/test_guard_cross_channel.py +0 -141
  85. package/tests/test_pv_cover.py +0 -95
  86. package/tests/test_pv_stage1.py +0 -250
  87. package/tests/test_reply_pictures.py +0 -255
  88. package/tests/token-ledger.test.mjs +0 -78
  89. package/tests/verify_pv_sample.py +0 -91
package/lib/asr.py CHANGED
@@ -18,33 +18,42 @@ import argparse
18
18
  import os
19
19
  import re
20
20
  import sys
21
- import tempfile
22
-
23
- # ⭐ Windows 上 Python 默认按 GBK 编/解码标准输出 —— 中文或 emoji 打进管道会直接炸
24
- # (有用户反馈装依赖 / 跑转写时报 UnicodeDecodeError 或乱码)⇒ 这里钉死 UTF-8
25
- try:
26
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
27
- sys.stderr.reconfigure(encoding='utf-8', errors='replace')
28
- except Exception:
29
- pass
21
+ import uuid
30
22
 
31
23
  # ⚠️ 临时目录必须待在家目录里:值班那一轮跑在沙箱里(只允许写工作区/cwd),
32
24
  # yt-dlp 默认往 %TEMP% 写、会被直接拒掉(2026-10-14 在主人机器上实证:
33
25
  # yt_dlp DownloadError: unable to open for writing: [Errno 13] Permission denied)
34
26
  _HOME = os.path.dirname(os.path.abspath(__file__))
35
27
  _TMP = os.path.join(_HOME, 'tmp', 'asr')
36
- try:
37
- os.makedirs(_TMP, exist_ok=True)
38
- tempfile.tempdir = _TMP
39
- except Exception:
40
- pass
41
28
 
42
- # ⭐ 必须在 import faster_whisper **之前**设好,否则它已经去连 HuggingFace 了
43
- os.environ.setdefault('HF_ENDPOINT', 'https://hf-mirror.com')
44
- # 模型缓存放本脚本所在目录(= 插件家):默认的 ~/.cache 在本机拒绝写入
45
- os.environ.setdefault('HF_HOME', os.path.join(os.path.dirname(os.path.abspath(__file__)), '.hf-cache'))
46
- # HuggingFace 新版存储(Xet)国内镜像不代理 ⇒ 关掉它,走老 CDN 通道(否则 401)
47
- os.environ.setdefault('HF_HUB_DISABLE_XET', '1')
29
+ def prepare_runtime() -> None:
30
+ """仅在执行命令时初始化环境,导入模块不改变进程的缓存或临时目录。"""
31
+ try:
32
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
33
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
34
+ except Exception:
35
+ pass
36
+ # 必须在 import faster_whisper 前设置;保留用户明确指定的端点与缓存。
37
+ os.environ.setdefault('HF_ENDPOINT', 'https://hf-mirror.com')
38
+ os.environ.setdefault('HF_HOME', os.path.join(_HOME, '.hf-cache'))
39
+ os.environ.setdefault('HF_HUB_DISABLE_XET', '1')
40
+
41
+
42
+ def cleanup_download(tmp: str, tag: str) -> None:
43
+ """只回收本次下载的独占文件前缀,也处理失败时留下的分片。"""
44
+ try:
45
+ with os.scandir(tmp) as entries:
46
+ for entry in entries:
47
+ if not entry.name.startswith(tag + '.'):
48
+ continue
49
+ try:
50
+ # 不递归进入目录;即使出现符号链接也只删除链接本身。
51
+ if entry.is_file(follow_symlinks=False) or entry.is_symlink():
52
+ os.remove(entry.path)
53
+ except OSError:
54
+ pass
55
+ except OSError:
56
+ pass
48
57
 
49
58
 
50
59
  def to_url(target: str) -> str:
@@ -59,7 +68,7 @@ def fetch_audio(url: str, tmp: str, tag: str) -> str:
59
68
  from yt_dlp import YoutubeDL
60
69
  opts = {
61
70
  'format': 'bestaudio/best',
62
- 'outtmpl': os.path.join(tmp, tag + '.%(ext)s'), # ⭐ 用 BV 号当文件名:固定名会和别的轮次撞车(踩过)
71
+ 'outtmpl': os.path.join(tmp, tag + '.%(ext)s'), # 每次调用的独占标签,允许同视频并行下载。
63
72
  'paths': {'home': tmp, 'temp': tmp}, # 别让 yt-dlp 往 %TEMP% 写(沙箱会拒)
64
73
  'cachedir': False, # 顺手别去动用户目录里的缓存
65
74
  # ⭐ nopart:不写 .part 再改名,直接落最终文件。
@@ -89,17 +98,22 @@ def main() -> int:
89
98
  help='Whisper 模型大小:tiny / base / small / medium(默认 small)')
90
99
  ap.add_argument('--lang', default='zh', help='语言(默认 zh;不给就自动判断请传 auto)')
91
100
  args = ap.parse_args()
101
+ prepare_runtime()
92
102
 
93
103
  src = args.target
94
104
  # ⚠️ 不用 mkdtemp:沙箱只允许写"子进程启动前就存在"的目录,新建的会被拒 ✗
95
- # 这个目录由宿主(插件)预先建好;文件名固定,跑完自己清掉
105
+ # 这个目录由宿主(插件)预先建好;使用唯一前缀而不是新建子目录。
96
106
  tmp = _TMP
97
- # ⭐ 每个视频用**自己的**文件名:以前写死 audio.m4a ⇒ 两轮同时跑会互相覆盖,
98
- # 上一轮转出来的其实是另一个视频的内容(2026-10-14 值班那轮亲踩 ✗)
107
+ # 同一个视频也可能同时转写;清理权只属于本次远程下载,不属于本地输入。
99
108
  tag = os.path.basename(src).rsplit('.', 1)[0] or 'clip'
100
109
  tag = re.sub(r'[^0-9A-Za-z_-]', '_', tag)[:40] or 'clip'
110
+ tag += '-' + uuid.uuid4().hex
111
+ downloaded = False
101
112
  try:
102
- if src.lower().startswith('http') or src.upper().startswith('BV'):
113
+ # 已存在的本地文件优先,即使文件名恰好以 BV/http 开头。
114
+ if not os.path.exists(src) and (src.lower().startswith('http') or src.upper().startswith('BV')):
115
+ os.makedirs(tmp, exist_ok=True)
116
+ downloaded = True
103
117
  print('[asr] 先下音频…', file=sys.stderr)
104
118
  src = fetch_audio(to_url(src), tmp, tag)
105
119
  if not os.path.exists(src):
@@ -117,16 +131,8 @@ def main() -> int:
117
131
  )
118
132
  text = ''.join(seg.text for seg in segments).strip()
119
133
  finally:
120
- # 临时音频不留在磁盘上
121
- try:
122
- import glob
123
- for f in glob.glob(os.path.join(tmp, tag + '.*')):
124
- try:
125
- os.remove(f)
126
- except Exception:
127
- pass
128
- except Exception:
129
- pass
134
+ if downloaded:
135
+ cleanup_download(tmp, tag)
130
136
 
131
137
  if args.out:
132
138
  with open(args.out, 'w', encoding='utf-8') as f:
@@ -1,48 +1,95 @@
1
1
  /** Bounded background diagnostics. Never copy raw tool arguments, private inboxes or reasoning into the pet log. */
2
2
  import { StringDecoder } from 'node:string_decoder'
3
- import { redactRecordText } from './record-privacy.mjs'
4
3
 
5
4
  const ansi = /\x1b\[[0-?]*[ -/]*[@-~]/g
5
+ const MAX_LINE = 16384
6
+ const TOOL_NAMES = new Set(('bili_search bili_video_info bili_subtitle bili_comments bili_reply bili_reply_opus bili_received_replies '
7
+ + 'bili_dm_inbox bili_dm_list bili_dm_thread bili_dm_reply bili_send_message bili_send_dynamic bili_send_opus bili_moment '
8
+ + 'bili_my_dynamics bili_my_notices bili_my_video_comments bili_user_info bili_user_videos bili_hot_videos bili_weekly_hot bili_rank '
9
+ + 'bili_danmaku bili_login bili_login_poll bili_login_start bili_check_credential bili_whoami bili_config bili_duty_status '
10
+ + 'bili_feiyu bili_guard_status bili_learn bili_pace bili_pv bili_records bili_run bili_say bili_seen bili_setup bili_soul bili_status bili_sticker bili_taste').split(' '))
11
+ const CODES = new Set(('NO_ADAPTER ENOENT EACCES EPERM ECONNREFUSED ECONNRESET ETIMEDOUT ERR_MODULE_NOT_FOUND MODULE_NOT_FOUND '
12
+ + 'ACCOUNT_SIGN_IN_REQUIRED INVALID_ARGUMENT INVALID_ROLE INVALID_MODEL_ROUTE RELAY_ERROR RELAY_INVALID_URL RELAY_NO_CAPABILITY '
13
+ + 'RELAY_ROUTE_MISMATCH RELAY_INVALID_MODEL_INFO RELAY_FRAME_LIMIT RELAY_STREAM_CLOSED RELAY_INVALID_ROLE RELAY_FORBIDDEN '
14
+ + 'RELAY_EXPIRED RELAY_BODY_LIMIT RELAY_INVALID_JSON RELAY_BUSY RELAY_NO_LLM RELAY_INVALID_REQUEST RELAY_NOT_READY').split(' '))
15
+
16
+ /** Exception messages may echo arbitrary model/tool input. Emit allowlisted metadata only. */
17
+ function diagnosticSummary(text, isWarning, truncated) {
18
+ const codes = [...new Set(text.match(/\b[A-Z][A-Z0-9_]+\b/g) || [])].filter(code => CODES.has(code)).slice(0, 6)
19
+ let summary = isWarning ? '后台运行时警告' : '后台进程报告错误'
20
+ if (/Cannot find module|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND|failed to import/i.test(text)) summary = '后台依赖加载失败'
21
+ else if (/NO_ADAPTER|RELAY_NO_LLM|RELAY_NOT_READY/.test(text)) summary = '后台模型适配器不可用'
22
+ else if (/ACCOUNT_SIGN_IN_REQUIRED/.test(text)) summary = '后台账号需要重新登录'
23
+ else if (/ECONNREFUSED|ECONNRESET|ETIMEDOUT|连接失败/.test(text)) summary = '后台连接失败或超时'
24
+ else if (/启动失败/.test(text)) summary = '后台任务启动失败'
25
+ else if (/Ignoring extra certs/i.test(text)) summary = '后台证书配置警告'
26
+ else if (/ExperimentalWarning/i.test(text)) summary = '后台运行时实验性能力警告'
27
+ return { codes, text: summary + (codes.length ? `(${codes.join('、')})` : '') + (truncated ? ';长行已截断' : '') + ';原始输出已省略' }
28
+ }
29
+
30
+ /** Shared by structured relay errors, which can echo the same private input as stderr. */
31
+ export function summarizeBackgroundDiagnostic(value) {
32
+ const text = typeof value === 'string' ? value : `${String(value?.code || '')} ${String(value?.message || value || '')}`
33
+ return diagnosticSummary(text.slice(0, MAX_LINE), false, text.length > MAX_LINE)
34
+ }
35
+
6
36
  export function createBackgroundOutput({ onDiagnostic, onProgress } = {}) {
7
37
  const decoders = { stdout: new StringDecoder('utf8'), stderr: new StringDecoder('utf8') }
8
38
  const pending = { stdout: '', stderr: '' }
39
+ const discarding = { stdout: false, stderr: false }
9
40
  let lastError = '', lastProgress = '', ended = false
10
- const notify = (fn, value) => { try { fn?.(value) } catch { /* diagnostic failures cannot kill the host */ } }
11
- const line = (value, channel) => {
12
- const text = redactRecordText(String(value).replace(ansi, '').trim(), { maxLength: 1200 })
41
+ const notify = (fn, value) => {
42
+ try { Promise.resolve(fn?.(value)).catch(() => {}) } catch { /* diagnostic failures cannot kill the host */ }
43
+ }
44
+ const line = (value, channel, truncated = false) => {
45
+ const text = String(value).replace(ansi, '').trim()
13
46
  if (!text) return
14
47
  // Print only task/diagnostic metadata; a tool invocation may contain somebody's private message.
15
- const tools = [...new Set(text.match(/\bbili_[a-z_]+\b/g) || [])]
48
+ const tools = [...new Set(text.match(/\bbili_[a-z_]+\b/g) || [])].filter(name => TOOL_NAMES.has(name))
16
49
  if (tools.length) {
17
50
  const message = '工具:' + tools.slice(0, 8).join('、')
18
51
  if (message !== lastProgress) { lastProgress = message; notify(onProgress, { stage: 'tool', text: message }) }
19
52
  }
20
- const isError = /NO_ADAPTER|\b(?:Error|ERROR|ENOENT|ECONNREFUSED|ETIMEDOUT|ACCOUNT_SIGN_IN_REQUIRED|INVALID_[A-Z_]+|RELAY_[A-Z_]+)\b|dsh:|failed to import|启动失败|连接失败/i.test(text)
53
+ const isError = /NO_ADAPTER|\b(?:Error|ERROR|ENOENT|EACCES|EPERM|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND|ACCOUNT_SIGN_IN_REQUIRED|INVALID_[A-Z_]+|RELAY_[A-Z_]+)\b|dsh:|failed to import|启动失败|连接失败/i.test(text)
21
54
  const isWarning = /^(?:\(node:\d+\)\s*)?(?:Warning:|warning:)|Ignoring extra certs|ExperimentalWarning/i.test(text)
22
55
  if ((channel === 'stderr' && (isError || isWarning || /^\s*at\s/.test(text))) || isError) {
23
- const clean = text.replace(/((?:request|response|messages?|body|arguments|prompt)\s*[:=]).*/i, '$1[内容省略]')
24
- if (isError && !isWarning) lastError = clean
25
- notify(onDiagnostic, { channel, text: clean, error: isError && !isWarning })
56
+ const summary = diagnosticSummary(text, isWarning, truncated)
57
+ if (isError && !isWarning) lastError = summary.text
58
+ notify(onDiagnostic, { channel, ...summary, error: isError && !isWarning })
26
59
  }
27
60
  }
28
- const feed = (text, channel, flush = false) => {
29
- pending[channel] += text
30
- let newline
31
- while ((newline = pending[channel].indexOf('\n')) >= 0) {
32
- line(pending[channel].slice(0, newline), channel)
33
- pending[channel] = pending[channel].slice(newline + 1)
61
+ const feed = (text, channel) => {
62
+ let offset = 0
63
+ while (offset < text.length) {
64
+ const newline = text.indexOf('\n', offset), end = newline < 0 ? text.length : newline
65
+ if (!discarding[channel]) {
66
+ const room = MAX_LINE - pending[channel].length
67
+ pending[channel] += text.slice(offset, Math.min(end, offset + room))
68
+ if (end - offset > room) {
69
+ line(pending[channel], channel, true)
70
+ pending[channel] = ''; discarding[channel] = true
71
+ }
72
+ }
73
+ if (newline < 0) break
74
+ if (!discarding[channel]) line(pending[channel], channel)
75
+ pending[channel] = ''; discarding[channel] = false
76
+ offset = newline + 1
34
77
  }
35
- if (pending[channel].length > 16384 || flush) { line(pending[channel], channel); pending[channel] = '' }
36
78
  }
37
79
  return {
38
80
  push(value, channel = 'stdout') {
39
81
  if (ended) return
82
+ if (channel !== 'stdout' && channel !== 'stderr') return
40
83
  try { feed(decoders[channel].write(Buffer.isBuffer(value) ? value : Buffer.from(String(value))), channel) } catch { /* invalid console bytes */ }
41
84
  },
42
85
  flush() {
43
86
  if (ended) return
44
87
  ended = true
45
- for (const channel of ['stdout', 'stderr']) feed(decoders[channel].end(), channel, true)
88
+ for (const channel of ['stdout', 'stderr']) {
89
+ feed(decoders[channel].end(), channel)
90
+ if (!discarding[channel]) line(pending[channel], channel)
91
+ pending[channel] = ''; discarding[channel] = false
92
+ }
46
93
  },
47
94
  get lastError() { return lastError },
48
95
  }
@@ -5,6 +5,7 @@ export function backgroundToolNames(cfg = {}) {
5
5
  if (cfg.replyToComments) names.push('bili_received_replies')
6
6
  if (cfg.autoMoment) names.push('bili_moment')
7
7
  if (cfg.autoReplyDm) names.push('bili_dm_inbox', 'bili_dm_thread', 'bili_dm_reply')
8
+ if (['only', 'both'].includes(cfg.dmMode) && cfg.dmUid) names.push('bili_deliver')
8
9
  return names
9
10
  }
10
11
  export function toolInputSchema(parameters = {}) {
@@ -0,0 +1,43 @@
1
+ /** Presentation preferences never change browsing, account permissions or send gates. */
2
+ export const COMPANION_DEFAULTS = Object.freeze({
3
+ idleChatter: true,
4
+ chatterFrequency: 'normal', notificationLevel: 'normal', focusMode: false,
5
+ quietHoursEnabled: false, quietHoursStart: '23:00', quietHoursEnd: '08:00', petReducedMotion: false,
6
+ })
7
+ const choices = { chatterFrequency: ['rare', 'normal', 'lively'], notificationLevel: ['minimal', 'normal', 'all'] }
8
+ const clock = value => typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
9
+ export function clampCompanionSettings(patch = {}) {
10
+ const out = { ...patch }
11
+ for (const [key, list] of Object.entries(choices)) if (Object.hasOwn(out, key) && !list.includes(out[key])) out[key] = COMPANION_DEFAULTS[key]
12
+ for (const key of ['idleChatter', 'focusMode', 'quietHoursEnabled', 'petReducedMotion']) if (Object.hasOwn(out, key) && typeof out[key] !== 'boolean') out[key] = COMPANION_DEFAULTS[key]
13
+ for (const key of ['quietHoursStart', 'quietHoursEnd']) if (Object.hasOwn(out, key) && !clock(out[key])) out[key] = COMPANION_DEFAULTS[key]
14
+ return out
15
+ }
16
+ export function companionPolicy(config = {}, now = new Date()) {
17
+ const cfg = { ...COMPANION_DEFAULTS, ...clampCompanionSettings(config) }
18
+ const minutes = text => Number(text.slice(0, 2)) * 60 + Number(text.slice(3))
19
+ const start = minutes(cfg.quietHoursStart), end = minutes(cfg.quietHoursEnd)
20
+ const at = now.getHours() * 60 + now.getMinutes()
21
+ const quiet = cfg.quietHoursEnabled && start !== end && (start < end ? at >= start && at < end : at >= start || at < end)
22
+ const focus = cfg.focusMode === true
23
+ const ranges = { rare: [240, 360], normal: [132, 210], lively: [45, 90] }
24
+ const range = ranges[cfg.chatterFrequency]
25
+ return {
26
+ quiet, focus, silenced: quiet || focus, reason: focus ? '专注模式' : quiet ? '夜间安静时段' : '',
27
+ chatterAllowed: cfg.idleChatter !== false && !quiet && !focus,
28
+ notificationLevel: cfg.notificationLevel, reducedMotion: cfg.petReducedMotion,
29
+ chatterMinMinutes: range[0], chatterMaxMinutes: range[1],
30
+ label: focus ? '专注陪伴' : quiet ? '夜间安静' : cfg.idleChatter === false ? '闲聊已关闭' : '自然陪伴',
31
+ }
32
+ }
33
+ export function nextChatterAt(config = {}, now = Date.now(), random = Math.random()) {
34
+ const policy = companionPolicy(config, new Date(now))
35
+ const fraction = Number.isFinite(random) ? Math.max(0, Math.min(1, random)) : 0.5
36
+ return now + (policy.chatterMinMinutes + fraction * (policy.chatterMaxMinutes - policy.chatterMinMinutes)) * 60_000
37
+ }
38
+ export function companionPrompt(config = {}, now = new Date()) {
39
+ const policy = companionPolicy(config, now)
40
+ return policy.silenced
41
+ ? `【陪伴节奏】目前是${policy.reason},少闲聊、少追问,完成结果和需要本人决定的事情仍要清楚反馈。运行任务、发送权限与原有安全闸照旧。`
42
+ : '【陪伴节奏】只在有具体发现或需要接话时自然开口,不为显示在线编造观看、发言或心情经历。'
43
+ }
@@ -0,0 +1,20 @@
1
+ /** Model-authored arguments are preferences, never evidence of human authorization. */
2
+ export const MODEL_CONFIG_KEYS = Object.freeze([
3
+ 'persona', 'focus', 'keywords', 'reportLevel', 'perDay', 'momentStyle', 'interactionStyle',
4
+ 'idleChatter', 'chatterFrequency', 'notificationLevel', 'focusMode',
5
+ 'quietHoursEnabled', 'quietHoursStart', 'quietHoursEnd', 'petReducedMotion', 'petShow',
6
+ ])
7
+ const permitted = new Set(MODEL_CONFIG_KEYS)
8
+ export function modelConfigDecision(patch) {
9
+ const keys = Object.keys(patch || {})
10
+ if (keys.includes('sendReview')) return { ok: false, code: 'SEND_REVIEW_MANUAL_ONLY', error: '发送预览开关只能由本人在工作台 → 互动与投递中修改。' }
11
+ if (keys.includes('stickerReview')) return { ok: false, code: 'STICKER_REVIEW_MANUAL_ONLY', error: '表情人工审核开关只能由本人在工作台 → 记忆与素材中修改。' }
12
+ const blockedKeys = keys.filter(key => !permitted.has(key))
13
+ return blockedKeys.length ? { ok: false, code: 'AUTHORIZATION_MANUAL_ONLY', blockedKeys,
14
+ error: '发送授权、收件人、防护、预算、自动任务和执行环境配置只能由本人在工作台修改;模型只能调整内容与陪伴偏好。' } : { ok: true }
15
+ }
16
+ export function modelForceDecision(args) {
17
+ return args && Object.hasOwn(args, 'force') && args.force !== false
18
+ ? { ok: false, blocked: true, code: 'FORCE_NOT_AUTHORIZATION', error: '模型提供的 force 参数不是本人授权,不能绕过发送开关、审核、判重或节奏。请在工作台启用发送预览并由本人逐条批准,或手动调整发送权限。' }
19
+ : null
20
+ }
package/lib/favops.py CHANGED
@@ -8,6 +8,7 @@
8
8
  import asyncio
9
9
  import json
10
10
  import os
11
+ import re
11
12
  import sys
12
13
 
13
14
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -49,6 +50,10 @@ async def folders():
49
50
 
50
51
 
51
52
  async def add(bvid, fid):
53
+ if not isinstance(bvid, str) or not re.fullmatch(r'BV[0-9A-Za-z]{10}', bvid):
54
+ raise ValueError('视频 BV 号格式无效')
55
+ if isinstance(fid, bool) or not isinstance(fid, int) or fid <= 0:
56
+ raise ValueError('收藏夹 ID 必须为正整数')
52
57
  # 2026-10-26:删掉了原来那句 `FavoriteList(aid, credential=cred).get_info()` ——
53
58
  # 它把**视频 aid** 当收藏夹 media_id 传进去,查的是"编号恰好等于这个 aid 的收藏夹",
54
59
  # 几乎必然不存在/报错;结果没被使用,却会把整个 add() 拖崩(真正的收藏动作到不了)。
@@ -73,9 +78,11 @@ def main():
73
78
  if not args:
74
79
  out({'ok': False, 'error': '没有子命令(folders / add)'})
75
80
  try:
76
- if args[0] == 'folders':
81
+ if args[0] == 'folders' and len(args) == 1:
77
82
  asyncio.run(folders())
78
- elif args[0] == 'add' and len(args) >= 3:
83
+ elif args[0] == 'add' and len(args) == 3:
84
+ if not re.fullmatch(r'[0-9]+', args[2]):
85
+ raise ValueError('收藏夹 ID 必须为正整数')
79
86
  asyncio.run(add(args[1], int(args[2])))
80
87
  else:
81
88
  out({'ok': False, 'error': '子命令不认识:' + ' '.join(args)})
package/lib/ids.mjs CHANGED
@@ -33,3 +33,44 @@ export const replyTargetId = (primary, fallback) => {
33
33
  const first = optionalId(primary)
34
34
  return first !== '0' ? first : optionalId(fallback)
35
35
  }
36
+
37
+ /** Parse JSON without rounding decimal integer tokens. Strings/escapes are scanned, never rewritten.
38
+ * Ordinary JSON syntax validation remains JSON.parse's job; invalid leading zeros stay invalid.
39
+ * Safe integers, decimal fractions and exponent notation retain their standard JSON number types.
40
+ */
41
+ export function parseJsonExactIntegers(source) {
42
+ if (typeof source !== 'string') throw new TypeError('JSON source must be a string')
43
+ // Validate original grammar first: quoting an illegal numeric object key must not repair it.
44
+ // Rounded values from this syntax pass are discarded whenever an unsafe token is present.
45
+ const ordinary = JSON.parse(source)
46
+ const parts = []
47
+ let cursor = 0, untouched = 0
48
+ const digit = index => source.charCodeAt(index) >= 48 && source.charCodeAt(index) <= 57
49
+ while (cursor < source.length) {
50
+ if (source[cursor] === '"') {
51
+ cursor++
52
+ while (cursor < source.length) {
53
+ if (source[cursor] === '\\') { cursor += 2; continue }
54
+ if (source[cursor++] === '"') break
55
+ }
56
+ continue
57
+ }
58
+ if (source[cursor] !== '-' && !digit(cursor)) { cursor++; continue }
59
+ const start = cursor
60
+ if (source[cursor] === '-') cursor++
61
+ while (digit(cursor)) cursor++
62
+ let integer = true
63
+ if (source[cursor] === '.') { integer = false; cursor++; while (digit(cursor)) cursor++ }
64
+ if (source[cursor] === 'e' || source[cursor] === 'E') {
65
+ integer = false; cursor++
66
+ if (source[cursor] === '+' || source[cursor] === '-') cursor++
67
+ while (digit(cursor)) cursor++
68
+ }
69
+ const token = source.slice(start, cursor)
70
+ if (integer && /^-?(?:0|[1-9]\d*)$/.test(token) && !Number.isSafeInteger(Number(token))) {
71
+ parts.push(source.slice(untouched, start), JSON.stringify(token))
72
+ untouched = cursor
73
+ }
74
+ }
75
+ return parts.length ? JSON.parse(parts.join('') + source.slice(untouched)) : ordinary
76
+ }
@@ -0,0 +1,34 @@
1
+ /** One explicit login generation owns credential writes; late polls cannot replace a newer account. */
2
+ export function createLoginSession({ start, poll, save, now = Date.now, ttlMs = 180_000 }) {
3
+ let generation = 0, active = null, disposed = false
4
+ const expired = () => ({ state: 'expired', message: '登录请求已失效,请使用最新二维码重新登录。' })
5
+ const current = attempt => !disposed && active === attempt && now() < attempt.expiresAt
6
+ return {
7
+ async start() {
8
+ if (disposed) throw new Error('登录服务已关闭')
9
+ const id = ++generation; active = null
10
+ const result = await start()
11
+ if (disposed || generation !== id) throw new Error('登录请求已被新的二维码替代')
12
+ if (typeof result?.key !== 'string' || !result.key || result.key.length > 512) throw new Error('二维码登录响应缺少有效 key')
13
+ active = Object.freeze({ ...result, generation: id, expiresAt: now() + ttlMs })
14
+ return active
15
+ },
16
+ current,
17
+ async poll(key) {
18
+ const attempt = active
19
+ if (typeof key !== 'string' || !key || key.length > 512 || !attempt || key !== attempt.key || !current(attempt)) return expired()
20
+ const result = await poll(key)
21
+ if (!current(attempt)) return expired()
22
+ if (result?.state === 'ok') {
23
+ const credential = result.credential
24
+ if (!credential || typeof credential.sessdata !== 'string' || !credential.sessdata || typeof credential.bili_jct !== 'string' || !credential.bili_jct) return { state: 'unknown', message: '登录凭证不完整,未替换本机账号。' }
25
+ const file = save(credential)
26
+ active = null
27
+ return { state: 'ok', file }
28
+ }
29
+ if (result?.state === 'expired') active = null
30
+ return { state: result?.state || 'unknown', code: result?.code, message: result?.message || '' }
31
+ },
32
+ dispose() { disposed = true; generation++; active = null },
33
+ }
34
+ }
package/lib/login.mjs CHANGED
@@ -18,6 +18,7 @@ import { spawn } from 'node:child_process'
18
18
  import { existsSync, mkdirSync } from 'node:fs'
19
19
  import { join } from 'node:path'
20
20
  import { atomicWriteFileSync } from './record-storage.mjs'
21
+ import { randomUUID } from 'node:crypto'
21
22
 
22
23
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'
23
24
  const GEN = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate?source=main-fe-header'
@@ -43,7 +44,7 @@ function pickCookies(list) {
43
44
  async function ensureBuvid3(jar) {
44
45
  if (jar.buvid3) return jar
45
46
  try {
46
- const r = await fetch(SPI, { headers: { 'User-Agent': UA } })
47
+ const r = await fetch(SPI, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(15_000) })
47
48
  const j = await r.json()
48
49
  const v = j?.data?.b_3
49
50
  if (v) jar.buvid3 = v
@@ -52,7 +53,7 @@ async function ensureBuvid3(jar) {
52
53
  }
53
54
 
54
55
  export async function loginStart() {
55
- const r = await fetch(GEN, { headers: { 'User-Agent': UA } })
56
+ const r = await fetch(GEN, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(20_000) })
56
57
  const j = await r.json()
57
58
  const data = j?.data
58
59
  if (!data?.qrcode_key || !data?.url) throw new Error(`生成二维码失败:${j?.message || '返回里没有 qrcode_key'}`)
@@ -61,7 +62,7 @@ export async function loginStart() {
61
62
 
62
63
  /** 轮询一次。state: waiting / scanned / ok / expired / unknown */
63
64
  export async function loginPoll(key) {
64
- const res = await fetch(pollUrl(key), { headers: { 'User-Agent': UA }, redirect: 'manual' })
65
+ const res = await fetch(pollUrl(key), { headers: { 'User-Agent': UA }, redirect: 'manual', signal: AbortSignal.timeout(20_000) })
65
66
  const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
66
67
  let body = {}
67
68
  try { body = await res.json() } catch { /* poll 有时返回空体 */ }
@@ -95,7 +96,7 @@ export function saveCredential(stateDir, credential) {
95
96
  * `/login/start` 那个 HTTP 请求就永远挂着(probeCmd 那批同型问题修过,这里漏了)。 */
96
97
  export function renderQrPng(pythonPath, stateDir, url, { timeoutMs = 20_000 } = {}) {
97
98
  return new Promise((resolve) => {
98
- const png = join(stateDir, `qrcode_login.${process.pid}.png`)
99
+ const png = join(stateDir, `qrcode_login.${process.pid}.${randomUUID()}.png`)
99
100
  const code = 'import sys, qrcode; qrcode.make(sys.argv[1]).save(sys.argv[2])'
100
101
  let child
101
102
  let settled = false
package/lib/mcp.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { spawn } from 'node:child_process'
13
13
  import { readFileSync } from 'node:fs'
14
+ import { parseJsonExactIntegers } from './ids.mjs'
14
15
 
15
16
  // 报给对端的身份:版本号**从 package.json 读**,别手写(手写的那份永远追不上发版)
16
17
  const PKG = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) } catch { return {} } })()
@@ -96,7 +97,7 @@ export class McpClient {
96
97
  const result = await this.#request('tools/call', { name, arguments: args }, timeoutMs)
97
98
  const text = (result?.content ?? []).map((c) => c?.text ?? '').join('\n').trim()
98
99
  if (result?.isError) throw new Error(text || `MCP 工具 ${name} 报错`)
99
- try { return text ? JSON.parse(text) : {} } catch { return text }
100
+ try { return text ? parseJsonExactIntegers(text) : {} } catch { return text }
100
101
  }
101
102
 
102
103
  #onData(chunk) {
@@ -107,7 +108,7 @@ export class McpClient {
107
108
  this.buf = this.buf.slice(idx + 1)
108
109
  if (!line) continue
109
110
  let msg
110
- try { msg = JSON.parse(line) } catch { continue } // 非 JSON 行(有些库会往 stdout 打日志)忽略
111
+ try { msg = parseJsonExactIntegers(line) } catch { continue } // Ignore non-JSON log lines; protocol request IDs remain safe integers.
111
112
  if (msg.id !== undefined && this.pending.has(msg.id)) {
112
113
  const p = this.pending.get(msg.id)
113
114
  this.pending.delete(msg.id)
@@ -0,0 +1,74 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { asBook, learnList } from './learn.mjs'
3
+ import { SOUL_SECTIONS, soulSectionBody } from './persona.mjs'
4
+ import { redactRecordText } from './record-privacy.mjs'
5
+
6
+ export const MEMORY_SOURCES = ['all', 'learning', 'soul', 'voice', 'taste', 'seen', 'history']
7
+ export const memoryFingerprint = (value) => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex')
8
+ const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}
9
+ const safe = (value, maxLength = 2000) => redactRecordText(String(value ?? ''), { maxLength })
10
+ const contentLines = (text) => {
11
+ const raw = String(text || '')
12
+ const visible = raw.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\r\n]/g, ' ')).split(/\r?\n/)
13
+ return raw.split(/\r?\n/).map((line, index) => ({ line, text: visible[index] || '', index }))
14
+ .filter((row) => row.text.trim() && !/^\s*#/.test(row.text))
15
+ }
16
+
17
+ /** A bounded read-only projection. It never creates memories or changes their authority. */
18
+ export function memoryCenterSnapshot(input = {}, options = {}) {
19
+ const source = MEMORY_SOURCES.includes(options.source) ? options.source : 'all'
20
+ const filter = String(options.filter || '').trim().slice(0, 160).toLocaleLowerCase()
21
+ const offset = Math.max(0, Math.min(10000, Math.floor(Number(options.offset) || 0)))
22
+ const limit = Math.max(1, Math.min(50, Math.floor(Number(options.limit) || 20)))
23
+ const rows = []
24
+ const add = (row) => {
25
+ const text = safe(row.text)
26
+ if (!text.trim()) return
27
+ rows.push({ ...row, text, from: safe(row.from, 300), label: safe(row.label, 80), at: String(row.at || ''), mutable: Boolean(row.mutable) })
28
+ }
29
+ const learning = []
30
+ for (let page = 0; page < 12; page++) {
31
+ const batch = learnList(asBook(input.learn), { state: 'all', limit: 100, offset: page * 100 })
32
+ learning.push(...batch.items)
33
+ if (!batch.more) break
34
+ }
35
+ for (const row of learning) add({ source: 'learning', id: String(row.id), label: row.label || row.kind || '学习',
36
+ state: row.state, text: row.text, from: row.from, at: row.decidedAt || row.at, example: safe(row.example, 500),
37
+ why: safe(row.why, 500), version: memoryFingerprint(object(asBook(input.learn).items)[row.id] || row), mutable: true,
38
+ actions: row.state === 'approved' ? ['revoke', 'forget'] : ['forget'] })
39
+ for (const section of SOUL_SECTIONS) {
40
+ const body = soulSectionBody(String(input.soulText || ''), section.key)
41
+ for (const row of contentLines(body)) add({ source: 'soul', id: `${section.key}:${row.index}`, section: section.key,
42
+ index: row.index, label: section.title, text: row.text, version: memoryFingerprint(row.line),
43
+ from: 'soul.md · 自己写下的心情', at: input.soulAt, mutable: true, actions: ['forget'] })
44
+ }
45
+ for (const row of contentLines(input.voiceText).slice(-500)) add({ source: 'voice', id: String(row.index), index: row.index,
46
+ label: '语气札记', text: row.text, from: 'voice.md · 自己整理的表达经验', at: input.voiceAt,
47
+ version: memoryFingerprint(row.line), mutable: true, actions: ['forget'] })
48
+ for (const [key, row] of Object.entries(object(input.taste)).slice(-300)) {
49
+ if (!/^\d{1,32}$/.test(key) || !row || typeof row !== 'object' || typeof row.text !== 'string') continue
50
+ add({ source: 'taste', id: key, label: '口味样本', text: row.text, from: row.bvid || '自己发出的评论',
51
+ at: row.at, likes: Number.isFinite(Number(row.likes)) ? Math.max(0, Math.floor(Number(row.likes))) : 0,
52
+ checked: Boolean(row.likesAt), version: memoryFingerprint(row), mutable: true, actions: ['forget'] })
53
+ }
54
+ for (const [key, value] of Object.entries(object(input.seen)).slice(-5000)) {
55
+ if (!/^BV[0-9A-Za-z]{10}$/.test(key)) continue
56
+ const row = typeof value === 'string' ? { note: value } : object(value)
57
+ add({ source: 'seen', id: key, label: '浏览历史', text: [key, row.title, row.note].filter(Boolean).join(' · '),
58
+ from: key, at: row.at, mutable: false, actions: [] })
59
+ }
60
+ for (const event of (Array.isArray(input.events) ? input.events : []).slice(0, 100)) {
61
+ if (!event || !['memory', 'learning'].includes(event.type)) continue
62
+ const data = object(event.data)
63
+ add({ source: 'history', id: String(event.eventId), label: event.type === 'memory' ? '记忆版本' : '审核审计',
64
+ text: event.type === 'memory' ? `${data.name || '记忆'} · ${data.operation || 'snapshot'}\n${data.text || ''}` : JSON.stringify(data),
65
+ from: event.source, at: event.at || event.recordedAt, mutable: false, actions: [] })
66
+ }
67
+ rows.sort((a, b) => String(b.at).localeCompare(String(a.at)) || a.source.localeCompare(b.source) || a.id.localeCompare(b.id))
68
+ const counts = Object.fromEntries(MEMORY_SOURCES.slice(1).map((key) => [key, rows.filter((row) => row.source === key).length]))
69
+ const matched = rows.filter((row) => (source === 'all' || row.source === source)
70
+ && (!filter || [row.text, row.from, row.label, row.example, row.why, row.state].join(' ').toLocaleLowerCase().includes(filter)))
71
+ return { ok: true, source, filter, offset, limit, total: rows.length, matched: matched.length,
72
+ more: matched.length > offset + limit, counts, items: matched.slice(offset, offset + limit),
73
+ scope: 'current-snapshots-and-latest-100-audits', historyReadOnly: true }
74
+ }
package/lib/mode.mjs CHANGED
@@ -6,21 +6,21 @@ const selection = (value, source) => {
6
6
  if (value === undefined) return null
7
7
  if (value === null || value === '') return { id: '', known: true, source }
8
8
  if (typeof value === 'string') return { id: value, known: true, source }
9
- return null
9
+ return { id: '', known: true, source, malformed: true }
10
10
  }
11
11
 
12
12
  export function resolveSessionPreset(ctx, agent) {
13
13
  if (!agent) return { id: '', known: false, source: 'none' }
14
14
  try {
15
15
  const service = ctx?.get?.('agentPresets')
16
- if (typeof service?.composedPreset === 'function') {
16
+ if (agent.ctx && typeof service?.composedPreset === 'function') {
17
17
  const found = selection(service.composedPreset(agent.ctx), 'host')
18
18
  if (found) return found
19
19
  }
20
20
  } catch { /* Older hosts may not expose this service. */ }
21
21
  try {
22
22
  const service = ctx?.get?.('sessionProjections')
23
- if (typeof service?.stateOf === 'function') {
23
+ if (agent.session && typeof service?.stateOf === 'function') {
24
24
  const found = selection(service.stateOf(agent.session, 'agentPreset'), 'projection')
25
25
  if (found) return found
26
26
  }
@@ -38,8 +38,6 @@ export function resolveSessionPreset(ctx, agent) {
38
38
  try {
39
39
  const fromHeader = selection(agent.session?.header?.agentPreset, 'header')
40
40
  if (fromHeader) return fromHeader
41
- const fromAgent = selection(agent.agentPreset, 'agent')
42
- if (fromAgent) return fromAgent
43
41
  } catch { /* Unknown context must not inherit a global mode. */ }
44
42
  return { id: '', known: false, source: 'none' }
45
43
  }
@@ -52,8 +50,9 @@ export function isFeiyuGoal(goal) {
52
50
  export function personaScope(preset, goal) {
53
51
  const selected = preset?.id === DAFEIYU_PRESET_ID
54
52
  const running = isFeiyuGoal(goal)
55
- // Selecting Standard/None is an explicit opt-out, even if an old fish goal survives.
56
- return { enabled: selected || (running && !preset?.known), selected, running }
53
+ // A goal is work, never permission to change a conversation's identity.
54
+ // Unknown, Standard and None all fail closed instead of adopting stale goals.
55
+ return { enabled: selected, selected, running }
57
56
  }
58
57
 
59
58
  /** Defaults describe motives, not mandatory words or a repeated catchphrase recipe. */