dsh-vision-router 2.1.7 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +8 -14
  2. package/README.zh.md +8 -14
  3. package/docs/architecture/compat-inventory.md +1 -1
  4. package/docs/architecture/dsh-compatibility-matrix.md +4 -1
  5. package/docs/architecture/dsh-support-window.md +1 -2
  6. package/docs/releases/v2.2.0.md +25 -0
  7. package/docs/remote-settings.md +2 -0
  8. package/entry.js +2 -0
  9. package/index.js +355 -64
  10. package/lib/artifact-boundary.js +11 -0
  11. package/lib/artifact-io.js +60 -7
  12. package/lib/catalog-corrections.js +5 -0
  13. package/lib/client.js +22 -17
  14. package/lib/core-primitives.js +115 -5
  15. package/lib/degraded-local-evidence.js +39 -0
  16. package/lib/dsh-contract-compat.js +202 -0
  17. package/lib/dsh-support-window.js +1 -1
  18. package/lib/image-offload-compat.js +38 -0
  19. package/lib/local-vision-stabilizer.js +15 -6
  20. package/lib/official-deepseek-catalog.js +120 -0
  21. package/lib/ollama-cold-start.js +3 -15
  22. package/lib/remote-settings-bridge.js +19 -1
  23. package/lib/replay-delegation.js +41 -21
  24. package/lib/runtime-composition.js +15 -2
  25. package/lib/runtime-i18n-boundary.js +4 -13
  26. package/lib/runtime-i18n.js +2 -2
  27. package/lib/session-surface-compat.js +40 -0
  28. package/lib/session-turn-resolver.js +57 -0
  29. package/lib/session-vision-index.js +206 -93
  30. package/lib/session-vision-mode-boundary.js +83 -6
  31. package/lib/session-vision-runtime.js +8 -2
  32. package/lib/session-vision-state.js +0 -13
  33. package/lib/tesseract-exec-compat.js +16 -2
  34. package/lib/twin-image-capability-fallback.js +2 -11
  35. package/lib/vision-artifact-store.js +16 -3
  36. package/lib/vision-attachment-handle-runtime.js +9 -21
  37. package/lib/vision-backend-runtime-policy.js +7 -6
  38. package/lib/vision-breaker-shadow-health.js +15 -5
  39. package/lib/vision-evidence-guidance.js +39 -0
  40. package/lib/vision-resilience.js +73 -1
  41. package/package.json +11 -6
package/index.js CHANGED
@@ -17,10 +17,10 @@
17
17
  // advanced vision-only override for `proxyHosts`; Host-owned visual adapters use
18
18
  // a scoped compatibility wrapper, never configuration-wide process routing.
19
19
 
20
- // Compatibility shim: dsh 0.1.2-alpha.4 removed `session.events` in favor of
21
- // `session.snapshotEvents()`. This helper returns an array (or undefined) that
22
- // works on both old and new harness versions.
23
- function getSessionEvents(session) {
20
+ // Legacy compatibility shim only. Modern supported Hosts use turnBoundary +
21
+ // SessionQuery for ordinary runtime reads; this path remains for older/partial
22
+ // Hosts that do not expose those capabilities.
23
+ function legacySessionEvents(session) {
24
24
  if (!session) return undefined
25
25
  // alpha.4+ : snapshotEvents() returns a frozen array of the event log
26
26
  if (typeof session.snapshotEvents === 'function') {
@@ -60,6 +60,7 @@ import {
60
60
  anthropicMediaType,
61
61
  } from './lib/catalog-corrections.js'
62
62
  import { createCachedUpdateChecker } from './lib/update-check.js'
63
+ import { getOfficialDeepSeekCatalog } from './lib/official-deepseek-catalog.js'
63
64
  import { probeLocalBackends } from './lib/local-connection-probe.js'
64
65
  import { detectDshSelfUpdatePlan, runDshPluginUpdate } from './lib/self-update.js'
65
66
  import {
@@ -95,6 +96,7 @@ import {
95
96
  scaleBox,
96
97
  scaledDimensions,
97
98
  } from './lib/image-resource-governor.js'
99
+ import { createSessionEventReader, createSessionEventTailReader, createSessionLogReader, hostOwnsOfficialDeepSeekProvider } from './lib/dsh-contract-compat.js'
98
100
  import { createSessionVisionIndex } from './lib/session-vision-index.js'
99
101
  import { createSessionVisionStateStore } from './lib/session-vision-state.js'
100
102
  import {
@@ -104,12 +106,22 @@ import {
104
106
  readResponseJsonBounded,
105
107
  readResponseTextBounded,
106
108
  } from './lib/http-body-limit.js'
107
- import { writeArtifactFile } from './lib/artifact-boundary.js'
109
+ import {
110
+ ARTIFACT_HANDOFF_RUN_ID,
111
+ ARTIFACT_RUNS_DIR,
112
+ normalizeArtifactsDir,
113
+ writeArtifactFile,
114
+ writePersistentArtifactFile,
115
+ } from './lib/artifact-boundary.js'
116
+ import { visionDescribeSuccessContext } from './lib/vision-evidence-guidance.js'
108
117
  import { stripTrailingSlashes } from './lib/string-normalization.js'
109
118
  import { streamWithLegacyGlobalProxyScope } from './lib/legacy-global-proxy-boundary.js'
110
119
  import { parseVersionComparator } from './lib/version-range.js'
111
120
  import { createCoalescingRunner } from './lib/adapter-update-coalescer.js'
112
121
  import { captureWindowsDesktop } from './lib/windows-desktop-capture.js'
122
+ import { blocksHaveRetainedImage, isOffloadedImageBlock, offloadedImagePlaceholder } from './lib/image-offload-compat.js'
123
+ import { createSessionTurnResolver } from './lib/session-turn-resolver.js'
124
+ import { shouldBlockDegradedHostTool } from './lib/degraded-local-evidence.js'
113
125
 
114
126
  import {
115
127
  sharpPromise,
@@ -168,7 +180,7 @@ export const Config = z.object({
168
180
  wrapperRoute: z.string().default('deepseek-vision'),
169
181
  chainRoute: z.string().default('vision-chain'),
170
182
  // 默认关闭(issue #34 明确 opt-in):关闭时官方 deepseek-official 路由
171
- // 原样保留;唯一例外见 apply 里的 keep-alive 兜底(官方行被禁用时)。
183
+ // 原样保留;仅 legacy Host 保留 keep-alive 接管兼容(官方行被禁用时)。
172
184
  stealth: z.boolean().default(false),
173
185
  textProvider: z
174
186
  .object({
@@ -386,6 +398,7 @@ import {
386
398
  posterizeSvgColor,
387
399
  resolveVisionOcrEngine,
388
400
  ocrWithTesseract,
401
+ ocrWithTesseractAdaptive,
389
402
  estimateTokens,
390
403
  estimateMessages,
391
404
  trimMessagesToBudget,
@@ -482,6 +495,7 @@ export {
482
495
  posterizeSvgColor,
483
496
  resolveVisionOcrEngine,
484
497
  ocrWithTesseract,
498
+ ocrWithTesseractAdaptive,
485
499
  estimateTokens,
486
500
  estimateMessages,
487
501
  trimMessagesToBudget,
@@ -549,6 +563,8 @@ export function apply(ctx, config = {}, runtime = {}) {
549
563
  // adapter boundaries that do not expose a Session; ambiguous attachment ids
550
564
  // deliberately miss instead of crossing conversations.
551
565
  const sessionVisionRuntime = runtime?.sessionVision
566
+ const hostOwnsOfficialDeepSeek = runtime?.hostOwnsOfficialDeepSeek
567
+ ?? hostOwnsOfficialDeepSeekProvider(ctx)
552
568
  const visionState = sessionVisionRuntime?.stateStore ?? createSessionVisionStateStore({
553
569
  maxSessions: 64,
554
570
  idleTtlMs: 60 * 60 * 1000,
@@ -566,6 +582,8 @@ export function apply(ctx, config = {}, runtime = {}) {
566
582
  },
567
583
  config: () => current(),
568
584
  logger: ctx.logger,
585
+ readSessionEvent: createSessionEventReader(ctx),
586
+ readSessionLog: createSessionLogReader(ctx),
569
587
  })
570
588
  const imageMemory = visionState.descriptionFacade
571
589
  // #208 follow-up complete: session-visible paths use scoped memory; only
@@ -696,11 +714,18 @@ export function apply(ctx, config = {}, runtime = {}) {
696
714
  const visionBreaker = createVisionCircuitBreaker()
697
715
  const visionTurnMemory = createVisionTurnMemory()
698
716
 
699
- // Same turn derivation the harness loop uses (dsh-agent-loop reads the last
700
- // turn/start event), so tool-side memory and pre-step bindings agree.
717
+ const sessionTurnResolver = runtime?.sessionTurnResolver ?? createSessionTurnResolver(ctx)
718
+ const sessionEventTailReader = runtime?.sessionEventTailReader ?? createSessionEventTailReader(ctx)
719
+
720
+ // Current stable/preview Hosts expose the Agent loop's `turnBoundary`
721
+ // Session projection. Runtime composition shares one resolver with shadow
722
+ // health so breaker scopes cannot diverge; rc.8 retains the resolver's
723
+ // explicit legacy event fallback.
701
724
  const turnNumberOf = (session) => {
702
725
  try {
703
- const events = getSessionEvents(session)
726
+ const projected = sessionTurnResolver.turnOf(session)
727
+ if (Number.isInteger(projected) && projected >= 0) return projected
728
+ const events = legacySessionEvents(session)
704
729
  if (!Array.isArray(events)) return 0
705
730
  const last = events.findLast((event) => event && event.type === 'turn/start')
706
731
  return last && Number.isInteger(last.data && last.data.turn) ? last.data.turn : 0
@@ -710,6 +735,41 @@ export function apply(ctx, config = {}, runtime = {}) {
710
735
  }
711
736
  const sessionIdOf = (session) => sessionIdentityOf(session) ?? 'anon'
712
737
  const visionScopeOf = (session) => `${sessionIdOf(session)}:${turnNumberOf(session)}`
738
+ const DEGRADED_LOCAL_REFINEMENT_LIMIT = 2
739
+ const visionEvidenceSourceKey = (value) => String(value ?? '').trim()
740
+ const degradedLocalFailure = (code, reason) => JSON.stringify({
741
+ ok: false,
742
+ code,
743
+ retryable: false,
744
+ reason,
745
+ })
746
+ const degradedLocalState = (session, source) => {
747
+ if (!session) return { active: false, scope: undefined, sourceKey: visionEvidenceSourceKey(source), used: 0 }
748
+ const scope = visionScopeOf(session)
749
+ const sourceKey = visionEvidenceSourceKey(source)
750
+ const active = visionTurnMemory.allFailed(scope) && visionTurnMemory.hasLocalOcr(scope, sourceKey)
751
+ return {
752
+ active,
753
+ scope,
754
+ sourceKey,
755
+ used: active ? visionTurnMemory.degradedRefinementCount(scope, sourceKey) : 0,
756
+ }
757
+ }
758
+
759
+ // DSH rc.8+ exposes a monotonic tool guard. Keep ordinary Host tools fully
760
+ // available, but do not let the same Agent rebuild an OCR/pixel-analysis
761
+ // pipeline from the current image bytes or Vision Router artifacts after every
762
+ // visual backend already failed and local OCR evidence exists for this turn.
763
+ if (typeof ctx.tools?.guard === 'function') {
764
+ ctx.tools.guard((exec) => {
765
+ const session = exec?.agent?.session
766
+ if (!session) return undefined
767
+ const scope = visionScopeOf(session)
768
+ const evidenceTokens = visionTurnMemory.degradedEvidenceTokens(scope)
769
+ if (!shouldBlockDegradedHostTool(exec.name, exec.arguments, evidenceTokens)) return undefined
770
+ return 'vision degraded-local evidence guard: do not reconstruct or re-parse this degraded image with Host tools after the visual backends failed; answer from the existing OCR evidence and state any remaining uncertainty'
771
+ })
772
+ }
713
773
 
714
774
  /** Stable, never-logged fingerprint of the credential a backend will use. */
715
775
  const credentialFingerprintOf = (value) => {
@@ -797,12 +857,10 @@ export function apply(ctx, config = {}, runtime = {}) {
797
857
  // image turns work. If the stock row is still active, taking over the route
798
858
  // throws DUPLICATE_ADAPTER and we fall back to the visible wrapper below.
799
859
  const stealthEnabled = current().stealth !== false
800
- // Keep-alive fallback: stealth off leaves the stock `deepseek-official`
801
- // route untouched — but when that route is dead (e.g. the official
802
- // llm-deepseek row is disabled in the profile patch layer), serving it
803
- // ourselves is the only way to keep the DeepSeek models in the picker.
804
- // The settings card surfaces this condition as a hint, and re-enabling
805
- // the stock row restores the fully official route.
860
+ // Legacy keep-alive fallback: older Hosts let DVR rebuild a missing stock
861
+ // `deepseek-official` route for compatibility. Newer Host generations own
862
+ // the provider's attachment/file lifecycle, so a missing official row is a
863
+ // Host configuration problem: DVR reports it and never reconstructs it.
806
864
  //
807
865
  // The takeover decision runs AFTER a short settle window, never inside
808
866
  // apply(): entry activation is service-driven, so this row can apply
@@ -888,11 +946,21 @@ export function apply(ctx, config = {}, runtime = {}) {
888
946
  if (adapterAvailable(ctx.llm, 'deepseek-official')) {
889
947
  if (stealthEnabled) {
890
948
  ctx.logger?.warn(
891
- 'vision-router: stealth is enabled but the stock deepseek-official route is alive; disable the llm-deepseek row to take it over',
949
+ hostOwnsOfficialDeepSeek
950
+ ? 'vision-router: stealth takeover is unavailable because this DSH Host owns deepseek-official; using the auto-vision wrapper instead'
951
+ : 'vision-router: legacy stealth takeover is enabled but the stock deepseek-official route is alive; disable llm-deepseek only on this legacy Host contract to take it over',
892
952
  )
893
953
  }
894
954
  return
895
955
  }
956
+ if (hostOwnsOfficialDeepSeek) {
957
+ takeoverAttempted = true
958
+ takeoverReason = 'host-owned-official-unavailable'
959
+ ctx.logger?.warn(
960
+ 'vision-router: deepseek-official is unavailable on a Host-owned provider contract; re-enable the llm-deepseek row because Vision Router will not recreate it',
961
+ )
962
+ return
963
+ }
896
964
  attemptTakeover(stealthEnabled ? 'stealth' : 'official-unavailable')
897
965
  }
898
966
  let takeoverSettled = false
@@ -1020,6 +1088,10 @@ export function apply(ctx, config = {}, runtime = {}) {
1020
1088
  const content = []
1021
1089
  for (const block of message.content) {
1022
1090
  if (block && block.type === 'image' && block.attachment) {
1091
+ if (isOffloadedImageBlock(block)) {
1092
+ content.push({ type: 'text', text: offloadedImagePlaceholder(block) })
1093
+ continue
1094
+ }
1023
1095
  if (attachments === undefined) continue
1024
1096
  try {
1025
1097
  const stored = await attachments.readImage(block.attachment)
@@ -1047,12 +1119,16 @@ export function apply(ctx, config = {}, runtime = {}) {
1047
1119
  if (nested && nested.type === 'text' && typeof nested.text === 'string') {
1048
1120
  parts.push(nested.text)
1049
1121
  } else if (nested && nested.type === 'image') {
1050
- const attachment = nested.attachment || {}
1051
- const id = attachment.attachmentId || attachment.id || 'unknown'
1052
- parts.push(
1053
- `[attached image: ${id}] this tool result contained an image; ` +
1054
- 'inspect it with vision_describe (or re-read it with read_image)',
1055
- )
1122
+ if (isOffloadedImageBlock(nested)) {
1123
+ parts.push(offloadedImagePlaceholder(nested))
1124
+ } else {
1125
+ const attachment = nested.attachment || {}
1126
+ const id = attachment.attachmentId || attachment.id || 'unknown'
1127
+ parts.push(
1128
+ `[attached image: ${id}] this tool result contained an image; ` +
1129
+ 'inspect it with vision_describe (or re-read it with read_image)',
1130
+ )
1131
+ }
1056
1132
  }
1057
1133
  }
1058
1134
  if (parts.length > 0) {
@@ -1173,7 +1249,7 @@ export function apply(ctx, config = {}, runtime = {}) {
1173
1249
  const real = delegateAdapter()
1174
1250
  if (real !== undefined && typeof real.listModels === 'function') {
1175
1251
  try {
1176
- const listed = await real.listModels('deepseek-official')
1252
+ const listed = await getOfficialDeepSeekCatalog(real)
1177
1253
  entries.push(
1178
1254
  ...(Array.isArray(listed) ? listed : [])
1179
1255
  .filter((model) => model && typeof model.id === 'string' && model.id !== '')
@@ -1239,7 +1315,7 @@ export function apply(ctx, config = {}, runtime = {}) {
1239
1315
  if (typeof real.listModels !== 'function') {
1240
1316
  throw new Error('vision-router: the official DeepSeek catalog is not available')
1241
1317
  }
1242
- const listed = await real.listModels('deepseek-official')
1318
+ const listed = await getOfficialDeepSeekCatalog(real)
1243
1319
  const admitted = Array.isArray(listed) && listed.some(
1244
1320
  (entry) => entry && entry.id === model,
1245
1321
  )
@@ -1984,7 +2060,9 @@ export function apply(ctx, config = {}, runtime = {}) {
1984
2060
  if (!message || message.role !== 'user' || !Array.isArray(message.content)) continue
1985
2061
  // Deep collection: images nested inside tool-result blocks also
1986
2062
  // identify this turn's subject and deserve memory recording.
1987
- for (const found of collectImageBlocks([message])) imageIds.push(found.id)
2063
+ for (const found of collectImageBlocks([message])) {
2064
+ if (!isOffloadedImageBlock(found.block)) imageIds.push(found.id)
2065
+ }
1988
2066
  if (imageIds.length > 0) break
1989
2067
  }
1990
2068
  let finalText = ''
@@ -2257,16 +2335,100 @@ export function apply(ctx, config = {}, runtime = {}) {
2257
2335
  },
2258
2336
  'vision-router: reactive routing mounts',
2259
2337
  )
2260
- // #208: attachment refs, description memory and the event-log cursor are
2261
- // owned by the same bounded SessionVisionStateStore above.
2338
+ // #208: attachment refs and description memory are owned by the same
2339
+ // bounded SessionVisionStateStore above. Historical ref recovery stays
2340
+ // target-only inside SessionVisionIndex rather than maintaining a log cursor.
2262
2341
 
2263
2342
  // Host-owned proxy overrides are scoped by lib/legacy-global-proxy-boundary.js.
2264
2343
  // Core no longer owns or installs a process-wide proxy fetch implementation.
2265
2344
 
2266
- const lookupAttachment = (session, id) => sessionVisionIndex.lookupAttachment(session, id)
2345
+ const resolveAttachment = (session, id) => sessionVisionIndex.resolveAttachment(session, id)
2346
+ const resolveAttachments = (session, ids) => sessionVisionIndex.resolveAttachments(session, ids)
2267
2347
 
2268
- // session -> { turn, startIndex, hasImage, routed, failures, lastError }
2348
+ // Session-local routing handoff between pre-step and agent/request. Modern
2349
+ // Hosts store one exact async raw-log tail seq; legacy Hosts retain only the
2350
+ // released synchronous array index fallback.
2269
2351
  const turnState = new WeakMap()
2352
+ const midTurnReadWarnings = new WeakMap()
2353
+
2354
+ const warnMidTurnReadFailure = (session, error) => {
2355
+ const message = String(error?.message ?? error ?? '').slice(0, 400)
2356
+ if (midTurnReadWarnings.get(session) === message) return
2357
+ midTurnReadWarnings.set(session, message)
2358
+ ctx.logger?.warn?.('vision-router: mid-turn Session event read failed; routing conservatively to vision: %s', message)
2359
+ }
2360
+
2361
+ const legacyTurnCapture = (session) => ({
2362
+ legacyStartIndex: (legacySessionEvents(session) ?? []).length,
2363
+ })
2364
+
2365
+ const captureTurnTail = async (session) => {
2366
+ const anchorSeq = sessionTurnResolver?.eventAnchorOf?.(session)
2367
+ if (!Number.isSafeInteger(anchorSeq) || anchorSeq < 0) return legacyTurnCapture(session)
2368
+ if (typeof sessionEventTailReader !== 'function') return legacyTurnCapture(session)
2369
+ try {
2370
+ const tail = await sessionEventTailReader(session, anchorSeq, { collect: false })
2371
+ if (tail?.supported === false) return legacyTurnCapture(session)
2372
+ if (tail?.supported !== true || !Number.isSafeInteger(tail.capturedThroughSeq)) {
2373
+ warnMidTurnReadFailure(session, new Error('Session tail reader returned an invalid capability result'))
2374
+ return { scanUnknown: true }
2375
+ }
2376
+ midTurnReadWarnings.delete(session)
2377
+ return tail.truncated === true
2378
+ ? { capturedThroughSeq: tail.capturedThroughSeq, scanUnknown: true }
2379
+ : { capturedThroughSeq: tail.capturedThroughSeq }
2380
+ } catch (error) {
2381
+ warnMidTurnReadFailure(session, error)
2382
+ return { scanUnknown: true }
2383
+ }
2384
+ }
2385
+
2386
+ const refreshTurnImageState = async (session, state) => {
2387
+ if (state.hasImage) return
2388
+ if (state.scanUnknown === true) {
2389
+ state.hasImage = true
2390
+ return
2391
+ }
2392
+ if (Number.isSafeInteger(state.capturedThroughSeq)) {
2393
+ try {
2394
+ const tail = await sessionEventTailReader(session, state.capturedThroughSeq)
2395
+ if (tail?.supported !== true || !Number.isSafeInteger(tail.capturedThroughSeq)) {
2396
+ state.hasImage = true
2397
+ warnMidTurnReadFailure(session, new Error('Session tail reader became unavailable after capture'))
2398
+ return
2399
+ }
2400
+ midTurnReadWarnings.delete(session)
2401
+ state.capturedThroughSeq = tail.capturedThroughSeq
2402
+ if (tail.truncated === true || tail.events.some((event) => eventHasImage(event))) state.hasImage = true
2403
+ return
2404
+ } catch (error) {
2405
+ state.hasImage = true
2406
+ warnMidTurnReadFailure(session, error)
2407
+ return
2408
+ }
2409
+ }
2410
+
2411
+ const events = legacySessionEvents(session) ?? []
2412
+ const startIndex = Number.isSafeInteger(state.legacyStartIndex) ? state.legacyStartIndex : 0
2413
+ for (let i = startIndex; i < events.length; i++) {
2414
+ if (eventHasImage(events[i])) {
2415
+ state.hasImage = true
2416
+ break
2417
+ }
2418
+ }
2419
+ state.legacyStartIndex = events.length
2420
+ }
2421
+
2422
+ ctx.on('tools/post-execute', async (exec, result, next) => {
2423
+ const downstream = await next()
2424
+ if (downstream?.kind !== 'accept') return downstream
2425
+ const context = visionDescribeSuccessContext(exec, result)
2426
+ if (!context) return downstream
2427
+ return {
2428
+ ...downstream,
2429
+ additionalContexts: [context, ...(downstream.additionalContexts ?? [])],
2430
+ }
2431
+ })
2270
2432
 
2271
2433
  ctx.on('agent/pre-step', async (payload, next) => {
2272
2434
  let decision = await next()
@@ -2291,7 +2453,7 @@ export function apply(ctx, config = {}, runtime = {}) {
2291
2453
  // settings 无该 key)一眼可见 "instant=off"。
2292
2454
  if (ctx.logger) {
2293
2455
  const hasImage = rawMessages.some(
2294
- (message) => message && Array.isArray(message.content) && blocksHaveImage(message.content),
2456
+ (message) => message && Array.isArray(message.content) && blocksHaveRetainedImage(message.content),
2295
2457
  )
2296
2458
  if (hasImage) {
2297
2459
  ctx.logger.info(
@@ -2308,18 +2470,18 @@ export function apply(ctx, config = {}, runtime = {}) {
2308
2470
  // repair; Core retains only the current inbox sanitizer.
2309
2471
  const sanitizedToolResults = sanitizeToolResultImages(rawMessages)
2310
2472
  const messages = sanitizedToolResults.messages
2311
- const hasImage = messages.some((message) => blocksHaveImage(message && message.content))
2473
+ const hasImage = messages.some((message) => blocksHaveRetainedImage(message && message.content))
2312
2474
 
2313
2475
  // Register the turn state BEFORE the image-turn branches below: those
2314
2476
  // branches return early (auto-mount reminder, history rewrite), and the
2315
2477
  // agent/request hook must still see the state, otherwise an image turn is
2316
2478
  // served by the text provider and rejected (issue #74, second root cause).
2317
2479
  if (routingEnabled()) {
2318
- const events = getSessionEvents(session) ?? []
2480
+ const capture = hasImage ? {} : await captureTurnTail(session)
2319
2481
  turnState.set(session, {
2320
2482
  turn: payload.turn,
2321
- startIndex: events.length,
2322
2483
  hasImage,
2484
+ ...capture,
2323
2485
  })
2324
2486
  }
2325
2487
  let bootstrapState = structuredBootstrapTurnState.get(session)
@@ -2536,15 +2698,7 @@ export function apply(ctx, config = {}, runtime = {}) {
2536
2698
  if (!session) return config0
2537
2699
  const state = turnState.get(session)
2538
2700
  if (!state || state.turn !== payload.turn) return config0
2539
- if (!state.hasImage) {
2540
- const events = getSessionEvents(session) ?? []
2541
- for (let i = state.startIndex; i < events.length; i++) {
2542
- if (eventHasImage(events[i])) {
2543
- state.hasImage = true
2544
- break
2545
- }
2546
- }
2547
- }
2701
+ if (!state.hasImage) await refreshTurnImageState(session, state)
2548
2702
  if (!state.hasImage) {
2549
2703
  // Reverse routing: the session's entry model is a vision provider
2550
2704
  // (needed to pass the prompt admission); send text-only turns back
@@ -2654,6 +2808,10 @@ export function apply(ctx, config = {}, runtime = {}) {
2654
2808
  ...attachmentIds.map((id) => String(id)).filter((id) => isAttachmentIdInput(id)),
2655
2809
  ...paths.map((item) => String(item)).filter((item) => isAttachmentIdInput(item)),
2656
2810
  ])]
2811
+ const attachmentSession = exec && exec.agent && exec.agent.session
2812
+ const resolvedAttachmentRefs = materializableAttachmentIds.length > 0
2813
+ ? await resolveAttachments(attachmentSession, materializableAttachmentIds)
2814
+ : new Map()
2657
2815
 
2658
2816
  for (const path of paths) {
2659
2817
  let bytes
@@ -2662,7 +2820,7 @@ export function apply(ctx, config = {}, runtime = {}) {
2662
2820
  // readImageBytes accepts both filesystem paths and attachment ids
2663
2821
  // ("sha256:..."), so a model that passes an uploaded image's id as
2664
2822
  // a path gets the right pixels instead of a not-found error.
2665
- ;({ bytes, mediaType } = await readImageBytes(exec, path))
2823
+ ;({ bytes, mediaType } = await readImageBytes(exec, path, resolvedAttachmentRefs))
2666
2824
  } catch (error) {
2667
2825
  throw new Error(
2668
2826
  `vision_describe: failed to read ${path} (${error && error.message ? error.message : String(error)})`,
@@ -2694,8 +2852,10 @@ export function apply(ctx, config = {}, runtime = {}) {
2694
2852
  }
2695
2853
 
2696
2854
  for (const id of attachmentIds) {
2697
- const session = exec && exec.agent && exec.agent.session
2698
- const ref = lookupAttachment(session, String(id))
2855
+ const attachmentId = String(id)
2856
+ const ref = isAttachmentIdInput(attachmentId)
2857
+ ? resolvedAttachmentRefs.get(attachmentId)
2858
+ : await resolveAttachment(attachmentSession, attachmentId)
2699
2859
  if (ref === undefined) {
2700
2860
  throw new Error(
2701
2861
  `vision_describe: unknown attachment id "${id}" (it must come from an image uploaded in this conversation)`,
@@ -3078,7 +3238,7 @@ ctx.logger?.info(
3078
3238
  tool: 'vision_materialize',
3079
3239
  attachmentIds: materializableAttachmentIds,
3080
3240
  advice:
3081
- 'If another local/OCR tool requires a filesystem path, call vision_materialize for the uploaded attachment id. Do not guess a filename or the attachment store path.',
3241
+ 'For text transcription, call vision_ocr with {"image":"<attachment id>","engine":"tesseract"}; vision_ocr accepts uploaded attachment ids directly. Use vision_materialize only when a separate non-Vision-Router local parser genuinely requires a filesystem path. Do not guess a filename or the attachment store path.',
3082
3242
  }
3083
3243
  }
3084
3244
  return JSON.stringify(baseFailure)
@@ -3189,7 +3349,31 @@ ctx.logger?.info(
3189
3349
  ? config.artifactsDir
3190
3350
  : '.dsh-vision-router/artifacts'
3191
3351
 
3192
- const readImageBytes = async (exec, imagePath) => {
3352
+ const resolveOcrImageInput = (args = {}) => {
3353
+ const hasImageField = Object.prototype.hasOwnProperty.call(args, 'image')
3354
+ const image = typeof args.image === 'string' ? args.image : undefined
3355
+ const hasAttachmentIdsField = Object.prototype.hasOwnProperty.call(args, 'attachmentIds')
3356
+ const attachmentIds = Array.isArray(args.attachmentIds) ? args.attachmentIds : []
3357
+
3358
+ if (hasImageField && (image === undefined || image.trim() === '')) {
3359
+ throw new Error('vision_ocr: image must be a non-empty path or attachment id')
3360
+ }
3361
+ if (hasAttachmentIdsField && !Array.isArray(args.attachmentIds)) {
3362
+ throw new Error('vision_ocr: attachmentIds must be an array containing exactly one uploaded attachment id')
3363
+ }
3364
+ if (hasImageField && hasAttachmentIdsField) {
3365
+ throw new Error('vision_ocr: provide exactly one image using image or attachmentIds, not both')
3366
+ }
3367
+ if (hasImageField) return image
3368
+ if (attachmentIds.length !== 1 || !isAttachmentIdInput(attachmentIds[0])) {
3369
+ throw new Error(
3370
+ 'vision_ocr: provide one image via image or exactly one uploaded attachment id via attachmentIds',
3371
+ )
3372
+ }
3373
+ return String(attachmentIds[0]).trim()
3374
+ }
3375
+
3376
+ const readImageBytes = async (exec, imagePath, resolvedAttachmentRefs) => {
3193
3377
  const input = String(imagePath ?? '')
3194
3378
  let bytes
3195
3379
  let storedMediaType
@@ -3202,7 +3386,10 @@ ctx.logger?.info(
3202
3386
  throw new Error('vision-router: the attachment service is not available in this deployment')
3203
3387
  }
3204
3388
  const session = exec && exec.agent && exec.agent.session
3205
- const ref = lookupAttachment(session, input.trim())
3389
+ const canonicalInput = input.trim()
3390
+ const ref = resolvedAttachmentRefs instanceof Map
3391
+ ? resolvedAttachmentRefs.get(canonicalInput)
3392
+ : await resolveAttachment(session, canonicalInput)
3206
3393
  if (ref === undefined) {
3207
3394
  throw new Error(
3208
3395
  `vision-router: unknown attachment id "${input}" (it must come from an image uploaded in this conversation)`,
@@ -3250,6 +3437,8 @@ ctx.logger?.info(
3250
3437
 
3251
3438
  const saveArtifact = async (exec, relPath, data) =>
3252
3439
  writeArtifactFile(workspaceOf(exec), artifactsRel, relPath, data)
3440
+ const savePersistentArtifact = async (exec, relPath, data) =>
3441
+ writePersistentArtifactFile(workspaceOf(exec), artifactsRel, relPath, data)
3253
3442
 
3254
3443
  const artifactStem = (imagePath, suffix) => artifactStemOf(imagePath, suffix)
3255
3444
 
@@ -3263,7 +3452,8 @@ ctx.logger?.info(
3263
3452
  deepToolDefs.push({
3264
3453
  name: 'vision_materialize',
3265
3454
  description:
3266
- 'Copy an uploaded image attachment (sha256:...) or readable local image into the session workspace and return a real filesystem path. ' +
3455
+ 'Copy an uploaded image attachment (sha256:...) or readable local image into a stable content-addressed file in the session workspace. ' +
3456
+ 'Returns both an absolute path and a shorter workspaceRelativePath; prefer workspaceRelativePath in later tool or shell calls to avoid copying long internal paths. ' +
3267
3457
  'This tool performs NO vision model/network call. Use it after vision_describe/vision_bootstrap returns ok:false when a local OCR/parser accepts only file_path. ' +
3268
3458
  'Never guess the attachment store path or search for a same-named file.',
3269
3459
  parameters: {
@@ -3277,6 +3467,14 @@ ctx.logger?.info(
3277
3467
  output: stringOutput,
3278
3468
  async execute(args, exec) {
3279
3469
  const source = String(args.image ?? '')
3470
+ const session = exec?.agent?.session
3471
+ const degraded = degradedLocalState(session, source)
3472
+ if (degraded.active) {
3473
+ return degradedLocalFailure(
3474
+ 'VISION_LOCAL_EVIDENCE_AVAILABLE',
3475
+ 'local OCR evidence already exists for this image and every configured vision backend has failed this turn; do not materialize the image to rebuild another parser/OCR pipeline',
3476
+ )
3477
+ }
3280
3478
  const { bytes, mediaType } = await readImageBytes(exec, source)
3281
3479
  const extension = mediaType === 'image/jpeg'
3282
3480
  ? 'jpg'
@@ -3285,9 +3483,26 @@ ctx.logger?.info(
3285
3483
  : mediaType === 'image/gif'
3286
3484
  ? 'gif'
3287
3485
  : 'png'
3288
- const target = await saveArtifact(exec, `${artifactStem(source, 'materialized')}.${extension}`, bytes)
3486
+ const fingerprint = createHash('sha256').update(bytes).digest('hex').slice(0, 20)
3487
+ const relativeArtifactPath = path.join('materialized', `${fingerprint}.${extension}`)
3488
+ const artifactName = path.basename(relativeArtifactPath)
3489
+ const target = await savePersistentArtifact(exec, relativeArtifactPath, bytes)
3490
+ const workspaceRelativePath = path.join(
3491
+ normalizeArtifactsDir(artifactsRel),
3492
+ ARTIFACT_RUNS_DIR,
3493
+ ARTIFACT_HANDOFF_RUN_ID,
3494
+ relativeArtifactPath,
3495
+ ).split(path.sep).join('/')
3496
+ if (session) {
3497
+ visionTurnMemory.recordDerivedArtifact(
3498
+ visionScopeOf(session),
3499
+ visionEvidenceSourceKey(source),
3500
+ artifactName,
3501
+ )
3502
+ }
3289
3503
  return JSON.stringify({
3290
3504
  path: target,
3505
+ workspaceRelativePath,
3291
3506
  mediaType,
3292
3507
  bytes: bytes.length,
3293
3508
  ...(isAttachmentIdInput(source) ? { source } : {}),
@@ -3678,6 +3893,14 @@ ctx.logger?.info(
3678
3893
  },
3679
3894
  output: stringOutput,
3680
3895
  async execute(args, exec) {
3896
+ const session = exec?.agent?.session
3897
+ const degraded = degradedLocalState(session, args.image)
3898
+ if (degraded.active && degraded.used >= DEGRADED_LOCAL_REFINEMENT_LIMIT) {
3899
+ return degradedLocalFailure(
3900
+ 'VISION_DEGRADED_LOCAL_LIMIT',
3901
+ `the degraded local evidence budget for this image is exhausted after ${DEGRADED_LOCAL_REFINEMENT_LIMIT} refinement call(s); answer from existing evidence and state any remaining uncertainty`,
3902
+ )
3903
+ }
3681
3904
  const { bytes } = await readImageBytes(exec, args.image)
3682
3905
  const { width, height } = await imageDims(bytes)
3683
3906
  const box = parseBox(args.region)
@@ -3709,11 +3932,17 @@ ctx.logger?.info(
3709
3932
  } finally {
3710
3933
  releaseCrop()
3711
3934
  }
3712
- const target = await saveArtifact(
3713
- exec,
3714
- `${artifactStem(args.image, `crop-${box.x1}-${box.y1}-${box.x2}-${box.y2}`)}.png`,
3715
- cropped,
3716
- )
3935
+ const artifactName = `${artifactStem(args.image, `crop-${box.x1}-${box.y1}-${box.x2}-${box.y2}`)}.png`
3936
+ const target = await saveArtifact(exec, artifactName, cropped)
3937
+ if (session) {
3938
+ const scope = visionScopeOf(session)
3939
+ visionTurnMemory.recordDerivedArtifact(
3940
+ scope,
3941
+ visionEvidenceSourceKey(args.image),
3942
+ artifactName,
3943
+ )
3944
+ if (degraded.active) visionTurnMemory.recordDegradedRefinement(scope, degraded.sourceKey)
3945
+ }
3717
3946
  const meta = await sharp(cropped).metadata()
3718
3947
  return JSON.stringify({
3719
3948
  path: target,
@@ -3978,34 +4207,83 @@ ctx.logger?.info(
3978
4207
  'ACCURACY: OCR transcribes characters verbatim and is systematically unreliable for confusable ' +
3979
4208
  'glyphs (1/l, 0/O), spacing and line breaks; prefer vision_describe / vision_detect for semantic ' +
3980
4209
  'understanding and use OCR only when exact verbatim text is required (executable code, exact ' +
3981
- 'quotation, forms/contracts, table digits, CAPTCHAs). Treat OCR output as evidence to verify, ' +
3982
- 'never as ground truth.',
4210
+ 'quotation, forms/contracts, table digits, CAPTCHAs). Local OCR uses a bounded layout review when ' +
4211
+ 'the first pass looks weak. If the result has uncertain:true, cross-check the ambiguous text when ' +
4212
+ 'another visual backend is available; otherwise state the remaining uncertainty. If uncertain:false ' +
4213
+ 'and the text directly answers the user, do not call more tools merely to re-prove the same text. ' +
4214
+ 'INPUT: `image` is the canonical single-image argument. For compatibility with other Vision Router ' +
4215
+ 'tools, one uploaded image may instead be passed as `attachmentIds: [id]`; do not pass both forms ' +
4216
+ 'or more than one attachment id.',
3983
4217
  parameters: {
3984
4218
  type: 'object',
3985
4219
  properties: {
3986
- image: { type: 'string', description: 'Local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
4220
+ image: { type: 'string', description: 'Canonical single-image input: local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
4221
+ attachmentIds: {
4222
+ type: 'array',
4223
+ items: { type: 'string' },
4224
+ minItems: 1,
4225
+ maxItems: 1,
4226
+ description: 'Compatibility alias for one uploaded image attachment id. Use exactly one sha256:... id. Do not combine with image.',
4227
+ },
3987
4228
  engine: {
3988
4229
  type: 'string',
3989
4230
  description: '"auto" (default): always try local Tesseract first, then fall back to the vision model if local OCR fails or returns no text. Structured 1+x does not change this order; use explicit "tesseract"/"vision" to force an engine.',
3990
4231
  },
3991
4232
  },
3992
- required: ['image'],
3993
4233
  additionalProperties: false,
3994
4234
  },
3995
4235
  output: stringOutput,
3996
4236
  async execute(args, exec) {
3997
- const { bytes, mediaType } = await readImageBytes(exec, args.image)
4237
+ const imageInput = resolveOcrImageInput(args)
4238
+ const session = exec?.agent?.session
3998
4239
  const engine = resolveVisionOcrEngine(args.engine)
4240
+ const degraded = degradedLocalState(session, imageInput)
4241
+ if (
4242
+ engine !== 'vision' &&
4243
+ degraded.active &&
4244
+ degraded.used >= DEGRADED_LOCAL_REFINEMENT_LIMIT
4245
+ ) {
4246
+ return degradedLocalFailure(
4247
+ 'VISION_DEGRADED_LOCAL_LIMIT',
4248
+ `the degraded local evidence budget for this image is exhausted after ${DEGRADED_LOCAL_REFINEMENT_LIMIT} refinement call(s); answer from existing evidence and state any remaining uncertainty`,
4249
+ )
4250
+ }
4251
+ const { bytes, mediaType } = await readImageBytes(exec, imageInput)
3999
4252
  // ONE OCR budget shared by tesseract AND the vision fallback: tesseract
4000
4253
  // gets a capped slice (never more than 12s), the vision model only the
4001
4254
  // remainder. The two timeouts can never stack into a multi-minute wait.
4002
4255
  const deadline = createDeadline(ocrBudgetMs())
4003
4256
  const tesseractSlice = Math.min(12000, deadline.remaining())
4004
4257
  if (engine !== 'vision') {
4258
+ let localAttempted = false
4005
4259
  try {
4006
- const text = await ocrWithTesseract(bytes, tesseractSlice)
4007
- if (text.trim() !== '') return JSON.stringify({ engine: 'tesseract', text: text.trim() })
4008
- if (engine === 'tesseract') return JSON.stringify({ engine: 'tesseract', text: '' })
4260
+ localAttempted = true
4261
+ const local = await ocrWithTesseractAdaptive(bytes, tesseractSlice)
4262
+ if (local.text.trim() !== '') {
4263
+ if (session) {
4264
+ visionTurnMemory.recordLocalOcr(
4265
+ visionScopeOf(session),
4266
+ visionEvidenceSourceKey(imageInput),
4267
+ { uncertain: local.uncertain === true },
4268
+ )
4269
+ }
4270
+ return JSON.stringify({
4271
+ engine: 'tesseract',
4272
+ text: local.text.trim(),
4273
+ uncertain: local.uncertain === true,
4274
+ ...(local.uncertain === true
4275
+ ? {
4276
+ review: {
4277
+ psm: local.psm,
4278
+ attemptedPsms: local.attemptedPsms,
4279
+ quality: Number(local.quality.toFixed(2)),
4280
+ riskyTokens: local.riskyTokens,
4281
+ },
4282
+ }
4283
+ : {}),
4284
+ })
4285
+ }
4286
+ if (engine === 'tesseract') return JSON.stringify({ engine: 'tesseract', text: '', uncertain: true })
4009
4287
  } catch (error) {
4010
4288
  if (engine === 'tesseract') {
4011
4289
  throw new Error(
@@ -4013,6 +4291,13 @@ ctx.logger?.info(
4013
4291
  )
4014
4292
  }
4015
4293
  ctx.logger?.warn('vision-router: tesseract OCR unavailable, falling back to vision model')
4294
+ } finally {
4295
+ if (degraded.active && localAttempted && session) {
4296
+ visionTurnMemory.recordDegradedRefinement(
4297
+ visionScopeOf(session),
4298
+ degraded.sourceKey,
4299
+ )
4300
+ }
4016
4301
  }
4017
4302
  }
4018
4303
  if (deadline.expired()) {
@@ -4850,10 +5135,16 @@ ctx.logger?.info(
4850
5135
  // Runtime takeover state: lets the settings card explain the
4851
5136
  // keep-alive fallback when stealth is off but the stock route is
4852
5137
  // disabled at the composition layer.
5138
+ const officialRouteAvailable = adapterAvailable(ctx.llm, 'deepseek-official')
4853
5139
  result.stealth = {
4854
5140
  configured: stealthEnabled,
4855
5141
  active: stealthActive,
4856
- reason: stealthActive ? takeoverReason : undefined,
5142
+ reason: stealthActive
5143
+ ? takeoverReason
5144
+ : hostOwnsOfficialDeepSeek && !officialRouteAvailable
5145
+ ? 'host-owned-official-unavailable'
5146
+ : undefined,
5147
+ hostOwned: hostOwnsOfficialDeepSeek,
4857
5148
  }
4858
5149
  res.writeHead(result.ok ? 200 : 502, { 'content-type': 'application/json' })
4859
5150
  res.end(JSON.stringify(result))