dsh-vision-router 1.7.3 → 1.7.4
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/entry.js +18 -8
- package/lib/adversarial-hardening.js +36 -5
- package/lib/artifact-boundary.js +45 -6
- package/lib/artifact-retention.js +124 -0
- package/lib/live-model-client-prelude.js +7 -1
- package/lib/repetition-guard.js +57 -36
- package/lib/runtime-reliability.js +261 -0
- package/lib/settings-client-rc8-lifecycle.js +383 -0
- package/lib/turn-budget-context.js +36 -2
- package/lib/vision-tool-runtime-boundary.js +449 -0
- package/package.json +2 -2
package/entry.js
CHANGED
|
@@ -25,16 +25,18 @@ import { contextWithCoalescedAdapterUpdates } from './lib/adapter-update-coalesc
|
|
|
25
25
|
import { installTesseractExecFileCompat } from './lib/tesseract-exec-compat.js'
|
|
26
26
|
import { installLocalMutationRouteBoundary } from './lib/web-capability-boundary.js'
|
|
27
27
|
import { installScreenshotSourceBoundary } from './lib/screenshot-source-boundary.js'
|
|
28
|
+
import { installVisionToolRuntimeBoundary } from './lib/vision-tool-runtime-boundary.js'
|
|
28
29
|
import { installVisionRouterRemoteSettingsBridge } from './lib/remote-settings-bridge.js'
|
|
30
|
+
import { installSettingsRc8ClientLifecycle } from './lib/settings-client-rc8-lifecycle.js'
|
|
29
31
|
import {
|
|
30
32
|
installStructuredFlowHardening,
|
|
31
33
|
normalizeGuidanceOverrides,
|
|
32
34
|
} from './lib/structured-flow-hardening.js'
|
|
33
35
|
import {
|
|
34
36
|
attachmentContextForContract,
|
|
35
|
-
ensureVisionAttachmentAdmissionPolicy,
|
|
36
37
|
hasBatchAttachmentContract,
|
|
37
38
|
installHostSettingsCompatibility,
|
|
39
|
+
installVisionAttachmentAdmissionPolicy,
|
|
38
40
|
protectHostProviderOwnership,
|
|
39
41
|
} from './lib/dsh-contract-compat.js'
|
|
40
42
|
|
|
@@ -84,6 +86,7 @@ export {
|
|
|
84
86
|
ensureVisionAttachmentAdmissionPolicy,
|
|
85
87
|
hasBatchAttachmentContract,
|
|
86
88
|
installHostSettingsCompatibility,
|
|
89
|
+
installVisionAttachmentAdmissionPolicy,
|
|
87
90
|
protectHostProviderOwnership,
|
|
88
91
|
// Transitional public aliases retained for callers/tests written during the
|
|
89
92
|
// rc.7 compatibility pass. Runtime code below no longer branches on names.
|
|
@@ -163,18 +166,20 @@ export function apply(ctx, config = {}) {
|
|
|
163
166
|
// generation. Keep the branch named after that observable capability rather
|
|
164
167
|
// than a release number so rc.8+ naturally follows the same public contract.
|
|
165
168
|
const batchAttachmentHost = hasBatchAttachmentContract(stabilizedCtx)
|
|
166
|
-
// DSH
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// after the plugin package itself has updated. Repair only that historical
|
|
170
|
-
// 20MiB/100MP fingerprint; explicit deployment policies remain authoritative.
|
|
169
|
+
// DSH may reconstruct attachment-local after a profile/home patch reload.
|
|
170
|
+
// Keep the historical rc.8 overlay migration attached to that service
|
|
171
|
+
// lifecycle instead of healing only the instance present during apply().
|
|
171
172
|
if (batchAttachmentHost) {
|
|
172
|
-
|
|
173
|
+
installVisionAttachmentAdmissionPolicy(stabilizedCtx, logging.logger)
|
|
173
174
|
}
|
|
174
175
|
// The remote settings bridge uses DSH Connection's trusted-host carrier
|
|
175
176
|
// fence and its own safe-field capability allow-list. Main's local Web
|
|
176
177
|
// mutation boundary continues to protect the independent /_dsh write routes.
|
|
177
178
|
installVisionRouterRemoteSettingsBridge(stabilizedCtx, logging.logger)
|
|
179
|
+
// rc.8 swaps ModuleLoader.load() while entering live mode. The older local
|
|
180
|
+
// permission/risk shims still own rc.6/rc.7; this narrow lifecycle shim
|
|
181
|
+
// re-installs both contexts after rc.8's queue -> live transition.
|
|
182
|
+
installSettingsRc8ClientLifecycle(stabilizedCtx)
|
|
178
183
|
const ownershipCtx = batchAttachmentHost
|
|
179
184
|
? protectHostProviderOwnership(stabilizedCtx)
|
|
180
185
|
: stabilizedCtx
|
|
@@ -191,11 +196,16 @@ export function apply(ctx, config = {}) {
|
|
|
191
196
|
const attachmentCompatCtx = attachmentContextForContract(settingsCtx, logging.logger, {
|
|
192
197
|
installAndroidAttachmentCompat,
|
|
193
198
|
})
|
|
199
|
+
// Put per-tool cwd/cancellation/cache policy AFTER Host settings compatibility
|
|
200
|
+
// so rc.7/rc.8's synthetic settings injection is visible to the boundary.
|
|
201
|
+
// The secure screenshot renderer owns its exact FsTarget and active browser
|
|
202
|
+
// cancellation directly, so it does not depend on this placement.
|
|
203
|
+
const toolRuntimeCtx = installVisionToolRuntimeBoundary(attachmentCompatCtx)
|
|
194
204
|
// Final structured-flow guard sits closest to core.apply so it sees the
|
|
195
205
|
// actual tool registrations and pre-step listener. It makes bootstrap
|
|
196
206
|
// one-shot, enforces fast/standard/deep/custom quotas, tracks mixed branches,
|
|
197
207
|
// rejects empty/non-evidence results, and applies one shared visual deadline.
|
|
198
|
-
const structuredCtx = installStructuredFlowHardening(
|
|
208
|
+
const structuredCtx = installStructuredFlowHardening(toolRuntimeCtx, runtimeConfig)
|
|
199
209
|
// Newer DSH releases publish llm/adapters-updated synchronously from inside
|
|
200
210
|
// registerAdapter(). Coalesce only Vision Router's listener: nested events
|
|
201
211
|
// mark the topology dirty and the outer pass reruns to a fixed point, so we
|
|
@@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
5
5
|
import { writeArtifactFile } from './artifact-boundary.js'
|
|
6
|
+
import { assertScreenshotSourceInWorkspace } from './screenshot-source-boundary.js'
|
|
6
7
|
|
|
7
8
|
const DEFAULT_ARTIFACTS_DIR = '.dsh-vision-router/artifacts'
|
|
8
9
|
const MAX_VIEWPORT_WIDTH = 4096
|
|
@@ -217,7 +218,7 @@ export async function wakePageForFullCaptureBounded(
|
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
function screenshotAbortError() {
|
|
220
|
-
const error = new Error('vision_html_screenshot: browser
|
|
221
|
+
const error = new Error('vision_html_screenshot: browser work aborted')
|
|
221
222
|
error.name = 'AbortError'
|
|
222
223
|
error.code = 'ABORT_ERR'
|
|
223
224
|
return error
|
|
@@ -299,18 +300,26 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
|
|
|
299
300
|
|
|
300
301
|
return async (args, exec) => {
|
|
301
302
|
const source = String(args?.source ?? '')
|
|
303
|
+
const signal = exec?.signal
|
|
304
|
+
if (signal?.aborted) throw screenshotAbortError()
|
|
302
305
|
if (!/\.(html?|htm)$/i.test(source)) {
|
|
303
306
|
throw new Error('vision_html_screenshot: source must be a local .html/.htm file')
|
|
304
307
|
}
|
|
305
308
|
const fsService = ctx.get('fs')
|
|
306
309
|
if (fsService === undefined) throw new Error('vision_html_screenshot: the fs service is not available')
|
|
307
|
-
|
|
310
|
+
// Authorize the exact FsTarget that will be rendered. Do not validate one
|
|
311
|
+
// cwd interpretation and then resolve the same string again under the
|
|
312
|
+
// provider default cwd.
|
|
313
|
+
const resolved = await assertScreenshotSourceInWorkspace(ctx, core, source, exec, { realpathSync: realpath })
|
|
308
314
|
const targetPath = core.toRealPath(fsService, resolved)
|
|
309
315
|
if (!fileExists(targetPath)) throw new Error(`vision_html_screenshot: file not found: ${source}`)
|
|
310
316
|
|
|
311
317
|
const targetReal = realpath(targetPath)
|
|
312
318
|
const workspace = realpathOrResolve(workspaceOf(exec), realpath)
|
|
313
|
-
|
|
319
|
+
if (!isPathInside(workspace, targetReal)) {
|
|
320
|
+
throw new Error('vision_html_screenshot: source must stay inside the session workspace')
|
|
321
|
+
}
|
|
322
|
+
const sourceRoot = workspace
|
|
314
323
|
|
|
315
324
|
const width = safeViewportDimension(args?.width, 1200, MAX_VIEWPORT_WIDTH, 'width')
|
|
316
325
|
const height = safeViewportDimension(args?.height, 720, MAX_VIEWPORT_HEIGHT, 'height')
|
|
@@ -336,13 +345,22 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
|
|
|
336
345
|
)
|
|
337
346
|
}
|
|
338
347
|
|
|
339
|
-
const releaseBrowserSlot = await browserGovernor.acquire({ signal
|
|
348
|
+
const releaseBrowserSlot = await browserGovernor.acquire({ signal })
|
|
340
349
|
const launchArgs = ['--disable-gpu', '--hide-scrollbars', '--incognito']
|
|
341
350
|
if (fullPage) launchArgs.push('--blink-settings=imagesLazyLoadingEnabled=false')
|
|
342
351
|
const launcher = puppeteer.default ?? puppeteer
|
|
343
352
|
let browser
|
|
353
|
+
let browserClosePromise
|
|
354
|
+
let abortHandler
|
|
344
355
|
let png
|
|
345
356
|
let pageHeight
|
|
357
|
+
const closeBrowser = () => {
|
|
358
|
+
if (!browser) return Promise.resolve()
|
|
359
|
+
if (!browserClosePromise) {
|
|
360
|
+
browserClosePromise = Promise.resolve(browser.close()).catch(() => undefined)
|
|
361
|
+
}
|
|
362
|
+
return browserClosePromise
|
|
363
|
+
}
|
|
346
364
|
try {
|
|
347
365
|
try {
|
|
348
366
|
browser = await launcher.launch({ executablePath, headless: true, args: launchArgs })
|
|
@@ -350,6 +368,11 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
|
|
|
350
368
|
const detail = error && error.message ? error.message : String(error)
|
|
351
369
|
throw new Error(`vision_html_screenshot: secure Chrome sandbox launch failed: ${detail}`)
|
|
352
370
|
}
|
|
371
|
+
if (signal) {
|
|
372
|
+
abortHandler = () => { void closeBrowser() }
|
|
373
|
+
signal.addEventListener('abort', abortHandler, { once: true })
|
|
374
|
+
}
|
|
375
|
+
if (signal?.aborted) throw screenshotAbortError()
|
|
353
376
|
|
|
354
377
|
const page = await browser.newPage()
|
|
355
378
|
await page.setViewport({ width, height })
|
|
@@ -362,6 +385,7 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
|
|
|
362
385
|
await page.setOfflineMode(true)
|
|
363
386
|
await wrapRequestInterception(page, sourceRoot)
|
|
364
387
|
await page.goto(pathToFileURL(targetReal).href, { waitUntil: 'networkidle0', timeout: 30000 })
|
|
388
|
+
if (signal?.aborted) throw screenshotAbortError()
|
|
365
389
|
|
|
366
390
|
if (fullPage) {
|
|
367
391
|
pageHeight = await wakePageForFullCaptureBounded(page, height, width, {
|
|
@@ -371,13 +395,19 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
|
|
|
371
395
|
maxWakeMs: deps.maxWakeMs,
|
|
372
396
|
})
|
|
373
397
|
}
|
|
398
|
+
if (signal?.aborted) throw screenshotAbortError()
|
|
374
399
|
|
|
375
400
|
png = fullPage
|
|
376
401
|
? await page.screenshot({ type: 'png', fullPage: true })
|
|
377
402
|
: await page.screenshot({ type: 'png' })
|
|
403
|
+
if (signal?.aborted) throw screenshotAbortError()
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (signal?.aborted && error?.code !== 'ABORT_ERR') throw screenshotAbortError()
|
|
406
|
+
throw error
|
|
378
407
|
} finally {
|
|
408
|
+
if (signal && abortHandler) signal.removeEventListener('abort', abortHandler)
|
|
379
409
|
try {
|
|
380
|
-
|
|
410
|
+
await closeBrowser()
|
|
381
411
|
} finally {
|
|
382
412
|
releaseBrowserSlot()
|
|
383
413
|
}
|
|
@@ -385,6 +415,7 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
|
|
|
385
415
|
|
|
386
416
|
// The heavyweight Chrome slot is released before filesystem artifact IO;
|
|
387
417
|
// slow antivirus/indexing must not unnecessarily serialize later captures.
|
|
418
|
+
if (signal?.aborted) throw screenshotAbortError()
|
|
388
419
|
const stem = fullPage ? `shot-${width}x${height}-fullpage` : `shot-${width}x${height}`
|
|
389
420
|
const fileName = `${core.artifactStemOf(source, stem)}.png`
|
|
390
421
|
let target
|
package/lib/artifact-boundary.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import { lstat, mkdir, realpath, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
currentVisionTurnBudget,
|
|
6
|
+
currentVisionTurnBudgetSignal,
|
|
7
|
+
} from './turn-budget-context.js'
|
|
8
|
+
import {
|
|
9
|
+
isManagedArtifactRunName,
|
|
10
|
+
scheduleArtifactRetention,
|
|
11
|
+
} from './artifact-retention.js'
|
|
4
12
|
|
|
5
13
|
export const DEFAULT_ARTIFACTS_DIR = '.dsh-vision-router/artifacts'
|
|
6
14
|
|
|
@@ -13,6 +21,22 @@ function isMissing(error) {
|
|
|
13
21
|
return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
|
14
22
|
}
|
|
15
23
|
|
|
24
|
+
function artifactAbortError() {
|
|
25
|
+
const error = new Error('vision-router: artifact publication aborted')
|
|
26
|
+
error.name = 'AbortError'
|
|
27
|
+
error.code = 'ABORT_ERR'
|
|
28
|
+
return error
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function throwIfVisionAborted() {
|
|
32
|
+
if (currentVisionTurnBudgetSignal()?.aborted) throw artifactAbortError()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function currentArtifactRunId() {
|
|
36
|
+
const value = currentVisionTurnBudget()?.artifactRunId
|
|
37
|
+
return isManagedArtifactRunName(value) ? value : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
16
40
|
export function normalizeArtifactsDir(value) {
|
|
17
41
|
if (typeof value !== 'string' || value.trim() === '') return DEFAULT_ARTIFACTS_DIR
|
|
18
42
|
const raw = value.trim()
|
|
@@ -69,18 +93,22 @@ async function safeLstat(target, lstatImpl) {
|
|
|
69
93
|
/**
|
|
70
94
|
* Resolve an artifact target without treating lexical workspace containment as
|
|
71
95
|
* authority. Existing ancestors are canonicalized before mkdir runs, then the
|
|
72
|
-
* completed parent
|
|
73
|
-
* nested OCR directory) symlink from turning a workspace-looking path into an
|
|
74
|
-
* outside write.
|
|
96
|
+
* completed parent and artifact root are canonicalized again.
|
|
75
97
|
*/
|
|
76
98
|
export async function resolveArtifactTarget(workspace, artifactsDir, relativePath, deps = {}) {
|
|
77
99
|
const realpathImpl = deps.realpath ?? realpath
|
|
78
100
|
const mkdirImpl = deps.mkdir ?? mkdir
|
|
79
101
|
const lstatImpl = deps.lstat ?? lstat
|
|
80
102
|
|
|
103
|
+
throwIfVisionAborted()
|
|
81
104
|
const workspaceReal = await realpathImpl(path.resolve(String(workspace ?? '')))
|
|
105
|
+
throwIfVisionAborted()
|
|
82
106
|
const relativeBase = normalizeArtifactsDir(artifactsDir)
|
|
83
|
-
const
|
|
107
|
+
const runId = currentArtifactRunId()
|
|
108
|
+
const requestedTarget = normalizeRelativeArtifactPath(relativePath)
|
|
109
|
+
const relativeTarget = runId
|
|
110
|
+
? normalizeRelativeArtifactPath(path.join(runId, requestedTarget))
|
|
111
|
+
: requestedTarget
|
|
84
112
|
const lexicalBase = path.resolve(workspaceReal, relativeBase)
|
|
85
113
|
if (!isPathInside(workspaceReal, lexicalBase)) {
|
|
86
114
|
throw new Error('vision-router: artifactsDir must stay inside the session workspace')
|
|
@@ -92,22 +120,27 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
|
|
|
92
120
|
|
|
93
121
|
const lexicalParent = path.dirname(lexicalTarget)
|
|
94
122
|
const existingAncestorReal = await realpathNearestExisting(lexicalParent, realpathImpl)
|
|
123
|
+
throwIfVisionAborted()
|
|
95
124
|
if (!isPathInside(workspaceReal, existingAncestorReal)) {
|
|
96
125
|
throw new Error('vision-router: artifact parent escapes the session workspace through a symlink')
|
|
97
126
|
}
|
|
98
127
|
|
|
99
128
|
await mkdirImpl(lexicalParent, { recursive: true })
|
|
129
|
+
throwIfVisionAborted()
|
|
100
130
|
const parentReal = await realpathImpl(lexicalParent)
|
|
101
|
-
|
|
131
|
+
const artifactsBaseReal = await realpathImpl(lexicalBase)
|
|
132
|
+
throwIfVisionAborted()
|
|
133
|
+
if (!isPathInside(workspaceReal, parentReal) || !isPathInside(workspaceReal, artifactsBaseReal)) {
|
|
102
134
|
throw new Error('vision-router: artifact parent escapes the session workspace through a symlink')
|
|
103
135
|
}
|
|
104
136
|
|
|
105
137
|
const target = path.join(parentReal, path.basename(lexicalTarget))
|
|
106
138
|
const existing = await safeLstat(target, lstatImpl)
|
|
139
|
+
throwIfVisionAborted()
|
|
107
140
|
if (existing?.isDirectory?.()) {
|
|
108
141
|
throw new Error('vision-router: artifact target is a directory')
|
|
109
142
|
}
|
|
110
|
-
return { target, parentReal, existing }
|
|
143
|
+
return { target, parentReal, artifactsBaseReal, existing, runId }
|
|
111
144
|
}
|
|
112
145
|
|
|
113
146
|
/**
|
|
@@ -126,16 +159,22 @@ export async function writeArtifactFile(workspace, artifactsDir, relativePath, d
|
|
|
126
159
|
)
|
|
127
160
|
let published = false
|
|
128
161
|
try {
|
|
162
|
+
throwIfVisionAborted()
|
|
129
163
|
await writeFileImpl(temp, data, { mode: 0o600 })
|
|
164
|
+
throwIfVisionAborted()
|
|
130
165
|
if (resolved.existing !== undefined) {
|
|
131
166
|
try {
|
|
132
167
|
await unlinkImpl(resolved.target)
|
|
133
168
|
} catch (error) {
|
|
134
169
|
if (!isMissing(error)) throw error
|
|
135
170
|
}
|
|
171
|
+
throwIfVisionAborted()
|
|
136
172
|
}
|
|
137
173
|
await renameImpl(temp, resolved.target)
|
|
138
174
|
published = true
|
|
175
|
+
if (resolved.runId) {
|
|
176
|
+
scheduleArtifactRetention(resolved.artifactsBaseReal, { protectRunId: resolved.runId })
|
|
177
|
+
}
|
|
139
178
|
return resolved.target
|
|
140
179
|
} finally {
|
|
141
180
|
if (!published) {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { lstat, readdir, rm } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const ARTIFACT_RUN_PREFIX = '.vision-run-'
|
|
5
|
+
export const DEFAULT_ARTIFACT_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
6
|
+
export const DEFAULT_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024 * 1024
|
|
7
|
+
export const DEFAULT_ARTIFACT_MAX_RUNS = 512
|
|
8
|
+
export const DEFAULT_ARTIFACT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000
|
|
9
|
+
|
|
10
|
+
const cleanupState = new Map()
|
|
11
|
+
const MAX_TRACKED_ROOTS = 64
|
|
12
|
+
|
|
13
|
+
export function isManagedArtifactRunName(name) {
|
|
14
|
+
return typeof name === 'string' && /^\.vision-run-[A-Za-z0-9._-]+$/.test(name)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function treeBytes(target) {
|
|
18
|
+
let info
|
|
19
|
+
try { info = await lstat(target) } catch { return 0 }
|
|
20
|
+
if (info.isSymbolicLink()) return 0
|
|
21
|
+
if (!info.isDirectory()) return Number(info.size) || 0
|
|
22
|
+
let total = 0
|
|
23
|
+
let entries
|
|
24
|
+
try { entries = await readdir(target, { withFileTypes: true }) } catch { return 0 }
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
total += await treeBytes(path.join(target, entry.name))
|
|
27
|
+
}
|
|
28
|
+
return total
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function finitePositive(value, fallback) {
|
|
32
|
+
const number = Number(value)
|
|
33
|
+
return Number.isFinite(number) && number > 0 ? number : fallback
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Delete only Vision Router run directories. Unknown files, legacy artifacts,
|
|
38
|
+
* symlinks and user-created directories are intentionally outside this policy.
|
|
39
|
+
*/
|
|
40
|
+
export async function cleanupArtifactRuns(root, options = {}) {
|
|
41
|
+
const ttlMs = finitePositive(options.ttlMs, DEFAULT_ARTIFACT_TTL_MS)
|
|
42
|
+
const maxBytes = finitePositive(options.maxBytes, DEFAULT_ARTIFACT_MAX_BYTES)
|
|
43
|
+
const maxRuns = Math.max(1, Math.floor(finitePositive(options.maxRuns, DEFAULT_ARTIFACT_MAX_RUNS)))
|
|
44
|
+
const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now()
|
|
45
|
+
const protect = typeof options.protectRunId === 'string' ? options.protectRunId : undefined
|
|
46
|
+
|
|
47
|
+
let entries
|
|
48
|
+
try { entries = await readdir(root, { withFileTypes: true }) } catch { return { scanned: 0, removed: 0, bytes: 0 } }
|
|
49
|
+
const runs = []
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (!entry.isDirectory() || !isManagedArtifactRunName(entry.name)) continue
|
|
52
|
+
const target = path.join(root, entry.name)
|
|
53
|
+
let info
|
|
54
|
+
try { info = await lstat(target) } catch { continue }
|
|
55
|
+
if (!info.isDirectory() || info.isSymbolicLink()) continue
|
|
56
|
+
runs.push({
|
|
57
|
+
name: entry.name,
|
|
58
|
+
target,
|
|
59
|
+
mtimeMs: Number(info.mtimeMs) || 0,
|
|
60
|
+
bytes: await treeBytes(target),
|
|
61
|
+
protected: entry.name === protect,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
runs.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
66
|
+
const remove = new Set()
|
|
67
|
+
for (const run of runs) {
|
|
68
|
+
if (!run.protected && now - run.mtimeMs >= ttlMs) remove.add(run)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const survivors = () => runs.filter((run) => !remove.has(run))
|
|
72
|
+
let live = survivors()
|
|
73
|
+
while (live.length > maxRuns) {
|
|
74
|
+
const candidate = [...live].reverse().find((run) => !run.protected)
|
|
75
|
+
if (!candidate) break
|
|
76
|
+
remove.add(candidate)
|
|
77
|
+
live = survivors()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let totalBytes = live.reduce((sum, run) => sum + run.bytes, 0)
|
|
81
|
+
while (totalBytes > maxBytes) {
|
|
82
|
+
const candidate = [...live].reverse().find((run) => !run.protected)
|
|
83
|
+
if (!candidate) break
|
|
84
|
+
remove.add(candidate)
|
|
85
|
+
totalBytes -= candidate.bytes
|
|
86
|
+
live = survivors()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let removed = 0
|
|
90
|
+
for (const run of remove) {
|
|
91
|
+
try {
|
|
92
|
+
await rm(run.target, { recursive: true, force: true })
|
|
93
|
+
removed += 1
|
|
94
|
+
} catch {
|
|
95
|
+
// Retention is best-effort and must never make the foreground tool fail.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { scanned: runs.length, removed, bytes: Math.max(0, totalBytes) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Coalesce cleanup work per artifact root so foreground tools only schedule it. */
|
|
102
|
+
export function scheduleArtifactRetention(root, options = {}) {
|
|
103
|
+
if (typeof root !== 'string' || root === '') return
|
|
104
|
+
const now = Date.now()
|
|
105
|
+
let state = cleanupState.get(root)
|
|
106
|
+
if (!state) {
|
|
107
|
+
state = { lastStartedAt: 0, promise: undefined }
|
|
108
|
+
cleanupState.set(root, state)
|
|
109
|
+
}
|
|
110
|
+
const intervalMs = finitePositive(options.intervalMs, DEFAULT_ARTIFACT_CLEANUP_INTERVAL_MS)
|
|
111
|
+
if (state.promise || now - state.lastStartedAt < intervalMs) return
|
|
112
|
+
state.lastStartedAt = now
|
|
113
|
+
state.promise = cleanupArtifactRuns(root, options)
|
|
114
|
+
.catch(() => undefined)
|
|
115
|
+
.finally(() => { state.promise = undefined })
|
|
116
|
+
|
|
117
|
+
cleanupState.delete(root)
|
|
118
|
+
cleanupState.set(root, state)
|
|
119
|
+
while (cleanupState.size > MAX_TRACKED_ROOTS) {
|
|
120
|
+
const oldest = cleanupState.keys().next().value
|
|
121
|
+
if (oldest === undefined) break
|
|
122
|
+
cleanupState.delete(oldest)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -415,6 +415,7 @@ export const LIVE_MODEL_CLIENT_PRELUDE = String.raw`(function(){
|
|
|
415
415
|
if (next.zh && typeof next.zh === 'object') {
|
|
416
416
|
next.zh = Object.assign({}, next.zh, {
|
|
417
417
|
catalogPartialFailure: '部分已配置供应商的模型目录加载失败:{detail}。供应商仍会保留在识图设置中;若无法枚举模型,可选择“手动输入模型 ID”。',
|
|
418
|
+
chainHint: '请先在「设置 → 模型」中配置可用模型,再回到这里选择 Vision Router 用来读取图片的模型。将按从上到下的顺序尝试,失败时自动切换到下一个。',
|
|
418
419
|
visionDepthStandard: '标准(最多再细看 2 次,默认)',
|
|
419
420
|
visionDepthDeep: '细致(最多再细看 4 次)',
|
|
420
421
|
hintVisionDepth: '结构化预识别之后:快速最多再细看 1 次、标准最多 2 次、细致最多 4 次;自定义可自行设置上限。档位只限制追加的证据深挖次数,具体看什么仍由模型按你的问题决定。',
|
|
@@ -424,6 +425,7 @@ export const LIVE_MODEL_CLIENT_PRELUDE = String.raw`(function(){
|
|
|
424
425
|
if (next.en && typeof next.en === 'object') {
|
|
425
426
|
next.en = Object.assign({}, next.en, {
|
|
426
427
|
catalogPartialFailure: 'Some configured providers failed to load their model catalog: {detail}. The provider stays available in Vision Router; choose “Enter model ID” when its models cannot be enumerated.',
|
|
428
|
+
chainHint: 'Configure the model first in Settings → Models, then return here to choose which models Vision Router uses to read images. They are tried from top to bottom, with automatic failover.',
|
|
427
429
|
visionDepthStandard: 'Standard (at most 2 more looks, default)',
|
|
428
430
|
visionDepthDeep: 'Thorough (at most 4 more looks)',
|
|
429
431
|
hintVisionDepth: 'After the structured pre-scan: Quick allows at most 1 more evidence look, Standard 2, Thorough 4, and Custom lets you set the cap. The tier limits only additional evidence calls; the model still decides what to inspect from your request.',
|
|
@@ -460,6 +462,10 @@ export const LIVE_MODEL_CLIENT_PRELUDE = String.raw`(function(){
|
|
|
460
462
|
if (typeof register !== 'function') return register;
|
|
461
463
|
return function(options) {
|
|
462
464
|
var args = Array.prototype.slice.call(arguments);
|
|
465
|
+
if (options && options.name === 'settings.plugin.item'
|
|
466
|
+
&& (options.id === SETTINGS_SECTION_ID || options.key === SETTINGS_SECTION_ID)) {
|
|
467
|
+
return function(){};
|
|
468
|
+
}
|
|
463
469
|
if (options && options.name === 'settings.section' && options.id === SETTINGS_SECTION_ID) {
|
|
464
470
|
args[0] = Object.assign({}, options, {
|
|
465
471
|
order: settingsSectionOrder(target, SETTINGS_SECTION_ID)
|
|
@@ -610,4 +616,4 @@ export function installLiveModelClientPrelude(ctx) {
|
|
|
610
616
|
'vision-router: live model client prelude',
|
|
611
617
|
)
|
|
612
618
|
})
|
|
613
|
-
}
|
|
619
|
+
}
|
package/lib/repetition-guard.js
CHANGED
|
@@ -1,45 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Repetition-loop guard for vision backend output.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* or 「華爲數據中心…」). Such output is useless to the agent and, worse,
|
|
9
|
-
* it looks like a successful backend result, so the fallback chain never
|
|
10
|
-
* runs. This module detects the signature of such loops so the vision chain
|
|
11
|
-
* can treat them as backend failures and move to the next candidate.
|
|
12
|
-
*
|
|
13
|
-
* Detection is intentionally cheap and heuristic:
|
|
14
|
-
* - whitespace is stripped first, because loops are often separated by
|
|
15
|
-
* spaces/punctuation that vary;
|
|
16
|
-
* - a consecutive-run scan looks for one window repeated back-to-back
|
|
17
|
-
* (the classic loop shape);
|
|
18
|
-
* - a token-density check catches non-consecutive-but-overwhelming
|
|
19
|
-
* repetition (the "路由器 / 互聯網 路由器" alternation shape).
|
|
4
|
+
* The guard targets language-generation loops, not legitimate OCR filler,
|
|
5
|
+
* numeric tables or long machine data. Analysis is deliberately bounded so a
|
|
6
|
+
* near-limit backend response cannot turn the detector itself into a memory
|
|
7
|
+
* spike.
|
|
20
8
|
*/
|
|
21
9
|
|
|
22
10
|
export const REPETITION_LOOP_MARKER = 'vision backend returned a repetition loop'
|
|
11
|
+
export const DEFAULT_REPETITION_ANALYSIS_CHARS = 128 * 1024
|
|
12
|
+
export const DEFAULT_REPETITION_DENSITY_CHARS = 64 * 1024
|
|
23
13
|
|
|
24
14
|
/** Strip whitespace (and NBSP) so loops split by varying whitespace collapse. */
|
|
25
15
|
export function compactForRepetition(text) {
|
|
26
16
|
return String(text ?? '').replace(/[\s\u00a0]+/gu, '')
|
|
27
17
|
}
|
|
28
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Bound analysis to front/middle/tail samples. NUL separators prevent a phrase
|
|
21
|
+
* ending one sample and starting another from fabricating a consecutive run.
|
|
22
|
+
*/
|
|
23
|
+
export function sampleForRepetition(text, maxChars = DEFAULT_REPETITION_ANALYSIS_CHARS) {
|
|
24
|
+
const source = String(text ?? '')
|
|
25
|
+
const limit = Math.max(256, Math.floor(Number(maxChars) || DEFAULT_REPETITION_ANALYSIS_CHARS))
|
|
26
|
+
if (source.length <= limit) return source
|
|
27
|
+
const frontSize = Math.floor(limit * 0.5)
|
|
28
|
+
const middleSize = Math.floor(limit * 0.25)
|
|
29
|
+
const tailSize = Math.max(1, limit - frontSize - middleSize)
|
|
30
|
+
const middleStart = Math.max(frontSize, Math.floor((source.length - middleSize) / 2))
|
|
31
|
+
return [
|
|
32
|
+
source.slice(0, frontSize),
|
|
33
|
+
source.slice(middleStart, middleStart + middleSize),
|
|
34
|
+
source.slice(-tailSize),
|
|
35
|
+
].join('\u0000')
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
const CANDIDATE_WINDOWS = [2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32, 40]
|
|
30
39
|
|
|
40
|
+
// Repetition failures seen from generative models contain words/ideographs.
|
|
41
|
+
// Pure digits/punctuation are common in OCR, matrices, serial numbers and
|
|
42
|
+
// telemetry, so those windows must not be treated as language loops.
|
|
43
|
+
function informativeWindow(value) {
|
|
44
|
+
return /\p{L}/u.test(String(value ?? ''))
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
/**
|
|
32
48
|
* @param {string} text raw backend output
|
|
33
49
|
* @param {object} [options]
|
|
34
50
|
* @param {number} [options.minRun=6] minimum consecutive repeats for a run
|
|
35
51
|
* @param {number} [options.minCoveredChars=32] minimum chars a loop must cover
|
|
36
|
-
* @param {number} [options.minCoveredRatio=0.35] minimum share of
|
|
37
|
-
* consecutive run must cover (0..1)
|
|
52
|
+
* @param {number} [options.minCoveredRatio=0.35] minimum share of analyzed text
|
|
38
53
|
* @param {number} [options.maxWindow=40] largest window size to try
|
|
54
|
+
* @param {number} [options.maxAnalysisChars=131072] maximum sampled chars
|
|
55
|
+
* @param {number} [options.maxDensityChars=65536] maximum density-map chars
|
|
39
56
|
* @returns {object|undefined} loop description, or undefined when healthy
|
|
40
57
|
*/
|
|
41
58
|
export function detectRepetitionLoop(text, options = {}) {
|
|
42
|
-
const
|
|
59
|
+
const sampled = sampleForRepetition(text, options.maxAnalysisChars)
|
|
60
|
+
const source = compactForRepetition(sampled)
|
|
43
61
|
const total = source.length
|
|
44
62
|
const minRun = options.minRun ?? 6
|
|
45
63
|
const minCoveredChars = options.minCoveredChars ?? 32
|
|
@@ -48,15 +66,15 @@ export function detectRepetitionLoop(text, options = {}) {
|
|
|
48
66
|
if (total < minCoveredChars) return undefined
|
|
49
67
|
const coveredFloor = Math.max(minCoveredChars, total * minCoveredRatio)
|
|
50
68
|
|
|
51
|
-
// Pass 0: exact period
|
|
52
|
-
//
|
|
53
|
-
// one of the candidate windows below.
|
|
69
|
+
// Pass 0: exact period. Require semantic characters so an OCR column made of
|
|
70
|
+
// zeros/dashes is not mistaken for a model language loop.
|
|
54
71
|
const maxPeriod = Math.min(64, Math.floor(total / 2))
|
|
55
72
|
for (let period = 2; period <= maxPeriod; period++) {
|
|
56
73
|
const full = Math.floor(total / period)
|
|
57
74
|
const remainder = total % period
|
|
58
75
|
if (full < minRun) continue
|
|
59
76
|
const prefix = source.slice(0, period)
|
|
77
|
+
if (!informativeWindow(prefix)) continue
|
|
60
78
|
let matches = true
|
|
61
79
|
for (let block = 1; block < full; block++) {
|
|
62
80
|
if (!source.startsWith(prefix, block * period)) {
|
|
@@ -78,7 +96,7 @@ export function detectRepetitionLoop(text, options = {}) {
|
|
|
78
96
|
}
|
|
79
97
|
}
|
|
80
98
|
|
|
81
|
-
// Pass 1: one window repeated consecutively, back-to-back.
|
|
99
|
+
// Pass 1: one language-bearing window repeated consecutively, back-to-back.
|
|
82
100
|
for (const windowSize of CANDIDATE_WINDOWS) {
|
|
83
101
|
if (windowSize > maxWindow || windowSize > Math.floor(total / 2)) continue
|
|
84
102
|
let bestCount = 0
|
|
@@ -91,11 +109,11 @@ export function detectRepetitionLoop(text, options = {}) {
|
|
|
91
109
|
count += 1
|
|
92
110
|
cursor += windowSize
|
|
93
111
|
}
|
|
94
|
-
if (count > bestCount) {
|
|
112
|
+
if (informativeWindow(candidate) && count > bestCount) {
|
|
95
113
|
bestCount = count
|
|
96
114
|
bestWindow = candidate
|
|
97
115
|
}
|
|
98
|
-
i = cursor
|
|
116
|
+
i = Math.max(i + 1, cursor)
|
|
99
117
|
}
|
|
100
118
|
const covered = bestCount * windowSize
|
|
101
119
|
if (bestCount >= minRun && covered >= coveredFloor) {
|
|
@@ -112,13 +130,20 @@ export function detectRepetitionLoop(text, options = {}) {
|
|
|
112
130
|
}
|
|
113
131
|
}
|
|
114
132
|
|
|
115
|
-
// Pass 2: one short token dominating the text
|
|
116
|
-
//
|
|
133
|
+
// Pass 2: one short language token dominating the text. Build maps only over
|
|
134
|
+
// a bounded slice, so high-entropy megabyte responses have a fixed ceiling.
|
|
135
|
+
const densityLimit = Math.max(
|
|
136
|
+
256,
|
|
137
|
+
Math.floor(Number(options.maxDensityChars) || DEFAULT_REPETITION_DENSITY_CHARS),
|
|
138
|
+
)
|
|
139
|
+
const densitySource = source.slice(0, densityLimit)
|
|
140
|
+
const densityTotal = densitySource.length
|
|
117
141
|
for (const windowSize of [2, 3, 4]) {
|
|
118
142
|
if (windowSize > maxWindow) continue
|
|
119
143
|
const counts = new Map()
|
|
120
|
-
for (let i = 0; i + windowSize <=
|
|
121
|
-
const token =
|
|
144
|
+
for (let i = 0; i + windowSize <= densityTotal; i++) {
|
|
145
|
+
const token = densitySource.slice(i, i + windowSize)
|
|
146
|
+
if (!informativeWindow(token)) continue
|
|
122
147
|
counts.set(token, (counts.get(token) ?? 0) + 1)
|
|
123
148
|
}
|
|
124
149
|
let topToken = ''
|
|
@@ -130,7 +155,7 @@ export function detectRepetitionLoop(text, options = {}) {
|
|
|
130
155
|
}
|
|
131
156
|
}
|
|
132
157
|
const covered = topCount * windowSize
|
|
133
|
-
if (topCount >= minRun && covered >= Math.max(minCoveredChars,
|
|
158
|
+
if (topCount >= minRun && covered >= Math.max(minCoveredChars, densityTotal * 0.5)) {
|
|
134
159
|
return {
|
|
135
160
|
looped: true,
|
|
136
161
|
mode: 'token-density',
|
|
@@ -147,17 +172,13 @@ export function detectRepetitionLoop(text, options = {}) {
|
|
|
147
172
|
return undefined
|
|
148
173
|
}
|
|
149
174
|
|
|
150
|
-
/**
|
|
151
|
-
* Throw when the output is a repetition loop. The thrown Error carries no
|
|
152
|
-
* status/code, so the vision chain's classifier maps it by message pattern;
|
|
153
|
-
* the message intentionally includes a stable marker for that classifier.
|
|
154
|
-
*/
|
|
175
|
+
/** Throw when the output is a repetition loop. */
|
|
155
176
|
export function assertNoRepetitionLoop(text, backendKey) {
|
|
156
177
|
const loop = detectRepetitionLoop(text)
|
|
157
178
|
if (loop === undefined) return
|
|
158
179
|
const label = backendKey ? ` (${backendKey})` : ''
|
|
159
180
|
throw new Error(
|
|
160
181
|
`${REPETITION_LOOP_MARKER}${label}: ${loop.repetitions} repeats of ${JSON.stringify(loop.window)} ` +
|
|
161
|
-
`(${loop.mode}) covering ${loop.coveredChars}/${loop.totalChars} chars;
|
|
182
|
+
`(${loop.mode}) covering ${loop.coveredChars}/${loop.totalChars} analyzed chars; sampled from ${JSON.stringify(loop.sample)}`,
|
|
162
183
|
)
|
|
163
184
|
}
|