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.
- package/AGENTS.md +91 -153
- package/CHANGELOG.md +27 -0
- package/CONTRIBUTING.md +51 -0
- package/CONTRIBUTORS.md +3 -7
- package/NOTICE.md +3 -3
- package/README.md +141 -81
- package/SECURITY.md +21 -0
- package/assets/screenshots/memory.png +0 -0
- package/assets/screenshots/menu.png +0 -0
- package/assets/screenshots/workspace.png +0 -0
- package/client.js +1348 -441
- package/cordis.patch.yml +1 -1
- package/docs/ASSETS.md +25 -0
- package/docs/DEPLOYMENT.md +102 -0
- package/docs/PRIVACY.md +32 -0
- package/docs/RELEASE_AUDIT.md +70 -0
- package/docs/RELEASING.md +94 -0
- package/docs/TEST_MATRIX.md +77 -0
- package/docs/assets-permissions.json +20 -0
- package/docs/tool-evidence.json +196 -0
- package/index.js +953 -270
- package/lib/asr.py +41 -35
- package/lib/background-output.mjs +64 -17
- package/lib/background-tools.mjs +1 -0
- package/lib/companion.mjs +43 -0
- package/lib/config-policy.mjs +20 -0
- package/lib/favops.py +9 -2
- package/lib/ids.mjs +41 -0
- package/lib/login-session.mjs +34 -0
- package/lib/login.mjs +5 -4
- package/lib/mcp.mjs +3 -2
- package/lib/memory-center.mjs +74 -0
- package/lib/mode.mjs +6 -7
- package/lib/model-relay.mjs +5 -4
- package/lib/open-directory.mjs +93 -0
- package/lib/pace.mjs +92 -12
- package/lib/persona.mjs +46 -0
- package/lib/product-status.mjs +123 -0
- package/lib/prompts.mjs +40 -5
- package/lib/pv-production.mjs +280 -0
- package/lib/pv-session.mjs +316 -0
- package/lib/pv.mjs +398 -306
- package/lib/pv.py +84 -22
- package/lib/pv_cover.py +108 -26
- package/lib/pv_editorial.py +336 -0
- package/lib/pv_verify.py +267 -0
- package/lib/python-env.mjs +61 -0
- package/lib/reply.mjs +10 -3
- package/lib/run-settings.mjs +6 -0
- package/lib/sticker-automation.mjs +5 -4
- package/lib/sticker.mjs +135 -25
- package/lib/tool-scope.mjs +65 -0
- package/lib/uvfetch.mjs +165 -153
- package/lib/verify.mjs +111 -127
- package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
- package/package.json +23 -12
- package/presets/deepmoyu.patch.yml +1 -1
- package/prompts/duty.md +4 -3
- package/screenshots.json +5 -0
- package/selfcheck.mjs +21 -6
- package/vendor/bilibili-mcp/requirements.in +9 -0
- package/vendor/bilibili-mcp/requirements.txt +1936 -11
- package/vendor/bilibili-mcp/runtime_guard.py +35 -10
- package/lib/__pycache__/pv.cpython-310.pyc +0 -0
- package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
- package/tests/automation-routes.test.mjs +0 -183
- package/tests/automation.test.mjs +0 -79
- package/tests/background-tools.test.mjs +0 -182
- package/tests/background-window.test.mjs +0 -75
- package/tests/dup-cross-channel.test.mjs +0 -158
- package/tests/manual-browsing.test.mjs +0 -671
- package/tests/mcp-restart.test.mjs +0 -131
- package/tests/memory-chain.test.mjs +0 -413
- package/tests/model-relay.test.mjs +0 -116
- package/tests/persona.test.mjs +0 -67
- package/tests/pet-run-routes.test.mjs +0 -127
- package/tests/pet-run-settings.test.mjs +0 -38
- package/tests/pet-session.test.mjs +0 -125
- package/tests/pv-stage1.test.mjs +0 -203
- package/tests/sticker-routes.test.mjs +0 -141
- package/tests/sticker-upload-routes.test.mjs +0 -97
- package/tests/sticker-upload.test.mjs +0 -122
- package/tests/sticker.test.mjs +0 -169
- package/tests/test_guard_cross_channel.py +0 -141
- package/tests/test_pv_cover.py +0 -95
- package/tests/test_pv_stage1.py +0 -250
- package/tests/test_reply_pictures.py +0 -255
- package/tests/token-ledger.test.mjs +0 -78
- 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
|
-
|
|
95
|
-
|
|
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) {
|
package/lib/run-settings.mjs
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
96
|
-
try {
|
|
97
|
-
for (const entry of
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
128
|
-
|
|
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
|
|
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
|
-
//
|
|
207
|
-
|
|
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
|
+
}
|