dsh-deepmoyu 2.3.0 → 2.4.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.
Files changed (90) hide show
  1. package/AGENTS.md +93 -153
  2. package/CHANGELOG.md +36 -0
  3. package/CONTRIBUTING.md +51 -0
  4. package/CONTRIBUTORS.md +3 -7
  5. package/NOTICE.md +3 -3
  6. package/README.md +143 -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 +1404 -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 +1095 -285
  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/update.mjs +265 -0
  54. package/lib/uvfetch.mjs +165 -153
  55. package/lib/verify.mjs +111 -127
  56. package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
  57. package/package.json +23 -12
  58. package/presets/deepmoyu.patch.yml +1 -1
  59. package/prompts/duty.md +4 -3
  60. package/screenshots.json +5 -0
  61. package/selfcheck.mjs +21 -6
  62. package/vendor/bilibili-mcp/requirements.in +9 -0
  63. package/vendor/bilibili-mcp/requirements.txt +1936 -11
  64. package/vendor/bilibili-mcp/runtime_guard.py +35 -10
  65. package/lib/__pycache__/pv.cpython-310.pyc +0 -0
  66. package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
  67. package/tests/automation-routes.test.mjs +0 -183
  68. package/tests/automation.test.mjs +0 -79
  69. package/tests/background-tools.test.mjs +0 -182
  70. package/tests/background-window.test.mjs +0 -75
  71. package/tests/dup-cross-channel.test.mjs +0 -158
  72. package/tests/manual-browsing.test.mjs +0 -671
  73. package/tests/mcp-restart.test.mjs +0 -131
  74. package/tests/memory-chain.test.mjs +0 -413
  75. package/tests/model-relay.test.mjs +0 -116
  76. package/tests/persona.test.mjs +0 -67
  77. package/tests/pet-run-routes.test.mjs +0 -127
  78. package/tests/pet-run-settings.test.mjs +0 -38
  79. package/tests/pet-session.test.mjs +0 -125
  80. package/tests/pv-stage1.test.mjs +0 -203
  81. package/tests/sticker-routes.test.mjs +0 -141
  82. package/tests/sticker-upload-routes.test.mjs +0 -97
  83. package/tests/sticker-upload.test.mjs +0 -122
  84. package/tests/sticker.test.mjs +0 -169
  85. package/tests/test_guard_cross_channel.py +0 -141
  86. package/tests/test_pv_cover.py +0 -95
  87. package/tests/test_pv_stage1.py +0 -250
  88. package/tests/test_reply_pictures.py +0 -255
  89. package/tests/token-ledger.test.mjs +0 -78
  90. package/tests/verify_pv_sample.py +0 -91
package/lib/reply.mjs CHANGED
@@ -28,7 +28,7 @@
28
28
  * 与 `lib/dup.mjs` 的 `DUP_LEDGER_MAX` 取同一个数:两边写的是**同一个对象**,
29
29
  * 上限不一致会出现"这边刚裁完那边又超"的来回抖动。
30
30
  */
31
- import { replyTargetId } from './ids.mjs'
31
+ import { normalizeId, replyTargetId } from './ids.mjs'
32
32
 
33
33
  export const REPLY_LEDGER_MAX = 2000
34
34
 
@@ -91,8 +91,15 @@ export const replySkipReason = (it, was) => {
91
91
  */
92
92
  export const replyRemember = (ledger, key, rec) => {
93
93
  const next = { ...(ledger && typeof ledger === 'object' && !Array.isArray(ledger) ? ledger : {}) }
94
- const k = String(key ?? '').trim()
95
- if (!k || k === '0') return next
94
+ let k
95
+ try {
96
+ if (typeof key === 'number') k = normalizeId(key, { allowZero: false })
97
+ else if (typeof key === 'string') {
98
+ const match = /^(video:)?([0-9]+)$/.exec(key.trim())
99
+ if (!match) return next
100
+ k = (match[1] || '') + normalizeId(match[2], { allowZero: false })
101
+ } else return next
102
+ } catch { return next }
96
103
  next[k] = rec
97
104
  const keys = Object.keys(next)
98
105
  if (keys.length > REPLY_LEDGER_MAX) {
@@ -2,6 +2,7 @@
2
2
  export const RUN_DEFAULTS = Object.freeze({
3
3
  sessionModelId: '', sessionTokenBudget: 0, sessionMaxRounds: 64, sessionMinutes: 0,
4
4
  backgroundModelId: '', backgroundTokenBudget: 0, backgroundMinutes: 45,
5
+ backgroundWindowStart: '', backgroundWindowEnd: '',
5
6
  })
6
7
  export const RUN_LIMITS = Object.freeze({
7
8
  sessionTokenBudget: [0, 5_000_000], sessionMaxRounds: [1, 1000], sessionMinutes: [0, 1440],
@@ -29,6 +30,11 @@ export function migrateRunSettings(source = {}, legacy = {}) {
29
30
 
30
31
  export function clampRunSettings(patch = {}) {
31
32
  const out = { ...patch }
33
+ for (const key of ['backgroundWindowStart', 'backgroundWindowEnd']) if (own(out, key)) {
34
+ const value = typeof out[key] === 'string' ? out[key].trim() : ''
35
+ const parsed = parseHM(value)
36
+ out[key] = parsed ? `${String(parsed.h).padStart(2, '0')}:${String(parsed.m).padStart(2, '0')}` : ''
37
+ }
32
38
  for (const key of ['sessionModelId', 'backgroundModelId']) if (own(out, key)) out[key] = model(out[key])
33
39
  for (const [key, [lo, hi]] of Object.entries(RUN_LIMITS)) {
34
40
  if (own(out, key)) out[key] = number(out[key], RUN_DEFAULTS[key], lo, hi)
@@ -7,7 +7,7 @@ import { withFileLockSync, readJsonFileSync, atomicWriteFileSync } from './recor
7
7
 
8
8
  export const STICKER_DEFAULTS = Object.freeze({
9
9
  stickerAutoCollect: false, stickerCollectProbability: 35, stickerCollectCooldown: 60,
10
- stickerAutoSend: false, stickerSendProbability: 30, stickerSendCooldown: 180, stickerCapacity: 60,
10
+ stickerAutoSend: false, stickerSendProbability: 30, stickerSendCooldown: 180, stickerCapacity: 60, stickerReview: false,
11
11
  })
12
12
  export const STICKER_LIMITS = Object.freeze({
13
13
  stickerCollectProbability: [0, 100], stickerCollectCooldown: [0, 86400],
@@ -15,7 +15,7 @@ export const STICKER_LIMITS = Object.freeze({
15
15
  })
16
16
  export function clampStickerSettings(patch = {}) {
17
17
  const out = { ...patch }
18
- for (const key of ['stickerAutoCollect', 'stickerAutoSend']) if (key in out) out[key] = out[key] === true
18
+ for (const key of ['stickerAutoCollect', 'stickerAutoSend', 'stickerReview']) if (key in out) out[key] = out[key] === true
19
19
  for (const [key, [lo, hi]] of Object.entries(STICKER_LIMITS)) if (key in out) {
20
20
  const n = Number(out[key])
21
21
  out[key] = Math.max(lo, Math.min(hi, Math.trunc(Number.isFinite(n) ? n : STICKER_DEFAULTS[key])))
@@ -97,10 +97,11 @@ export function createStickerAutomation({ stateDir, getConfig, now = Date.now, r
97
97
  try {
98
98
  controller.signal.throwIfAborted()
99
99
  const result = await collect(stateDir, { ...candidate, capacity: cfg.stickerCapacity, autoName: true, signal: controller.signal,
100
+ get stickerReview() { return config().stickerReview },
100
101
  shouldCommit: () => !disposed && !sourceSignal?.aborted && config().stickerAutoCollect && config().stickerCollectProbability > 0 })
101
102
  if (result.ok && !controller.signal.aborted) {
102
103
  mutate(saved => { saved.lastCollectedAt = now(); return { write: true } })
103
- emit('collected', { name: result.name, bvid: candidate.bvid })
104
+ emit('collected', { name: result.name, bvid: candidate.bvid, status: result.status })
104
105
  } else if (!result.duplicate) emit('collect-skipped', { error: String(result.error || '').slice(0, 160) })
105
106
  } catch (error) { if (!disposed && !controller.signal.aborted) emit('collect-skipped', { error: String(error?.message || error).slice(0, 160) }) }
106
107
  finally { sourceSignal?.removeEventListener('abort', abort); controllers.delete(controller) }
@@ -132,7 +133,7 @@ export function createStickerAutomation({ stateDir, getConfig, now = Date.now, r
132
133
  const cfg = config(), at = now()
133
134
  if (disposed || !cfg.stickerAutoSend || !chance(cfg.stickerSendProbability)) return null
134
135
  try {
135
- const all = list(stateDir)
136
+ const all = list(stateDir, { status: 'approved' }).filter(item => item && (item.status === undefined || item.status === 'approved'))
136
137
  if (!all.length) return null
137
138
  let choice
138
139
  const token = randomUUID()
package/lib/sticker.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Local sticker library: read-only builtins and bounded, provenance-preserving collected images. */
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, writeFileSync, openSync, closeSync, readSync } from 'node:fs'
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, writeFileSync, openSync, closeSync, readSync, mkdtempSync, rmSync } from 'node:fs'
3
3
  import { join, dirname, resolve, relative, isAbsolute, basename } from 'node:path'
4
+ import { tmpdir } from 'node:os'
4
5
  import { fileURLToPath } from 'node:url'
5
6
  import { createHash } from 'node:crypto'
6
7
  import { withFileLockSync, atomicWriteFileSync, readJsonFileSync } from './record-storage.mjs'
@@ -44,6 +45,8 @@ export const stickerTags = value => (Array.isArray(value) ? value : String(value
44
45
  const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
45
46
  const withinDir = (path, dir) => { const rel = relative(dir, path); return Boolean(rel) && !isAbsolute(rel) && rel !== '..' && !rel.startsWith('..\\') && !rel.startsWith('../') }
46
47
  const validFileName = name => typeof name === 'string' && name === basename(name) && !/[\\/\x00-\x1f:]/.test(name) && !/^\./.test(name)
48
+ const nameKey = name => process.platform === 'win32' ? name.toLowerCase() : name
49
+ const pathKey = file => nameKey(resolve(file))
47
50
 
48
51
  function readState(stateDir) {
49
52
  let state
@@ -80,57 +83,123 @@ function safeLibraryFile(dir, fileName) {
80
83
  } catch { return null }
81
84
  }
82
85
 
83
- function listFromBook(stateDir, book, { source, shadow = true } = {}) {
84
- const out = [], seen = new Set()
85
- if (source !== 'builtin') for (const [name, meta] of Object.entries(book).slice(0, STICKER_MAX * 4)) {
86
+ // Entries installed before review existed remain usable. Unknown explicit states fail closed.
87
+ const statusOf = meta => !Object.hasOwn(meta, 'status') || meta.status === 'approved' ? 'approved' : 'pending'
88
+
89
+ function listFromBook(stateDir, book, { source, shadow = true, status = 'approved' } = {}) {
90
+ if (!['approved', 'pending', 'all'].includes(status)) return []
91
+ const out = [], seen = new Set(), pendingNames = new Set(), pendingFiles = new Set()
92
+ const entries = Object.entries(book)
93
+ // A pending alias must never expose the same path through an approved alias or builtin fallback.
94
+ for (const [name, meta] of entries) if (meta && typeof meta === 'object' && statusOf(meta) === 'pending') {
95
+ pendingNames.add(nameKey(name))
96
+ if (validFileName(meta.file)) pendingFiles.add(pathKey(resolve(collectedDirOf(stateDir), meta.file)))
97
+ }
98
+ if (source !== 'builtin') for (const [name, meta] of entries.slice(0, STICKER_MAX * 4)) {
86
99
  if (!meta || typeof meta !== 'object' || !safeName(name) || safeName(name) !== name) continue
87
100
  const info = safeLibraryFile(collectedDirOf(stateDir), meta.file)
88
101
  if (!info) continue
89
102
  seen.add(name)
90
- out.push({ name, source: 'collected', ...info, addedAt: textOf(meta.addedAt, 40), from: textOf(meta.from, 2048),
103
+ const itemStatus = statusOf(meta)
104
+ if (status !== 'all' && status !== itemStatus) continue
105
+ out.push({ name, source: 'collected', status: itemStatus, ...info, addedAt: textOf(meta.addedAt, 40), from: textOf(meta.from, 2048),
91
106
  sourceUrl: textOf(meta.sourceUrl || (/^https?:\/\//i.test(meta.from || '') ? meta.from : ''), 2048),
92
- bvid: textOf(meta.bvid, 32), rpid: textOf(meta.rpid, 32), description: textOf(meta.description), tags: stickerTags(meta.tags), hash: textOf(meta.hash, 64) })
107
+ bvid: textOf(meta.bvid, 32), rpid: textOf(meta.rpid, 32), description: textOf(meta.description), tags: stickerTags(meta.tags), hash: textOf(meta.hash, 64),
108
+ reviewedAt: textOf(meta.reviewedAt, 40) })
93
109
  }
94
- if (source !== 'collected') {
95
- let entries = []
96
- try { entries = readdirSync(BUILTIN_DIR).slice(0, 500) } catch { /* no builtins */ }
97
- for (const entry of entries) {
110
+ if (source !== 'collected' && status !== 'pending') {
111
+ let builtins = []
112
+ try { builtins = readdirSync(BUILTIN_DIR).slice(0, 500) } catch { /* no builtins */ }
113
+ for (const entry of builtins) {
98
114
  const info = safeLibraryFile(BUILTIN_DIR, entry)
99
115
  const name = entry.replace(/\.[^.]+$/, '')
100
116
  if (!info || !safeName(name) || (shadow && seen.has(name))) continue
101
- out.push({ name, source: 'builtin', ...info, addedAt: '', from: '使用者的表情文件夹', sourceUrl: '', bvid: '', rpid: '', description: '', tags: [], hash: '' })
117
+ out.push({ name, source: 'builtin', status: 'approved', ...info, addedAt: '', from: '使用者的表情文件夹', sourceUrl: '', bvid: '', rpid: '', description: '', tags: [], hash: '', reviewedAt: '' })
102
118
  }
103
119
  }
104
- return out
120
+ return status === 'approved' ? out.filter(item => !pendingNames.has(nameKey(item.name)) && !pendingFiles.has(pathKey(item.file))) : out
105
121
  }
122
+
123
+ /** Sendable by default. Management/preview callers must explicitly request status:'pending' or 'all'. */
106
124
  export function listStickers(stateDir, options = {}) { return listFromBook(stateDir, readStickerBook(stateDir), options) }
107
125
 
108
- /** Explicit paths qualify only if they are already enumerated; never resolve an arbitrary in-directory path. */
126
+ /** Explicit paths qualify only if already approved and enumerated. Listing options cannot bypass review. */
109
127
  export function resolveSticker(stateDir, input, options = {}) {
110
128
  const raw = String(input || '').trim()
111
129
  if (!raw) return ''
112
- const all = listStickers(stateDir, options)
130
+ const all = listStickers(stateDir, { ...options, status: 'approved' })
113
131
  const found = all.find(item => item.name === raw || item.file === raw)
114
132
  || (!/[\\/]/.test(raw) ? all.find(item => safeName(raw) === item.name) : null)
115
133
  return found?.file || ''
116
134
  }
117
135
 
118
- export function readStickerImage(stateDir, name, source) {
136
+ /** Manual gallery previews may opt into pending; sending must always use resolveSticker. */
137
+ export function readStickerImage(stateDir, name, source, options = {}) {
119
138
  if (source && !['builtin', 'collected'].includes(source)) return null
120
- const item = listStickers(stateDir, { source }).find(item => item.name === String(name || ''))
139
+ const item = listStickers(stateDir, { ...options, source }).find(item => item.name === String(name || ''))
121
140
  if (!item) return null
122
141
  const bytes = readFileSync(item.file)
123
142
  if (bytes.length > STICKER_BYTES_MAX || !sniffImageFormat(bytes)) return null
124
143
  return { ...item, bytes, etag: '"' + sha256(bytes) + '"' }
125
144
  }
126
145
 
127
- /** Both remote collection and uploads commit under the same state transaction. */
128
- function commitStickerBytes(stateDir, bytes, options, { clean, capacity, fmt, sourceUrl = '', from = '', allowCopy = false, autoName = false }) {
146
+ /**
147
+ * Bind a send to the exact approved bytes, not to a reusable library path.
148
+ * The returned temp file is private and removed by cleanup(); a forget + same-name
149
+ * pending upload during the send can no longer swap the image under the MCP call.
150
+ */
151
+ export function snapshotStickerForSend(stateDir, input) {
152
+ const raw = String(input || '').trim()
153
+ if (!raw) return null
154
+ try {
155
+ return withFileLockSync(join(stateDir, 'state.json'), () => {
156
+ const book = readStickerBook(stateDir)
157
+ const entries = Object.entries(book)
158
+ let found = entries.find(([name, meta]) => meta && typeof meta === 'object' && statusOf(meta) === 'approved'
159
+ && (name === raw || (validFileName(meta.file) && resolve(collectedDirOf(stateDir), meta.file) === raw)))
160
+ if (!found && !/[\\/]/.test(raw)) {
161
+ const clean = safeName(raw)
162
+ found = entries.find(([name, meta]) => name === clean && meta && typeof meta === 'object' && statusOf(meta) === 'approved')
163
+ }
164
+ if (found) {
165
+ const [name, meta] = found
166
+ const info = safeLibraryFile(collectedDirOf(stateDir), meta.file)
167
+ if (!info) return null
168
+ const bytes = readFileSync(info.file)
169
+ const hash = sha256(bytes)
170
+ // A stale ledger hash fails closed; never send bytes that changed after review.
171
+ if (meta.hash && meta.hash !== hash) return null
172
+ return { name, source: 'collected', bytes: bytes.length, hash, ...makeSendSnapshot(bytes, info.fmt) }
173
+ }
174
+ // Builtins are read-only and always approved.
175
+ const clean = safeName(raw)
176
+ if (!clean) return null
177
+ let builtinName = ''
178
+ try {
179
+ builtinName = readdirSync(BUILTIN_DIR, { withFileTypes: true })
180
+ .find(entry => entry.isFile() && entry.name.replace(/\.[^.]+$/, '') === clean)?.name || ''
181
+ } catch { return null }
182
+ const info = safeLibraryFile(BUILTIN_DIR, builtinName)
183
+ if (!info) return null
184
+ const bytes = readFileSync(info.file)
185
+ return { name: clean, source: 'builtin', bytes: bytes.length, hash: '', ...makeSendSnapshot(bytes, info.fmt) }
186
+ })
187
+ } catch { return null }
188
+ }
189
+
190
+ function makeSendSnapshot(bytes, fmt) {
191
+ const dir = mkdtempSync(join(tmpdir(), 'deepmoyu-sticker-'))
192
+ const file = join(dir, 'image.' + (fmt || sniffImageFormat(bytes) || 'png'))
193
+ writeFileSync(file, bytes, { flag: 'wx', mode: 0o600 })
194
+ return { file, cleanup() { try { rmSync(dir, { recursive: true, force: true }) } catch { /* best effort */ } } }
195
+ }
196
+
197
+ /** Both remote collection and uploads commit under the same state transaction. */function commitStickerBytes(stateDir, bytes, options, { clean, capacity, fmt, sourceUrl = '', from = '', allowCopy = false, autoName = false }) {
129
198
  const hash = sha256(bytes), dir = collectedDirOf(stateDir), stateFile = join(stateDir, 'state.json')
130
199
  return withFileLockSync(stateFile, () => {
131
200
  options.signal?.throwIfAborted()
132
201
  if (options.shouldCommit && !options.shouldCommit()) return { ok: false, code: 'STICKER_CANCELED', error: '自动收集已关闭或停止' }
133
- const state = readState(stateDir), book = { ...bookOf(state) }, all = listFromBook(stateDir, book, { shadow: false })
202
+ const state = readState(stateDir), book = { ...bookOf(state) }, all = listFromBook(stateDir, book, { shadow: false, status: 'all' })
134
203
  const prior = all.find(item => (item.hash || sha256(readFileSync(item.file))) === hash)
135
204
  if (prior && !allowCopy) return { ok: false, code: 'STICKER_DUPLICATE', duplicate: true, name: prior.name, error: `这张图已经在库里了(${prior.name})` }
136
205
  if (all.filter(item => item.source === 'collected').length >= capacity || Object.keys(book).length >= STICKER_MAX) {
@@ -140,6 +209,8 @@ function commitStickerBytes(stateDir, bytes, options, { clean, capacity, fmt, so
140
209
  const base = clean
141
210
  for (let n = 2; all.some(item => item.name === clean) || Object.hasOwn(book, clean); n++) clean = base.slice(0, NAME_MAX - String(n).length - 1) + '-' + n
142
211
  } else if (all.some(item => item.name === clean) || Object.hasOwn(book, clean)) return { ok: false, code: 'STICKER_NAME_EXISTS', error: `「${clean}」已经在库里了,想换图先 forget(内置区只读)` }
212
+ // Read the gate inside the transaction; automation may expose a getter for live config.
213
+ const status = options.stickerReview === true ? 'pending' : 'approved'
143
214
  mkdirSync(dir, { recursive: true })
144
215
  if (lstatSync(dir).isSymbolicLink()) throw new Error('收集目录不能是符号链接')
145
216
  const fileName = `${clean}.${fmt}`, file = join(dir, fileName)
@@ -147,6 +218,7 @@ function commitStickerBytes(stateDir, bytes, options, { clean, capacity, fmt, so
147
218
  writeFileSync(file, bytes, { flag: 'wx', mode: 0o600 })
148
219
  const meta = { file: fileName, from: textOf(options.from || from, 2048), sourceUrl: textOf(sourceUrl, 2048),
149
220
  addedAt: new Date().toISOString(), bytes: bytes.length, mime: STICKER_MIME[fmt], hash,
221
+ status,
150
222
  bvid: textOf(options.bvid, 32), rpid: textOf(options.rpid, 32), description: textOf(options.description), tags: stickerTags(options.tags), automatic: Boolean(options.automatic) }
151
223
  book[clean] = meta; state.stickers = book
152
224
  try { atomicWriteFileSync(stateFile, JSON.stringify(state, null, 2)) } catch (error) { try { unlinkSync(file) } catch { /* original error */ }; throw error }
@@ -161,10 +233,10 @@ export async function collectSticker(stateDir, options = {}) {
161
233
  if (!clean || /[\\/]/.test(String(name || ''))) return { ok: false, error: 'name 必须是普通文件名字符(别带路径、扩展名或怪符号)' }
162
234
  const capacity = Math.max(1, Math.min(STICKER_MAX, Math.trunc(Number(options.capacity) || STICKER_MAX)))
163
235
  try {
164
- const initial = listStickers(stateDir, { shadow: false })
236
+ const initial = listStickers(stateDir, { shadow: false, status: 'all' })
165
237
  if (!autoName && initial.some(item => item.name === clean)) return { ok: false, error: `「${clean}」已经在库里了,想换图先 forget(内置区只读)` }
166
238
  if (initial.filter(item => item.source === 'collected').length >= capacity) return { ok: false, error: `收集区满了(上限 ${capacity} 张)——先 forget 一张再收` }
167
- let bytes, sourceUrl = '', from = ''
239
+ let bytes, sourceUrl = '', from = '', allowCopy = false
168
240
  if (url) {
169
241
  // URL equality avoids a repeat download; content equality below covers changed URLs.
170
242
  const prior = initial.find(item => item.sourceUrl === String(url))
@@ -174,13 +246,13 @@ export async function collectSticker(stateDir, options = {}) {
174
246
  } else if (path) {
175
247
  const file = resolveSticker(stateDir, path)
176
248
  if (!file) return { ok: false, error: 'path 必须是库内已有的图(内置区或收集区),不接受任意路径' }
177
- bytes = readFileSync(file); from = 'library copy'
249
+ bytes = readFileSync(file); from = 'library copy'; allowCopy = true
178
250
  } else return { ok: false, error: 'collect 要给 url 或 path 之一' }
179
251
  signal?.throwIfAborted()
180
252
  if (bytes.length > STICKER_BYTES_MAX) return { ok: false, error: '图片超过 5MB,太大了' }
181
253
  const fmt = sniffImageFormat(bytes)
182
254
  if (!fmt) return { ok: false, error: '内容不是认识的图片格式(jpg/png/gif/webp)' }
183
- return commitStickerBytes(stateDir, bytes, options, { clean, capacity, fmt, sourceUrl, from, allowCopy: Boolean(path), autoName })
255
+ return commitStickerBytes(stateDir, bytes, options, { clean, capacity, fmt, sourceUrl, from, allowCopy, autoName })
184
256
  } catch (error) { return { ok: false, error: String(error?.message || error) } }
185
257
  }
186
258
 
@@ -203,12 +275,50 @@ export function uploadSticker(stateDir, options = {}) {
203
275
  try { validateStickerImage(bytes, fmt) } catch (error) { return failure('STICKER_IMAGE_INVALID', `无法识别或图片结构损坏:${error.message}`) }
204
276
  const capacity = Math.max(1, Math.min(STICKER_MAX, Math.trunc(Number(options.capacity) || STICKER_MAX)))
205
277
  try {
206
- // Only metadata fields are forwarded. Client-supplied path/url/from/automatic cannot affect storage.
207
- return commitStickerBytes(stateDir, bytes, { description, tags }, { clean: safeName(name), capacity, fmt,
278
+ // Trusted callers supply stickerReview from config, never from an unfiltered upload body.
279
+ // Client-supplied path/url/from/automatic cannot affect storage.
280
+ return commitStickerBytes(stateDir, bytes, { description, tags, get stickerReview() { return options.stickerReview === true } }, { clean: safeName(name), capacity, fmt,
208
281
  from: filename ? `local upload: ${textOf(filename, 255)}` : 'local upload' })
209
282
  } catch (error) { return failure('STICKER_STORAGE', String(error?.message || error)) }
210
283
  }
211
284
 
285
+ /** Manual-only review. Re-read the entry under the shared state lock; optional hash binds the UI decision. */
286
+ function decideSticker(stateDir, name, approved, options = {}) {
287
+ const failure = (code, error) => ({ ok: false, code, error })
288
+ const clean = safeName(name)
289
+ if (typeof name !== 'string' || !clean || /[\\/]/.test(name)) return failure('STICKER_INPUT', 'name 必须是普通文件名字符')
290
+ if (!options || typeof options !== 'object' || Array.isArray(options)) return failure('STICKER_INPUT', '审核选项必须是对象')
291
+ const { expectedHash } = options
292
+ if (expectedHash !== undefined && (typeof expectedHash !== 'string' || !/^[a-f0-9]{64}$/.test(expectedHash))) return failure('STICKER_INPUT', 'expectedHash 必须是图片的 SHA-256')
293
+ try {
294
+ return withFileLockSync(join(stateDir, 'state.json'), () => {
295
+ const state = readState(stateDir), book = { ...bookOf(state) }, meta = Object.hasOwn(book, clean) ? book[clean] : null
296
+ if (!meta || typeof meta !== 'object') return failure('STICKER_NOT_FOUND', `「${clean}」不在收集区(内置区只读)`)
297
+ if (statusOf(meta) !== 'pending') return failure('STICKER_NOT_PENDING', `「${clean}」不在待审核队列`)
298
+ const info = safeLibraryFile(collectedDirOf(stateDir), meta.file)
299
+ if (!info) return failure('STICKER_IMAGE_INVALID', '待审核图片不存在或不是收集区的有效图片')
300
+ const hash = sha256(readFileSync(info.file))
301
+ if ((meta.hash && meta.hash !== hash) || (expectedHash !== undefined && expectedHash !== hash)) return failure('STICKER_REVIEW_CHANGED', '待审核图片已变化,请重新查看后再决定')
302
+ if (approved) {
303
+ book[clean] = { ...meta, hash, status: 'approved', reviewedAt: new Date().toISOString() }
304
+ } else {
305
+ // A malformed alias must not delete a file still referenced by another entry.
306
+ if (!Object.entries(book).some(([key, value]) => key !== clean && validFileName(value?.file)
307
+ && pathKey(resolve(collectedDirOf(stateDir), value.file)) === pathKey(info.file))) unlinkSync(info.file)
308
+ delete book[clean]
309
+ }
310
+ state.stickers = book
311
+ atomicWriteFileSync(join(stateDir, 'state.json'), JSON.stringify(state, null, 2))
312
+ return approved ? { ok: true, approved: clean, status: 'approved', hash, reviewedAt: book[clean].reviewedAt }
313
+ : { ok: true, rejected: clean, hash }
314
+ })
315
+ } catch (error) { return failure('STICKER_STORAGE', String(error?.message || error)) }
316
+ }
317
+
318
+ /** Call from manual settings/API actions, never expose approval to automatic browsing tools. */
319
+ export function approveSticker(stateDir, name, options = {}) { return decideSticker(stateDir, name, true, options) }
320
+ export function rejectSticker(stateDir, name, options = {}) { return decideSticker(stateDir, name, false, options) }
321
+
212
322
  /** Only the collected area's validated regular file may be removed; malformed ledgers fail closed. */
213
323
  export function forgetSticker(stateDir, name) {
214
324
  const clean = safeName(name)
@@ -0,0 +1,65 @@
1
+ /** Bind role tools only to real agent.ctx registries. No global-schema fallback. */
2
+ export function createToolScopeRegistry({ ctx, enabled, log = () => {} }) {
3
+ const definitions = new Map(), mounted = new Map()
4
+ const invalidContexts = new WeakSet()
5
+ let lastError = ''
6
+ const scopedRegistry = agent => {
7
+ try {
8
+ const receiver = agent?.ctx, registry = receiver?.tools
9
+ // A distinct ctx object does not prove isolation when an old adapter aliases the root service.
10
+ return receiver && receiver !== ctx && registry && registry !== ctx.tools && !invalidContexts.has(receiver)
11
+ && typeof registry.register === 'function' ? registry : null
12
+ } catch { return null }
13
+ }
14
+ const dispose = value => { try { if (typeof value === 'function') value(); else value?.dispose?.() } catch (error) { log(String(error?.message ?? error)) } }
15
+ const detach = agent => {
16
+ const record = mounted.get(agent)
17
+ if (!record) return
18
+ for (const value of record.values()) dispose(value)
19
+ mounted.delete(agent)
20
+ }
21
+ const refresh = agent => {
22
+ if (!agent || !enabled(agent)) { detach(agent); return false }
23
+ // Cordis resolves this service through the receiver ctx. Using ctx.tools at
24
+ // plugin scope would register every schema in every normal conversation.
25
+ const registry = scopedRegistry(agent)
26
+ if (!registry) {
27
+ lastError = '当前 DSH 未提供可确认的 agent.ctx.tools 会话作用域;已禁用对话内鲸鱼工具,未回退为全局工具。'
28
+ detach(agent); return false
29
+ }
30
+ let record = mounted.get(agent)
31
+ if (!record) { record = new Map(); mounted.set(agent, record) }
32
+ for (const [name, definition] of definitions) {
33
+ if (record.has(name)) continue
34
+ try {
35
+ const before = typeof ctx.tools?.get === 'function' ? ctx.tools.get(name) : undefined
36
+ const off = registry.register(definition)
37
+ // Old/adapted receivers must not silently route agent.ctx calls to the
38
+ // root layer. The installed DSH exposes get(name, undefined) for this
39
+ // exact global view; preserve any pre-existing foreign registration.
40
+ if (typeof ctx.tools?.get === 'function' && ctx.tools.get(name) !== before) {
41
+ dispose(off); invalidContexts.add(agent.ctx); detach(agent)
42
+ throw new Error('宿主会话注册落入全局工具层,已撤回且禁用;请更新 DSH。')
43
+ }
44
+ record.set(name, off)
45
+ }
46
+ catch (error) { lastError = String(error?.message ?? error); log('鲸鱼工具会话挂载失败:' + lastError); if (invalidContexts.has(agent.ctx)) return false }
47
+ }
48
+ if (record.size === definitions.size) lastError = ''
49
+ return record.size === definitions.size
50
+ }
51
+ const agents = () => { try { return ctx.get('agents')?.list?.() || [] } catch { return [] } }
52
+ const refreshAll = () => { for (const agent of agents()) refresh(agent) }
53
+ return {
54
+ register(definition) { definitions.set(definition.name, definition); refreshAll(); return definition },
55
+ refresh, detach, refreshAll,
56
+ snapshot() {
57
+ const live = agents()
58
+ const supported = live.some(agent => Boolean(scopedRegistry(agent)))
59
+ return { mode: 'session-scoped', supported, checked: live.length > 0, mountedSessions: mounted.size,
60
+ toolCount: definitions.size, globalFallback: false,
61
+ error: lastError || (live.length && !supported ? '当前 DSH 未提供 agent.ctx.tools;鲸鱼工具保持禁用,不会注入普通会话。' : '') }
62
+ },
63
+ dispose() { for (const agent of [...mounted.keys()]) detach(agent) },
64
+ }
65
+ }