dsh-vision-router 2.1.7 → 2.2.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/README.md +8 -14
- package/README.zh.md +8 -14
- package/docs/architecture/compat-inventory.md +1 -1
- package/docs/architecture/dsh-compatibility-matrix.md +4 -1
- package/docs/architecture/dsh-support-window.md +1 -2
- package/docs/releases/v2.2.0.md +25 -0
- package/docs/releases/v2.2.1.md +22 -0
- package/docs/remote-settings.md +2 -0
- package/entry.js +2 -0
- package/index.js +355 -64
- package/lib/artifact-boundary.js +11 -0
- package/lib/artifact-io.js +60 -7
- package/lib/catalog-corrections.js +5 -0
- package/lib/client.js +22 -17
- package/lib/core-primitives.js +126 -5
- package/lib/degraded-local-evidence.js +39 -0
- package/lib/dsh-contract-compat.js +225 -0
- package/lib/dsh-support-window.js +1 -1
- package/lib/fetch-wrapper-lifecycle.js +56 -0
- package/lib/image-offload-compat.js +38 -0
- package/lib/legacy-global-proxy-boundary.js +4 -3
- package/lib/local-vision-stabilizer.js +15 -6
- package/lib/official-deepseek-catalog.js +120 -0
- package/lib/ollama-cold-start.js +3 -15
- package/lib/pi-ai-bridge-wire-compat.js +4 -3
- package/lib/pixel-diff-stream.js +12 -6
- package/lib/remote-settings-bridge.js +19 -1
- package/lib/replay-delegation.js +41 -21
- package/lib/runtime-composition.js +15 -2
- package/lib/runtime-i18n-boundary.js +4 -13
- package/lib/runtime-i18n.js +2 -2
- package/lib/session-surface-compat.js +40 -0
- package/lib/session-turn-resolver.js +57 -0
- package/lib/session-vision-index.js +438 -97
- package/lib/session-vision-mode-boundary.js +83 -6
- package/lib/session-vision-runtime.js +8 -2
- package/lib/session-vision-state.js +0 -13
- package/lib/tesseract-exec-compat.js +16 -2
- package/lib/twin-image-capability-fallback.js +2 -11
- package/lib/vision-artifact-store.js +16 -3
- package/lib/vision-attachment-handle-runtime.js +9 -21
- package/lib/vision-backend-runtime-policy.js +7 -6
- package/lib/vision-breaker-shadow-health.js +26 -6
- package/lib/vision-evidence-guidance.js +39 -0
- package/lib/vision-resilience.js +73 -1
- package/package.json +12 -7
package/lib/artifact-boundary.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createVisionArtifactStore } from './vision-artifact-store.js'
|
|
2
2
|
|
|
3
3
|
export {
|
|
4
|
+
ARTIFACT_HANDOFF_RUN_ID,
|
|
5
|
+
ARTIFACT_RUNS_DIR,
|
|
4
6
|
DEFAULT_ARTIFACTS_DIR,
|
|
5
7
|
normalizeArtifactsDir,
|
|
6
8
|
} from './artifact-io.js'
|
|
@@ -23,3 +25,12 @@ export function resolveArtifactTarget(workspace, artifactsDir, relativePath, dep
|
|
|
23
25
|
export function writeArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
|
|
24
26
|
return createVisionArtifactStore({ workspace, artifactsDir, deps }).publish(relativePath, data)
|
|
25
27
|
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Persistent writer for file-path handoffs such as vision_materialize. It uses
|
|
31
|
+
* the same store boundary but publishes under one stable reserved managed run,
|
|
32
|
+
* so later tools can reference it without losing retention governance.
|
|
33
|
+
*/
|
|
34
|
+
export function writePersistentArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
|
|
35
|
+
return createVisionArtifactStore({ workspace, artifactsDir, deps }).publishPersistent(relativePath, data)
|
|
36
|
+
}
|
package/lib/artifact-io.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { lstat, mkdir, realpath, rename, unlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { lstat, mkdir, realpath, rename, unlink, utimes, writeFile } from 'node:fs/promises'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import {
|
|
5
5
|
currentVisionTurnBudget,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
|
|
13
13
|
export const DEFAULT_ARTIFACTS_DIR = '.dsh-vision-router/artifacts'
|
|
14
14
|
export const ARTIFACT_RUNS_DIR = '.runs'
|
|
15
|
+
export const ARTIFACT_HANDOFF_RUN_ID = '.vision-run-handoff'
|
|
15
16
|
|
|
16
17
|
function isPathInside(root, candidate) {
|
|
17
18
|
const relative = path.relative(root, candidate)
|
|
@@ -99,7 +100,7 @@ async function safeLstat(target, lstatImpl) {
|
|
|
99
100
|
* existing symlink is rejected even when it points somewhere else inside the
|
|
100
101
|
* workspace. Artifacts outside a managed run keep their original root layout.
|
|
101
102
|
*/
|
|
102
|
-
|
|
103
|
+
async function resolveArtifactTargetForRun(workspace, artifactsDir, relativePath, deps = {}, runId) {
|
|
103
104
|
const realpathImpl = deps.realpath ?? realpath
|
|
104
105
|
const mkdirImpl = deps.mkdir ?? mkdir
|
|
105
106
|
const lstatImpl = deps.lstat ?? lstat
|
|
@@ -108,7 +109,6 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
|
|
|
108
109
|
const workspaceReal = await realpathImpl(path.resolve(String(workspace ?? '')))
|
|
109
110
|
throwIfVisionAborted()
|
|
110
111
|
const relativeBase = normalizeArtifactsDir(artifactsDir)
|
|
111
|
-
const runId = currentArtifactRunId()
|
|
112
112
|
const requestedTarget = normalizeRelativeArtifactPath(relativePath)
|
|
113
113
|
const relativeTarget = runId
|
|
114
114
|
? normalizeRelativeArtifactPath(path.join(ARTIFACT_RUNS_DIR, runId, requestedTarget))
|
|
@@ -119,6 +119,7 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
const lexicalRunsBase = runId ? path.resolve(lexicalBase, ARTIFACT_RUNS_DIR) : undefined
|
|
122
|
+
const lexicalRunBase = runId ? path.resolve(lexicalRunsBase, runId) : undefined
|
|
122
123
|
if (lexicalRunsBase && !isPathInside(lexicalBase, lexicalRunsBase)) {
|
|
123
124
|
throw new Error('vision-router: managed artifact runs must stay inside the artifacts directory')
|
|
124
125
|
}
|
|
@@ -155,6 +156,7 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
|
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
let runsBaseReal
|
|
159
|
+
let runBaseReal
|
|
158
160
|
if (lexicalRunsBase) {
|
|
159
161
|
const after = await safeLstat(lexicalRunsBase, lstatImpl)
|
|
160
162
|
throwIfVisionAborted()
|
|
@@ -166,6 +168,16 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
|
|
|
166
168
|
if (!isPathInside(artifactsBaseReal, runsBaseReal)) {
|
|
167
169
|
throw new Error('vision-router: managed artifact runs directory escapes the artifacts directory')
|
|
168
170
|
}
|
|
171
|
+
const runInfo = await safeLstat(lexicalRunBase, lstatImpl)
|
|
172
|
+
throwIfVisionAborted()
|
|
173
|
+
if (!runInfo?.isDirectory?.() || runInfo.isSymbolicLink?.()) {
|
|
174
|
+
throw new Error('vision-router: managed artifact run must be a real directory')
|
|
175
|
+
}
|
|
176
|
+
runBaseReal = await realpathImpl(lexicalRunBase)
|
|
177
|
+
throwIfVisionAborted()
|
|
178
|
+
if (!isPathInside(runsBaseReal, runBaseReal)) {
|
|
179
|
+
throw new Error('vision-router: managed artifact run escapes the runs directory')
|
|
180
|
+
}
|
|
169
181
|
}
|
|
170
182
|
|
|
171
183
|
const target = path.join(parentReal, path.basename(lexicalTarget))
|
|
@@ -174,15 +186,33 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
|
|
|
174
186
|
if (existing?.isDirectory?.()) {
|
|
175
187
|
throw new Error('vision-router: artifact target is a directory')
|
|
176
188
|
}
|
|
177
|
-
return { target, parentReal, artifactsBaseReal, runsBaseReal, existing, runId }
|
|
189
|
+
return { target, parentReal, artifactsBaseReal, runsBaseReal, runBaseReal, existing, runId }
|
|
178
190
|
}
|
|
179
191
|
|
|
180
|
-
|
|
181
|
-
|
|
192
|
+
export function resolveArtifactTarget(workspace, artifactsDir, relativePath, deps = {}) {
|
|
193
|
+
return resolveArtifactTargetForRun(
|
|
194
|
+
workspace, artifactsDir, relativePath, deps, currentArtifactRunId(),
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resolve a stable cross-tool handoff artifact even when the caller is inside
|
|
200
|
+
* a random managed per-tool run. Handoff files share one reserved managed run,
|
|
201
|
+
* so their path is deterministic enough for later tool calls while existing
|
|
202
|
+
* retention still bounds their lifetime and total bytes.
|
|
203
|
+
*/
|
|
204
|
+
export function resolvePersistentArtifactTarget(workspace, artifactsDir, relativePath, deps = {}) {
|
|
205
|
+
return resolveArtifactTargetForRun(
|
|
206
|
+
workspace, artifactsDir, relativePath, deps, ARTIFACT_HANDOFF_RUN_ID,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function writeArtifactFileForRun(workspace, artifactsDir, relativePath, data, deps, runId) {
|
|
182
211
|
const writeFileImpl = deps.writeFile ?? writeFile
|
|
183
212
|
const renameImpl = deps.rename ?? rename
|
|
184
213
|
const unlinkImpl = deps.unlink ?? unlink
|
|
185
|
-
const
|
|
214
|
+
const utimesImpl = deps.utimes ?? utimes
|
|
215
|
+
const resolved = await resolveArtifactTargetForRun(workspace, artifactsDir, relativePath, deps, runId)
|
|
186
216
|
const temp = path.join(
|
|
187
217
|
resolved.parentReal,
|
|
188
218
|
`.dsh-vision-router-${process.pid}-${randomUUID()}.tmp`,
|
|
@@ -202,6 +232,11 @@ export async function writeArtifactFile(workspace, artifactsDir, relativePath, d
|
|
|
202
232
|
}
|
|
203
233
|
await renameImpl(temp, resolved.target)
|
|
204
234
|
published = true
|
|
235
|
+
if (resolved.runId === ARTIFACT_HANDOFF_RUN_ID && resolved.runBaseReal) {
|
|
236
|
+
const touchedAt = new Date()
|
|
237
|
+
await utimesImpl(resolved.runBaseReal, touchedAt, touchedAt)
|
|
238
|
+
throwIfVisionAborted()
|
|
239
|
+
}
|
|
205
240
|
if (resolved.runId) {
|
|
206
241
|
// New managed runs live under `.runs`, so retention scans that directory
|
|
207
242
|
// directly. Also schedule the old artifact root so pre-P2-B direct
|
|
@@ -218,3 +253,21 @@ export async function writeArtifactFile(workspace, artifactsDir, relativePath, d
|
|
|
218
253
|
}
|
|
219
254
|
}
|
|
220
255
|
}
|
|
256
|
+
|
|
257
|
+
/** Hardened atomic publication primitive used by VisionArtifactStore. */
|
|
258
|
+
export async function writeArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
|
|
259
|
+
return writeArtifactFileForRun(
|
|
260
|
+
workspace, artifactsDir, relativePath, data, deps, currentArtifactRunId(),
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Publish a stable workspace handoff artifact under the reserved managed run
|
|
266
|
+
* while preserving the exact same containment, symlink, cancellation, atomic
|
|
267
|
+
* write and retention checks as writeArtifactFile.
|
|
268
|
+
*/
|
|
269
|
+
export async function writePersistentArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
|
|
270
|
+
return writeArtifactFileForRun(
|
|
271
|
+
workspace, artifactsDir, relativePath, data, deps, ARTIFACT_HANDOFF_RUN_ID,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { stripTrailingSlashes } from './string-normalization.js'
|
|
18
18
|
import { currentVisionProviderTransport } from './vision-provider-transport.js'
|
|
19
19
|
import { directSessionAffinityHeaders } from './session-affinity.js'
|
|
20
|
+
import { isOffloadedImageBlock, offloadedImagePlaceholder } from './image-offload-compat.js'
|
|
20
21
|
|
|
21
22
|
export const CATALOG_ROUTING_CORRECTIONS = [
|
|
22
23
|
{
|
|
@@ -109,6 +110,10 @@ async function convertBlocks(blocks, bytesOf, depth = 0) {
|
|
|
109
110
|
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
|
|
110
111
|
out.push({ type: 'text', text: block.text })
|
|
111
112
|
} else if (block.type === 'image') {
|
|
113
|
+
if (isOffloadedImageBlock(block)) {
|
|
114
|
+
out.push({ type: 'text', text: offloadedImagePlaceholder(block) })
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
112
117
|
if (typeof bytesOf !== 'function') continue
|
|
113
118
|
let bytes
|
|
114
119
|
try {
|
package/lib/client.js
CHANGED
|
@@ -231,14 +231,15 @@ window.__ModuleLoader__.load({
|
|
|
231
231
|
'本地后端不受影响,仍在最前。关闭时保持既有顺序(付费在前、免费补全在后)。',
|
|
232
232
|
hintDesktopScreenshot: '允许模型按需截取桌面屏幕用于识图。默认关闭;开启后才提供 vision_screenshot 工具。macOS 在保存开启时会立即触发一次屏幕录制授权检查;如果已经授权,系统不会重复弹窗。',
|
|
233
233
|
hintStealth:
|
|
234
|
-
'只作用于官方 DeepSeek
|
|
235
|
-
'
|
|
236
|
-
'
|
|
237
|
-
'它们默认由「自动创建 + 自动识图模型组」处理;只有要限制范围时才用下方手动包装。改动后需重启 dsh 生效。',
|
|
234
|
+
'只作用于官方 DeepSeek 路由。新版 DSH 中官方 provider 始终归 Host 所有,Vision Router 只使用「+ 自动识图」' +
|
|
235
|
+
'包装,不再要求你禁用 llm-deepseek;旧版 Host 仍保留官方行缺失时的历史接管兼容。opencode 等自定义路由' +
|
|
236
|
+
'默认自动包装,只有要限制范围时才用下方手动包装。',
|
|
238
237
|
stealthOfficialDeadHint:
|
|
239
|
-
'
|
|
240
|
-
'
|
|
241
|
-
|
|
238
|
+
'提示:这是旧版 Host,且官方 DeepSeek 行当前被禁用;Vision Router 正使用兼容性 keep-alive 路由。' +
|
|
239
|
+
'如需恢复完全官方的原生行,请在 cordis.patch.yml 中重新启用 llm-deepseek 后重启。',
|
|
240
|
+
stealthOfficialHostOwnedHint:
|
|
241
|
+
'检测到官方 DeepSeek provider 被禁用或不可用,但当前 DSH 已由 Host 负责该路由。Vision Router 不会自行重建它。' +
|
|
242
|
+
'请重新启用 llm-deepseek,再通过「+ 自动识图」包装 /「👁 识图」处理图片任务。',
|
|
242
243
|
numTimeoutMs: '单次识图请求超时(毫秒)',
|
|
243
244
|
numVisionTaskTimeoutMs: '识图任务最长时间(毫秒)',
|
|
244
245
|
numOcrTimeoutMs: 'OCR 最长时间(毫秒)',
|
|
@@ -507,16 +508,16 @@ window.__ModuleLoader__.load({
|
|
|
507
508
|
'Local backends are unaffected and stay first. When off, the existing order is preserved (paid first, free appended last).',
|
|
508
509
|
hintDesktopScreenshot: 'Lets the model capture the desktop on demand for image understanding. Off by default; vision_screenshot is exposed only when enabled. On macOS, saving the enabled setting immediately triggers a screen-recording permission check; if permission was already granted, macOS will not prompt again.',
|
|
509
510
|
hintStealth:
|
|
510
|
-
'Only affects the official DeepSeek route
|
|
511
|
-
'
|
|
512
|
-
'stock row is
|
|
513
|
-
'
|
|
514
|
-
'Restart dsh after changing stealth/profile takeover.',
|
|
511
|
+
'Only affects the official DeepSeek route. On newer DSH Hosts the official provider stays Host-owned, so Vision Router ' +
|
|
512
|
+
'uses the “+ Auto Vision” wrapper and never asks you to disable llm-deepseek. Legacy Hosts retain the historical ' +
|
|
513
|
+
'takeover compatibility when the stock row is absent. Custom routes like opencode are auto-wrapped by default; use ' +
|
|
514
|
+
'the manual wrapper section below only when you want to restrict their scope.',
|
|
515
515
|
stealthOfficialDeadHint:
|
|
516
|
-
'Notice: the official DeepSeek row is
|
|
517
|
-
'
|
|
518
|
-
|
|
519
|
-
'
|
|
516
|
+
'Notice: on this legacy Host the official DeepSeek row is disabled, so Vision Router is using its compatibility ' +
|
|
517
|
+
'keep-alive route. To restore the fully official route, re-enable llm-deepseek in cordis.patch.yml and restart.',
|
|
518
|
+
stealthOfficialHostOwnedHint:
|
|
519
|
+
'The official DeepSeek provider is disabled or unavailable, but this DSH Host owns that provider route. Vision Router ' +
|
|
520
|
+
'will not recreate it. Re-enable llm-deepseek, then use the “+ Auto Vision” wrapper / Vision control for image tasks.',
|
|
520
521
|
numTimeoutMs: 'Single vision request timeout (ms)',
|
|
521
522
|
numVisionTaskTimeoutMs: 'Vision task maximum time (ms)',
|
|
522
523
|
numOcrTimeoutMs: 'OCR maximum time (ms)',
|
|
@@ -3913,7 +3914,11 @@ window.__ModuleLoader__.load({
|
|
|
3913
3914
|
const stealthNotice = () => {
|
|
3914
3915
|
if (testState.status !== 'done') return null
|
|
3915
3916
|
const state = testState.result && testState.result.stealth
|
|
3916
|
-
if (!state
|
|
3917
|
+
if (!state) return null
|
|
3918
|
+
if (state.reason === 'host-owned-official-unavailable') {
|
|
3919
|
+
return h('p', { className: 'vr-hint vr-stealth-notice' }, t('stealthOfficialHostOwnedHint'))
|
|
3920
|
+
}
|
|
3921
|
+
if (state.active !== true || state.reason !== 'official-unavailable') return null
|
|
3917
3922
|
return h('p', { className: 'vr-hint vr-stealth-notice' }, t('stealthOfficialDeadHint'))
|
|
3918
3923
|
}
|
|
3919
3924
|
|
package/lib/core-primitives.js
CHANGED
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
} from './http-body-limit.js'
|
|
73
73
|
import { writeArtifactFile } from './artifact-boundary.js'
|
|
74
74
|
import { stripTrailingSlashes } from './string-normalization.js'
|
|
75
|
+
import { isOffloadedImageBlock, offloadedImagePlaceholder } from './image-offload-compat.js'
|
|
75
76
|
import { proxyHostMatchesAny } from './proxy-routing.js'
|
|
76
77
|
import { parseVersionComparator } from './version-range.js'
|
|
77
78
|
import { captureWindowsDesktop } from './windows-desktop-capture.js'
|
|
@@ -1454,6 +1455,108 @@ export async function ocrWithTesseract(bytes, timeoutMs = 60000) {
|
|
|
1454
1455
|
return String(stdout ?? '')
|
|
1455
1456
|
}
|
|
1456
1457
|
|
|
1458
|
+
const TESSERACT_OCR_PSMS = Object.freeze([6, 3, 11])
|
|
1459
|
+
const TESSERACT_REVIEW_THRESHOLD = 70
|
|
1460
|
+
const TESSERACT_CANDIDATE_MARGIN = 5
|
|
1461
|
+
const TESSERACT_CONFUSABLE_RUN = /[0O1IlL]{3,}/
|
|
1462
|
+
|
|
1463
|
+
function median(values) {
|
|
1464
|
+
if (!Array.isArray(values) || values.length === 0) return 0
|
|
1465
|
+
const ordered = [...values].sort((a, b) => a - b)
|
|
1466
|
+
const middle = Math.floor(ordered.length / 2)
|
|
1467
|
+
return ordered.length % 2 === 0
|
|
1468
|
+
? (ordered[middle - 1] + ordered[middle]) / 2
|
|
1469
|
+
: ordered[middle]
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
export function parseTesseractTsv(tsv) {
|
|
1473
|
+
const words = []
|
|
1474
|
+
for (const line of String(tsv ?? '').split(/\r?\n/).slice(1)) {
|
|
1475
|
+
const columns = line.split('\t')
|
|
1476
|
+
if (columns.length < 12) continue
|
|
1477
|
+
const confidence = Number(columns[10])
|
|
1478
|
+
const text = columns.slice(11).join('\t').trim()
|
|
1479
|
+
if (text === '' || !Number.isFinite(confidence) || confidence < 0) continue
|
|
1480
|
+
words.push({ text, confidence })
|
|
1481
|
+
}
|
|
1482
|
+
return words
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
export function assessTesseractOcr(text, tsv, psm = 6) {
|
|
1486
|
+
const words = parseTesseractTsv(tsv)
|
|
1487
|
+
const confidences = words.map((word) => word.confidence)
|
|
1488
|
+
const precisionWords = words.filter((word) => /[0-9$%#:/._-]/.test(word.text))
|
|
1489
|
+
const precisionConfidences = precisionWords.map((word) => word.confidence)
|
|
1490
|
+
const riskyTokens = words
|
|
1491
|
+
.map((word) => word.text)
|
|
1492
|
+
.filter((token) => TESSERACT_CONFUSABLE_RUN.test(token))
|
|
1493
|
+
const quality = precisionConfidences.length > 0
|
|
1494
|
+
? Math.min(...precisionConfidences)
|
|
1495
|
+
: median(confidences)
|
|
1496
|
+
return {
|
|
1497
|
+
psm,
|
|
1498
|
+
text: String(text ?? '').trim(),
|
|
1499
|
+
words: words.length,
|
|
1500
|
+
quality: Number.isFinite(quality) ? quality : 0,
|
|
1501
|
+
riskyTokens,
|
|
1502
|
+
uncertain: words.length === 0 || quality < TESSERACT_REVIEW_THRESHOLD || riskyTokens.length > 0,
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
export function selectTesseractOcrCandidate(candidates) {
|
|
1507
|
+
const usable = (Array.isArray(candidates) ? candidates : []).filter((candidate) => candidate && candidate.text)
|
|
1508
|
+
if (usable.length === 0) return undefined
|
|
1509
|
+
let selected = usable[0]
|
|
1510
|
+
for (const candidate of usable.slice(1)) {
|
|
1511
|
+
if (candidate.quality >= selected.quality + TESSERACT_CANDIDATE_MARGIN) selected = candidate
|
|
1512
|
+
}
|
|
1513
|
+
return selected
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
export async function ocrWithTesseractAdaptive(bytes, timeoutMs = 12000, options = {}) {
|
|
1517
|
+
const exec = typeof options.exec === 'function' ? options.exec : promisify(execFile)
|
|
1518
|
+
const requestedBudget = Number(timeoutMs)
|
|
1519
|
+
const budgetMs = Number.isFinite(requestedBudget) && requestedBudget > 0
|
|
1520
|
+
? Math.min(60000, requestedBudget)
|
|
1521
|
+
: 12000
|
|
1522
|
+
const started = Date.now()
|
|
1523
|
+
const attemptedPsms = []
|
|
1524
|
+
const remaining = () => Math.max(1, budgetMs - (Date.now() - started))
|
|
1525
|
+
const run = async (psm) => {
|
|
1526
|
+
attemptedPsms.push(psm)
|
|
1527
|
+
const common = ['stdin', 'stdout', '-l', 'chi_sim+eng', '--psm', String(psm)]
|
|
1528
|
+
const execOptions = () => ({
|
|
1529
|
+
timeout: remaining(),
|
|
1530
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
1531
|
+
input: bytes,
|
|
1532
|
+
})
|
|
1533
|
+
const { stdout: plain } = await exec('tesseract', common, execOptions())
|
|
1534
|
+
const text = String(plain ?? '').trim()
|
|
1535
|
+
if (text === '') return assessTesseractOcr('', '', psm)
|
|
1536
|
+
try {
|
|
1537
|
+
const { stdout: tsv } = await exec('tesseract', [...common, 'tsv'], execOptions())
|
|
1538
|
+
return assessTesseractOcr(text, String(tsv ?? ''), psm)
|
|
1539
|
+
} catch {
|
|
1540
|
+
return { psm, text, words: 0, quality: 0, riskyTokens: [], uncertain: true }
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const candidates = [await run(TESSERACT_OCR_PSMS[0])]
|
|
1545
|
+
if (candidates[0].text !== '' && !candidates[0].uncertain) {
|
|
1546
|
+
return { ...candidates[0], attemptedPsms }
|
|
1547
|
+
}
|
|
1548
|
+
for (const psm of TESSERACT_OCR_PSMS.slice(1)) {
|
|
1549
|
+
if (Date.now() - started >= budgetMs) break
|
|
1550
|
+
try {
|
|
1551
|
+
candidates.push(await run(psm))
|
|
1552
|
+
} catch {
|
|
1553
|
+
// Keep the first successful local OCR result when an optional review pass fails.
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
const selected = selectTesseractOcrCandidate(candidates) ?? candidates[0]
|
|
1557
|
+
return { ...selected, attemptedPsms }
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1457
1560
|
/** Rough token estimate for one message (no tokenizer; conservative on purpose). */
|
|
1458
1561
|
export function estimateTokens(message) {
|
|
1459
1562
|
let chars = 0
|
|
@@ -1467,7 +1570,10 @@ export function estimateTokens(message) {
|
|
|
1467
1570
|
if (typeof block.text === 'string') chars += block.text.length
|
|
1468
1571
|
if (typeof block.arguments === 'string') chars += block.arguments.length
|
|
1469
1572
|
if (typeof block.name === 'string') chars += block.name.length
|
|
1470
|
-
if (block.type === 'image')
|
|
1573
|
+
if (block.type === 'image') {
|
|
1574
|
+
if (isOffloadedImageBlock(block)) chars += offloadedImagePlaceholder(block).length
|
|
1575
|
+
else images += 1
|
|
1576
|
+
}
|
|
1471
1577
|
if (Array.isArray(block.content)) block.content.forEach(walk)
|
|
1472
1578
|
}
|
|
1473
1579
|
if (message === null || message === undefined) return 0
|
|
@@ -1752,6 +1858,10 @@ export function localOllamaProvidersOf(config) {
|
|
|
1752
1858
|
model,
|
|
1753
1859
|
apiKeyEnv: '',
|
|
1754
1860
|
maxTokens: 2048,
|
|
1861
|
+
// Ollama's OpenAI-compatible Chat Completions endpoint supports
|
|
1862
|
+
// reasoning_effort='none'. Vision extraction should spend the bounded
|
|
1863
|
+
// completion budget on observable answer text rather than hidden thought.
|
|
1864
|
+
reasoningEffort: 'none',
|
|
1755
1865
|
// 仅显式选择 anthropic 格式时携带(默认 openai 路径保持字节不变)。
|
|
1756
1866
|
...(local.format === 'anthropic' ? { format: 'anthropic' } : {}),
|
|
1757
1867
|
// 建议值透传:温度/top_p 只在显式配置时携带(callOpenAICompatible
|
|
@@ -1872,6 +1982,9 @@ export async function callLocalBackend(provider, messages, options = {}) {
|
|
|
1872
1982
|
...(typeof options.resolveCredential === 'function'
|
|
1873
1983
|
? { resolveCredential: options.resolveCredential }
|
|
1874
1984
|
: {}),
|
|
1985
|
+
...(typeof provider.reasoningEffort === 'string'
|
|
1986
|
+
? { reasoningEffort: provider.reasoningEffort }
|
|
1987
|
+
: {}),
|
|
1875
1988
|
...sampling,
|
|
1876
1989
|
})
|
|
1877
1990
|
}
|
|
@@ -1949,6 +2062,9 @@ export function dedupeHttpProviders(pairs, httpProviders) {
|
|
|
1949
2062
|
/** Convert harness image/text blocks plus resolved image bytes into OpenAI wire content. */
|
|
1950
2063
|
export function toOpenAIContent(blocks, bytesOf) {
|
|
1951
2064
|
return blocks.map((block) => {
|
|
2065
|
+
if (isOffloadedImageBlock(block)) {
|
|
2066
|
+
return { type: 'text', text: offloadedImagePlaceholder(block) }
|
|
2067
|
+
}
|
|
1952
2068
|
if (block && block.type === 'image' && block.attachment) {
|
|
1953
2069
|
const bytes = bytesOf(block.attachment)
|
|
1954
2070
|
const data = Buffer.from(bytes).toString('base64')
|
|
@@ -2012,6 +2128,7 @@ export async function callOpenAICompatible(provider, messages, options = {}) {
|
|
|
2012
2128
|
if (resolvedApiKey === '') throw new Error(`http provider "${provider.name}": ${apiKeyEnv} is not set`)
|
|
2013
2129
|
headers.authorization = `Bearer ${resolvedApiKey}`
|
|
2014
2130
|
}
|
|
2131
|
+
const reasoningEffort = options.reasoningEffort ?? provider.reasoningEffort
|
|
2015
2132
|
const body = {
|
|
2016
2133
|
model: provider.model,
|
|
2017
2134
|
messages,
|
|
@@ -2021,6 +2138,9 @@ export async function callOpenAICompatible(provider, messages, options = {}) {
|
|
|
2021
2138
|
// never pass them, so the wire body stays byte-identical for main paths.
|
|
2022
2139
|
...(typeof options.temperature === 'number' ? { temperature: options.temperature } : {}),
|
|
2023
2140
|
...(typeof options.top_p === 'number' ? { top_p: options.top_p } : {}),
|
|
2141
|
+
...(['none', 'low', 'medium', 'high', 'max'].includes(reasoningEffort)
|
|
2142
|
+
? { reasoning_effort: reasoningEffort }
|
|
2143
|
+
: {}),
|
|
2024
2144
|
}
|
|
2025
2145
|
const url = `${provider.baseURL.replace(/\/$/, '')}/chat/completions`
|
|
2026
2146
|
const request = () =>
|
|
@@ -2162,9 +2282,10 @@ export function launchEnvironmentLike(env) {
|
|
|
2162
2282
|
}
|
|
2163
2283
|
|
|
2164
2284
|
/**
|
|
2165
|
-
*
|
|
2166
|
-
*
|
|
2167
|
-
*
|
|
2285
|
+
* Legacy-only DeepSeek reconstruction for Host contracts that do not own the
|
|
2286
|
+
* official provider lifecycle. Modern Hosts keep `deepseek-official` Host-owned
|
|
2287
|
+
* because request-local attachment/file/image-access seams cannot be cloned
|
|
2288
|
+
* safely here. Remove this helper when the legacy takeover window is retired.
|
|
2168
2289
|
*/
|
|
2169
2290
|
export function createNativeDeepSeekAdapter(ctx) {
|
|
2170
2291
|
const env = launchEnvironmentLike(
|
|
@@ -2262,7 +2383,7 @@ export async function buildInstantLocalMap(ctx, messages, provider, options = {}
|
|
|
2262
2383
|
for (const message of messages) {
|
|
2263
2384
|
if (!message || !Array.isArray(message.content)) continue
|
|
2264
2385
|
for (const block of message.content) {
|
|
2265
|
-
if (!block || block.type !== 'image' || !block.attachment) continue
|
|
2386
|
+
if (!block || block.type !== 'image' || !block.attachment || isOffloadedImageBlock(block)) continue
|
|
2266
2387
|
const attachment = block.attachment
|
|
2267
2388
|
const id = attachment.attachmentId || attachment.id || ''
|
|
2268
2389
|
if (id === '' || seen.has(id)) continue
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Bounded policy for stopping Host tools from re-parsing images after vision degradation. */
|
|
2
|
+
|
|
3
|
+
function flattenStrings(value, out = [], depth = 0) {
|
|
4
|
+
if (out.length >= 64 || depth > 6 || value == null) return out
|
|
5
|
+
if (typeof value === 'string') { out.push(value); return out }
|
|
6
|
+
if (Array.isArray(value)) {
|
|
7
|
+
for (const item of value) flattenStrings(item, out, depth + 1)
|
|
8
|
+
return out
|
|
9
|
+
}
|
|
10
|
+
if (typeof value === 'object') {
|
|
11
|
+
for (const item of Object.values(value)) flattenStrings(item, out, depth + 1)
|
|
12
|
+
}
|
|
13
|
+
return out
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizedEvidenceTokens(tokens) {
|
|
17
|
+
const out = []
|
|
18
|
+
for (const token of tokens) {
|
|
19
|
+
if (typeof token !== 'string' || token === '') continue
|
|
20
|
+
out.push(token)
|
|
21
|
+
if (token.startsWith('sha256:')) {
|
|
22
|
+
const digest = token.slice('sha256:'.length)
|
|
23
|
+
out.push(digest)
|
|
24
|
+
if (digest.length >= 16) out.push(digest.slice(0, 16))
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function shouldBlockDegradedHostTool(name, args, evidenceTokens = []) {
|
|
31
|
+
if (name !== 'bash' && name !== 'read_image') return false
|
|
32
|
+
const tokens = normalizedEvidenceTokens(evidenceTokens)
|
|
33
|
+
if (tokens.length === 0) return false
|
|
34
|
+
const strings = flattenStrings(args)
|
|
35
|
+
return strings.some((text) => {
|
|
36
|
+
if (text.includes('.dsh/attachments') || text.includes('/attachments/v1/objects/')) return true
|
|
37
|
+
return tokens.some(token => text.includes(token))
|
|
38
|
+
})
|
|
39
|
+
}
|