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 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 profile overlays replace an attachment-local config object wholesale.
167
- // A stale pre-rc.8 Vision Router profile row can therefore erase the newer
168
- // bundle's maxImageDimension and silently restore rc.8's 2000px default even
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
- ensureVisionAttachmentAdmissionPolicy(stabilizedCtx, logging.logger)
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(attachmentCompatCtx, runtimeConfig)
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 slot wait aborted')
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
- const resolved = await fsService.resolve(source)
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
- const sourceRoot = isPathInside(workspace, targetReal) ? workspace : realpath(path.dirname(targetReal))
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: exec?.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
- if (browser) await browser.close()
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
@@ -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 is canonicalized again. This prevents an artifactsDir (or
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 relativeTarget = normalizeRelativeArtifactPath(relativePath)
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
- if (!isPathInside(workspaceReal, parentReal)) {
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
+ }
@@ -1,45 +1,63 @@
1
1
  /**
2
2
  * Repetition-loop guard for vision backend output.
3
3
  *
4
- * Some vision backends occasionally degenerate into repetition loops when
5
- * asked for long constrained output (e.g. the structured-bootstrap JSON
6
- * schema) over a tall screenshot — the "answer" is one short phrase repeated
7
- * hundreds of times (observed in the wild as 「網絡路由器 互聯網 路由器…」
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 the text a
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 source = compactForRepetition(text)
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 — the whole text is one phrase repeated, possibly
52
- // with a partial trailing repeat. Handles clean loops whose period is not
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 without being consecutive
116
- // (the alternating loop shape).
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 <= total; i++) {
121
- const token = source.slice(i, i + windowSize)
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, total * 0.5)) {
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; first ${loop.windowSize}-char window sampled from ${JSON.stringify(loop.sample)}`,
182
+ `(${loop.mode}) covering ${loop.coveredChars}/${loop.totalChars} analyzed chars; sampled from ${JSON.stringify(loop.sample)}`,
162
183
  )
163
184
  }