dsh-vision-router 2.1.6 → 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 (49) hide show
  1. package/README.md +8 -14
  2. package/README.zh.md +8 -14
  3. package/cordis.patch.yml +14 -0
  4. package/docs/architecture/compat-inventory.md +1 -1
  5. package/docs/architecture/dsh-compatibility-matrix.md +4 -1
  6. package/docs/architecture/dsh-support-window.md +1 -2
  7. package/docs/releases/v2.1.7.md +13 -0
  8. package/docs/releases/v2.2.0.md +25 -0
  9. package/docs/remote-settings.md +2 -0
  10. package/entry.js +2 -0
  11. package/index.js +387 -77
  12. package/lib/artifact-boundary.js +11 -0
  13. package/lib/artifact-io.js +60 -7
  14. package/lib/catalog-corrections.js +5 -0
  15. package/lib/client.js +293 -48
  16. package/lib/core-primitives.js +122 -7
  17. package/lib/degraded-local-evidence.js +39 -0
  18. package/lib/dsh-contract-compat.js +202 -0
  19. package/lib/dsh-support-window.js +1 -1
  20. package/lib/image-offload-compat.js +38 -0
  21. package/lib/live-model-discovery.js +125 -15
  22. package/lib/local-vision-stabilizer.js +15 -6
  23. package/lib/official-deepseek-catalog.js +120 -0
  24. package/lib/ollama-cold-start.js +3 -15
  25. package/lib/remote-settings-bridge.js +28 -5
  26. package/lib/replay-delegation.js +68 -42
  27. package/lib/runtime-composition.js +15 -2
  28. package/lib/runtime-config-normalizer.js +271 -1
  29. package/lib/runtime-i18n-boundary.js +4 -13
  30. package/lib/runtime-i18n.js +2 -2
  31. package/lib/session-surface-compat.js +40 -0
  32. package/lib/session-turn-resolver.js +57 -0
  33. package/lib/session-vision-index.js +206 -93
  34. package/lib/session-vision-mode-boundary.js +83 -6
  35. package/lib/session-vision-runtime.js +8 -2
  36. package/lib/session-vision-state.js +0 -13
  37. package/lib/tesseract-exec-compat.js +16 -2
  38. package/lib/twin-image-capability-fallback.js +2 -11
  39. package/lib/vision-artifact-store.js +16 -3
  40. package/lib/vision-attachment-handle-runtime.js +9 -21
  41. package/lib/vision-backend-runtime-policy.js +7 -6
  42. package/lib/vision-background-stop-store.js +30 -20
  43. package/lib/vision-breaker-shadow-health.js +15 -5
  44. package/lib/vision-capability-probe.js +24 -14
  45. package/lib/vision-evidence-guidance.js +39 -0
  46. package/lib/vision-image-input-verdict.js +22 -7
  47. package/lib/vision-resilience.js +73 -1
  48. package/lib/web-capability-boundary.js +85 -22
  49. package/package.json +14 -7
@@ -123,7 +123,6 @@ function createState(key, stable, options, now) {
123
123
  attachments: new WeightedLruMap({
124
124
  maxEntries: options.attachmentMaxEntries,
125
125
  }),
126
- scannedEventSeq: 0,
127
126
  }
128
127
  }
129
128
 
@@ -356,17 +355,6 @@ export function createSessionVisionStateStore(config = {}) {
356
355
  return stateFor(session, false)?.attachments.get(id)
357
356
  },
358
357
 
359
- getScannedEventSeq(session) {
360
- return stateFor(session, false)?.scannedEventSeq ?? 0
361
- },
362
-
363
- setScannedEventSeq(session, seq) {
364
- const state = stateFor(session, true)
365
- if (state === undefined) return
366
- const next = Number.isFinite(Number(seq)) && Number(seq) >= 0 ? Math.floor(Number(seq)) : 0
367
- state.scannedEventSeq = next
368
- touchState(state)
369
- },
370
358
 
371
359
  forgetSession(sessionOrId) {
372
360
  const id =
@@ -389,7 +377,6 @@ export function createSessionVisionStateStore(config = {}) {
389
377
  descriptions: state.descriptions.size,
390
378
  descriptionChars: state.descriptions.weight,
391
379
  attachments: state.attachments.size,
392
- scannedEventSeq: state.scannedEventSeq,
393
380
  }
394
381
  },
395
382
 
@@ -1,4 +1,4 @@
1
- import { mkdtemp, rm, writeFile } from 'node:fs/promises'
1
+ import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { createRequire, syncBuiltinESMExports } from 'node:module'
@@ -80,9 +80,23 @@ export function createTesseractPromisifyCompat(execFileImpl, originalCustom, opt
80
80
  const makeTempDir = options.mkdtemp ?? mkdtemp
81
81
  const writeTempFile = options.writeFile ?? writeFile
82
82
  const removeTempDir = options.rm ?? rm
83
+ const resolveRealPath = options.realpath ?? realpath
83
84
  const tempDir = options.tempDir ?? tmpdir()
85
+ let resolvedTempDir
84
86
  let active = true
85
87
 
88
+ const stagingRoot = async () => {
89
+ if (resolvedTempDir !== undefined) return resolvedTempDir
90
+ try {
91
+ resolvedTempDir = await resolveRealPath(tempDir)
92
+ } catch {
93
+ // Some tests/embedders provide a virtual temp root. Preserve the old
94
+ // path when canonicalization is unavailable instead of disabling OCR.
95
+ resolvedTempDir = tempDir
96
+ }
97
+ return resolvedTempDir
98
+ }
99
+
86
100
  async function tesseractPromisifyCompat(file, args, execOptions) {
87
101
  if (!active || !isTesseractCompatCall(file, args, execOptions)) {
88
102
  return delegate(file, args, execOptions)
@@ -91,7 +105,7 @@ export function createTesseractPromisifyCompat(execFileImpl, originalCustom, opt
91
105
  const bytes = bytesView(execOptions.input)
92
106
  let dir
93
107
  try {
94
- dir = await makeTempDir(path.join(tempDir, 'dsh-vision-router-ocr-'))
108
+ dir = await makeTempDir(path.join(await stagingRoot(), 'dsh-vision-router-ocr-'))
95
109
  const inputPath = path.join(dir, `input${extensionForBytes(bytes)}`)
96
110
  await writeTempFile(inputPath, bytes)
97
111
 
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { blocksHaveRetainedImage } from './image-offload-compat.js'
2
3
 
3
4
  const TWIN_SUFFIX = '-vision'
4
5
  const DEFAULT_MAIN_WRAPPER_ROUTE = 'deepseek-vision'
@@ -16,19 +17,9 @@ function nonEmptyString(value) {
16
17
  return typeof value === 'string' && value.length > 0
17
18
  }
18
19
 
19
- function blocksHaveImage(content) {
20
- if (!Array.isArray(content)) return false
21
- for (const block of content) {
22
- if (!block) continue
23
- if (block.type === 'image') return true
24
- if (Array.isArray(block.content) && blocksHaveImage(block.content)) return true
25
- }
26
- return false
27
- }
28
-
29
20
  export function twinRequestHasImage(messages) {
30
21
  return (messages ?? []).some(
31
- (message) => message && Array.isArray(message.content) && blocksHaveImage(message.content),
22
+ (message) => message && Array.isArray(message.content) && blocksHaveRetainedImage(message.content),
32
23
  )
33
24
  }
34
25
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  resolveArtifactTarget,
3
3
  writeArtifactFile,
4
+ writePersistentArtifactFile,
4
5
  } from './artifact-io.js'
5
6
  import {
6
7
  retainArtifactRun,
@@ -46,6 +47,17 @@ export function createVisionArtifactStore({ workspace, artifactsDir, deps = {} }
46
47
  )
47
48
  }
48
49
 
50
+ const publishPersistent = (relativePath, data, options = {}) => {
51
+ const current = location()
52
+ return writePersistentArtifactFile(
53
+ current.workspace,
54
+ current.artifactsDir,
55
+ relativePath,
56
+ data,
57
+ callDeps(deps, options),
58
+ )
59
+ }
60
+
49
61
  return Object.freeze({
50
62
  resolve(relativePath, options = {}) {
51
63
  const current = location()
@@ -58,10 +70,11 @@ export function createVisionArtifactStore({ workspace, artifactsDir, deps = {} }
58
70
  },
59
71
 
60
72
  publish,
73
+ publishPersistent,
61
74
 
62
- // Temporary-vs-persistent placement is authority/lifetime driven: the
63
- // ambient managed artifactRunId decides whether hardened IO uses `.runs`.
64
- // Keeping this method as an alias avoids inventing a second placement path.
75
+ // Temporary placement remains authority/lifetime driven by the ambient
76
+ // random managed artifactRunId. publishPersistent is the narrow handoff
77
+ // exception and uses one stable reserved run that retention still owns.
65
78
  publishTemporary(relativePath, data, options = {}) {
66
79
  return publish(relativePath, data, options)
67
80
  },
@@ -29,13 +29,11 @@ function attachmentIdOf(ref) {
29
29
  return id === '' ? undefined : id
30
30
  }
31
31
 
32
- function sessionEvents(session) {
32
+ function sessionMessages(session) {
33
33
  try {
34
- if (session && typeof session.snapshotEvents === 'function') {
35
- const events = session.snapshotEvents()
36
- return Array.isArray(events) ? events : []
37
- }
38
- return Array.isArray(session?.events) ? session.events : []
34
+ if (!session || typeof session.deriveMessages !== 'function') return []
35
+ const messages = session.deriveMessages()
36
+ return Array.isArray(messages) ? messages : []
39
37
  } catch {
40
38
  return []
41
39
  }
@@ -55,18 +53,9 @@ function collectImageRefsFromMessages(messages, out) {
55
53
  for (const message of messages) collectImageRefsFromBlocks(message?.content, out)
56
54
  }
57
55
 
58
- function authorizedRefs(core, agent) {
56
+ function authorizedRefs(agent) {
59
57
  const refs = []
60
- const session = agent?.session
61
- const events = sessionEvents(session)
62
- if (typeof core?.collectEventAttachmentRefs === 'function') {
63
- try {
64
- const durable = core.collectEventAttachmentRefs(events)
65
- if (Array.isArray(durable)) refs.push(...durable)
66
- } catch {
67
- // The resolver remains fail-closed; pending refs below may still suffice.
68
- }
69
- }
58
+ collectImageRefsFromMessages(sessionMessages(agent?.session), refs)
70
59
  collectImageRefsFromMessages(agent?.inbox?.nextTurn, refs)
71
60
  collectImageRefsFromMessages(agent?.inbox?.nextStep, refs)
72
61
 
@@ -99,12 +88,12 @@ function isCanonicalSha256Id(value) {
99
88
  * only when exactly one image authorized by the current Session has that
100
89
  * prefix. Zero or multiple matches fail closed.
101
90
  */
102
- export function resolveProjectedAttachmentHandle(handle, { core, sessionVisionIndex, agent } = {}) {
91
+ export function resolveProjectedAttachmentHandle(handle, { sessionVisionIndex, agent } = {}) {
103
92
  const token = typeof handle === 'string' ? handle.trim() : ''
104
93
  if (!isProjectedAttachmentHandle(token)) return { kind: 'not-projected', value: handle }
105
94
 
106
95
  const session = agent?.session
107
- const refs = authorizedRefs(core, agent)
96
+ const refs = authorizedRefs(agent)
108
97
  const wanted = token.toLowerCase()
109
98
  const matches = refs.filter((ref) => {
110
99
  const id = attachmentIdOf(ref)
@@ -124,7 +113,7 @@ export function resolveProjectedAttachmentHandle(handle, { core, sessionVisionIn
124
113
  try {
125
114
  sessionVisionIndex?.recordAttachments?.(session, [candidate])
126
115
  } catch {
127
- // A durable event can still be recovered by lookupAttachment below.
116
+ // Authorization is still fail-closed if the bounded cache cannot be warmed.
128
117
  }
129
118
  if (!sessionVisionIndex || typeof sessionVisionIndex.lookupAttachment !== 'function') {
130
119
  return { kind: 'unknown', handle: token }
@@ -201,7 +190,6 @@ export function wrapVisionAttachmentHandleDefinition(def, options = {}) {
201
190
  ...def,
202
191
  execute(args, exec) {
203
192
  const nextArgs = canonicalizeArgs(args, {
204
- core: options.core,
205
193
  sessionVisionIndex: options.sessionVisionIndex,
206
194
  agent: exec?.agent,
207
195
  toolName: def.name,
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { blocksHaveRetainedImage, isOffloadedImageBlock, offloadedImagePlaceholder } from './image-offload-compat.js'
2
3
 
3
4
  const visionBackendScope = new AsyncLocalStorage()
4
5
  const wrappedContexts = new WeakMap()
@@ -127,12 +128,8 @@ async function resolveCredential(ctx, plan) {
127
128
  return undefined
128
129
  }
129
130
 
130
- function contentHasImage(core, messages) {
131
- return (messages ?? []).some((message) => {
132
- if (!Array.isArray(message?.content)) return false
133
- if (typeof core?.blocksHaveImage === 'function') return core.blocksHaveImage(message.content)
134
- return message.content.some((block) => block?.type === 'image')
135
- })
131
+ function contentHasImage(_core, messages) {
132
+ return (messages ?? []).some((message) => blocksHaveRetainedImage(message?.content))
136
133
  }
137
134
 
138
135
  async function toOpenAIMessages(ctx, messages) {
@@ -151,6 +148,10 @@ async function toOpenAIMessages(ctx, messages) {
151
148
  continue
152
149
  }
153
150
  if (block?.type === 'image' && block.attachment) {
151
+ if (isOffloadedImageBlock(block)) {
152
+ converted.push({ type: 'text', text: offloadedImagePlaceholder(block) })
153
+ continue
154
+ }
154
155
  const stored = await attachments.readImage(block.attachment)
155
156
  const mediaType = block.attachment.mediaType || stored?.mediaType || 'image/png'
156
157
  const data = Buffer.from(stored.data).toString('base64')
@@ -50,6 +50,25 @@ function recordKey(value) {
50
50
  return `${value.fingerprint}\u0000${value.axis}`
51
51
  }
52
52
 
53
+ function selectRetainedStops(values, at, maxEntries = MAX_ENTRIES) {
54
+ const seen = new Set()
55
+ return [...values]
56
+ .map(cleanStop)
57
+ .filter((item) => item && item.expiresAt > at)
58
+ .sort((a, b) => b.recordedAt - a.recordedAt || recordKey(a).localeCompare(recordKey(b)))
59
+ .filter((item) => {
60
+ const key = recordKey(item)
61
+ if (seen.has(key)) return false
62
+ seen.add(key)
63
+ return true
64
+ })
65
+ .slice(0, maxEntries)
66
+ }
67
+
68
+ function retainedStopMap(values, at, maxEntries = MAX_ENTRIES) {
69
+ return new Map(selectRetainedStops(values, at, maxEntries).map((item) => [recordKey(item), item]))
70
+ }
71
+
53
72
  export function backgroundBenchmarkStopCachePath(dshHome = resolveDshHome()) {
54
73
  return path.join(dshHome, 'cache', 'vision-router', 'background-benchmark-stops.json')
55
74
  }
@@ -59,10 +78,7 @@ async function load(file, fsOps, at) {
59
78
  const raw = await fsOps.readFile(file, 'utf8')
60
79
  const body = JSON.parse(raw)
61
80
  if (!body || !Array.isArray(body.stops) || ![2, CACHE_VERSION].includes(body.version)) return new Map()
62
- const records = new Map(body.stops
63
- .map(cleanStop)
64
- .filter((item) => item && item.expiresAt > at)
65
- .map((item) => [recordKey(item), item]))
81
+ const records = retainedStopMap(body.stops, at)
66
82
  if (body.version === 2) await save(file, records, fsOps, at)
67
83
  return records
68
84
  } catch (error) {
@@ -73,11 +89,7 @@ async function load(file, fsOps, at) {
73
89
 
74
90
  async function save(file, records, fsOps, at) {
75
91
  const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
76
- const stops = [...records.values()]
77
- .map(cleanStop)
78
- .filter((item) => item && item.expiresAt > at)
79
- .sort((a, b) => b.recordedAt - a.recordedAt)
80
- .slice(0, MAX_ENTRIES)
92
+ const stops = selectRetainedStops(records.values(), at)
81
93
  await fsOps.mkdir(path.dirname(file), { recursive: true })
82
94
  await fsOps.writeFile(temporary, JSON.stringify({ version: CACHE_VERSION, stops }), { encoding: 'utf8', mode: 0o600 })
83
95
  await fsOps.rename(temporary, file)
@@ -105,22 +117,19 @@ export function createBackgroundBenchmarkStopStore(options = {}) {
105
117
  return saveTail
106
118
  }
107
119
 
108
- const pruneExpired = () => {
120
+ const reconcile = () => {
109
121
  const at = Number(now())
110
- let removed = false
111
- for (const [key, record] of records.entries()) {
112
- if (record.expiresAt > at) continue
113
- records.delete(key)
114
- removed = true
115
- }
116
- return removed
122
+ const retained = retainedStopMap(records.values(), at)
123
+ const changed = retained.size !== records.size || [...retained.keys()].some((key) => !records.has(key))
124
+ records = retained
125
+ return changed
117
126
  }
118
127
 
119
128
  return {
120
129
  file,
121
130
  async list() {
122
131
  await ready
123
- if (pruneExpired()) await persist()
132
+ if (reconcile()) await persist()
124
133
  return [...records.values()].sort((a, b) => b.recordedAt - a.recordedAt)
125
134
  },
126
135
  async mark({
@@ -135,7 +144,7 @@ export function createBackgroundBenchmarkStopStore(options = {}) {
135
144
  expiresAt,
136
145
  } = {}) {
137
146
  await ready
138
- pruneExpired()
147
+ reconcile()
139
148
  const clean = cleanStop({
140
149
  fingerprint,
141
150
  key,
@@ -150,8 +159,9 @@ export function createBackgroundBenchmarkStopStore(options = {}) {
150
159
  })
151
160
  if (!clean) return undefined
152
161
  records.set(recordKey(clean), clean)
162
+ reconcile()
153
163
  await persist()
154
- return clean
164
+ return records.get(recordKey(clean))
155
165
  },
156
166
  async clearStop(fingerprint, axis) {
157
167
  await ready
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto'
2
+ import { createSessionTurnResolver } from './session-turn-resolver.js'
2
3
 
3
- function turnNumberOf(session) {
4
+ function legacyTurnNumberOf(session) {
4
5
  try {
5
6
  const events = session?.events
6
7
  if (!Array.isArray(events)) return 0
@@ -19,8 +20,15 @@ function sessionIdOf(session) {
19
20
  }
20
21
  }
21
22
 
22
- export function visionBreakerScopeOf(session) {
23
- return `${sessionIdOf(session)}:${turnNumberOf(session)}`
23
+ export function visionBreakerScopeOf(session, turnOf) {
24
+ let turn
25
+ try {
26
+ if (typeof turnOf === 'function') turn = turnOf(session)
27
+ } catch {
28
+ turn = undefined
29
+ }
30
+ if (!Number.isInteger(turn) || turn < 0) turn = legacyTurnNumberOf(session)
31
+ return `${sessionIdOf(session)}:${turn}`
24
32
  }
25
33
 
26
34
  function credentialFingerprintOf(value) {
@@ -76,7 +84,9 @@ function healthFromGate(gate) {
76
84
  * giving shadow code mutation authority. `capture()` is called only while
77
85
  * core.apply() constructs the breaker; `healthForCandidate()` uses peek().
78
86
  */
79
- export function createVisionBreakerShadowHealth(ctx) {
87
+ export function createVisionBreakerShadowHealth(ctx, options = {}) {
88
+ const sessionTurnResolver = options.sessionTurnResolver ?? createSessionTurnResolver(ctx)
89
+ const turnOf = sessionTurnResolver.turnOf
80
90
  let breaker
81
91
  return {
82
92
  capture(value) {
@@ -88,7 +98,7 @@ export function createVisionBreakerShadowHealth(ctx) {
88
98
  const key = typeof candidate?.key === 'string' ? candidate.key : undefined
89
99
  if (!key) return undefined
90
100
  const fingerprint = await visionBreakerFingerprintForCandidate(ctx, candidate)
91
- const scope = visionBreakerScopeOf(context.session)
101
+ const scope = visionBreakerScopeOf(context.session, turnOf)
92
102
  return healthFromGate(breaker.peek(key, fingerprint, scope))
93
103
  },
94
104
 
@@ -338,21 +338,34 @@ export async function runExactCapabilityBenchmark({
338
338
  return { record, results }
339
339
  }
340
340
 
341
+ function selectRetainedCapabilityProfiles(values, maxEntries) {
342
+ const seen = new Set()
343
+ return [...values]
344
+ .map(sanitizeCapabilityProfileRecord)
345
+ .filter(Boolean)
346
+ .sort((a, b) => b.measuredAt - a.measuredAt || a.fingerprint.localeCompare(b.fingerprint))
347
+ .filter((record) => {
348
+ if (seen.has(record.fingerprint)) return false
349
+ seen.add(record.fingerprint)
350
+ return true
351
+ })
352
+ .slice(0, maxEntries)
353
+ }
354
+
355
+ function retainedCapabilityProfileMap(values, maxEntries) {
356
+ return new Map(selectRetainedCapabilityProfiles(values, maxEntries).map((record) => [record.fingerprint, record]))
357
+ }
358
+
341
359
  function cacheEnvelope(records) {
342
360
  return { version: CAPABILITY_PROFILE_CACHE_VERSION, profiles: records }
343
361
  }
344
362
 
345
- async function loadCache(file, fsOps) {
363
+ async function loadCache(file, fsOps, maxEntries) {
346
364
  try {
347
365
  const raw = await fsOps.readFile(file, 'utf8')
348
366
  const body = JSON.parse(raw)
349
367
  if (!body || body.version !== CAPABILITY_PROFILE_CACHE_VERSION || !Array.isArray(body.profiles)) return new Map()
350
- return new Map(
351
- body.profiles
352
- .map(sanitizeCapabilityProfileRecord)
353
- .filter(Boolean)
354
- .map((record) => [record.fingerprint, record]),
355
- )
368
+ return retainedCapabilityProfileMap(body.profiles, maxEntries)
356
369
  } catch (error) {
357
370
  if (error?.code === 'ENOENT') return new Map()
358
371
  return new Map()
@@ -362,11 +375,7 @@ async function loadCache(file, fsOps) {
362
375
  async function saveCache(file, records, fsOps, maxEntries) {
363
376
  const directory = path.dirname(file)
364
377
  const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
365
- const profiles = [...records.values()]
366
- .map(sanitizeCapabilityProfileRecord)
367
- .filter(Boolean)
368
- .sort((a, b) => b.measuredAt - a.measuredAt || a.fingerprint.localeCompare(b.fingerprint))
369
- .slice(0, maxEntries)
378
+ const profiles = selectRetainedCapabilityProfiles(records.values(), maxEntries)
370
379
  await fsOps.mkdir(directory, { recursive: true })
371
380
  await fsOps.writeFile(temporary, JSON.stringify(cacheEnvelope(profiles)), { encoding: 'utf8', mode: 0o600 })
372
381
  await fsOps.rename(temporary, file)
@@ -384,7 +393,7 @@ export function createCapabilityProfileStore(options = {}) {
384
393
  const logger = options.logger
385
394
  let records = new Map()
386
395
  let saveTail = Promise.resolve()
387
- const ready = loadCache(file, fsOps).then((loaded) => { records = loaded })
396
+ const ready = loadCache(file, fsOps, maxEntries).then((loaded) => { records = loaded })
388
397
 
389
398
  const persist = () => {
390
399
  saveTail = saveTail
@@ -412,8 +421,9 @@ export function createCapabilityProfileStore(options = {}) {
412
421
  if (merged === undefined) throw new TypeError('invalid merged capability profile record')
413
422
  if (existing && JSON.stringify(existing) === JSON.stringify(merged)) return existing
414
423
  records.set(clean.fingerprint, merged)
424
+ records = retainedCapabilityProfileMap(records.values(), maxEntries)
415
425
  await persist()
416
- return merged
426
+ return records.get(clean.fingerprint)
417
427
  },
418
428
  async remove(fingerprint) {
419
429
  await ready
@@ -0,0 +1,39 @@
1
+ export const VISION_SUCCESS_GUIDANCE =
2
+ 'Vision Router trusted execution metadata: the preceding vision_describe call SUCCEEDED. ' +
3
+ 'This metadata is not image content. If that successful evidence resolves the visual fact needed for the current task, stop making additional vision calls for that fact and continue the remaining user task from this evidence. ' +
4
+ 'If no non-visual work remains, answer now in exactly the format the user requested; when the user asks for only one value or short phrase, do not add Markdown, explanation, or a restatement. ' +
5
+ 'Call another vision tool only for a specific unresolved visual fact. For verbatim or exact-text tasks such as identifiers, amounts, times, version strings, or code, preserve every visible symbol, prefix, suffix, and punctuation from the most faithful evidence already collected; a later semantic summary must not overwrite a more exact transcription. Focused OCR or localization is still appropriate when character identity remains uncertain.'
6
+
7
+ function isVisionFailureEnvelope(value) {
8
+ const text = value.trim()
9
+ if (!text.startsWith('{')) return false
10
+ try {
11
+ const parsed = JSON.parse(text)
12
+ return (
13
+ parsed !== null &&
14
+ typeof parsed === 'object' &&
15
+ parsed.ok === false &&
16
+ typeof parsed.code === 'string' &&
17
+ /^VISION_[A-Z0-9_]+$/u.test(parsed.code)
18
+ )
19
+ } catch {
20
+ return false
21
+ }
22
+ }
23
+
24
+ export function visionDescribeSuccessContext(exec, result) {
25
+ if (
26
+ exec?.name !== 'vision_describe' ||
27
+ !exec?.agent ||
28
+ result?.isError !== false ||
29
+ typeof result.value !== 'string' ||
30
+ result.value.trim().length === 0 ||
31
+ isVisionFailureEnvelope(result.value)
32
+ ) return undefined
33
+ return {
34
+ role: 'user',
35
+ id: `vision-router-evidence-success-${String(exec.callId)}`,
36
+ content: [{ type: 'text', text: VISION_SUCCESS_GUIDANCE }],
37
+ source: { kind: 'plugin', plugin: 'dsh-vision-router' },
38
+ }
39
+ }
@@ -38,12 +38,30 @@ export function imageInputVerdictCachePath(dshHome = resolveDshHome()) {
38
38
  return path.join(dshHome, 'cache', 'vision-router', 'image-input-verdicts.json')
39
39
  }
40
40
 
41
+ function selectRetainedVerdicts(values, maxEntries = MAX_ENTRIES) {
42
+ const seen = new Set()
43
+ return [...values]
44
+ .map(cleanVerdict)
45
+ .filter(Boolean)
46
+ .sort((a, b) => b.measuredAt - a.measuredAt || a.fingerprint.localeCompare(b.fingerprint))
47
+ .filter((item) => {
48
+ if (seen.has(item.fingerprint)) return false
49
+ seen.add(item.fingerprint)
50
+ return true
51
+ })
52
+ .slice(0, maxEntries)
53
+ }
54
+
55
+ function retainedVerdictMap(values, maxEntries = MAX_ENTRIES) {
56
+ return new Map(selectRetainedVerdicts(values, maxEntries).map((item) => [item.fingerprint, item]))
57
+ }
58
+
41
59
  async function load(file, fsOps) {
42
60
  try {
43
61
  const raw = await fsOps.readFile(file, 'utf8')
44
62
  const body = JSON.parse(raw)
45
63
  if (!body || body.version !== CACHE_VERSION || !Array.isArray(body.verdicts)) return new Map()
46
- return new Map(body.verdicts.map(cleanVerdict).filter(Boolean).map((item) => [item.fingerprint, item]))
64
+ return retainedVerdictMap(body.verdicts)
47
65
  } catch (error) {
48
66
  if (error?.code === 'ENOENT') return new Map()
49
67
  return new Map()
@@ -52,11 +70,7 @@ async function load(file, fsOps) {
52
70
 
53
71
  async function save(file, records, fsOps) {
54
72
  const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
55
- const verdicts = [...records.values()]
56
- .map(cleanVerdict)
57
- .filter(Boolean)
58
- .sort((a, b) => b.measuredAt - a.measuredAt)
59
- .slice(0, MAX_ENTRIES)
73
+ const verdicts = selectRetainedVerdicts(records.values())
60
74
  await fsOps.mkdir(path.dirname(file), { recursive: true })
61
75
  await fsOps.writeFile(temporary, JSON.stringify({ version: CACHE_VERSION, verdicts }), { encoding: 'utf8', mode: 0o600 })
62
76
  await fsOps.rename(temporary, file)
@@ -101,8 +115,9 @@ export function createImageInputVerdictStore(options = {}) {
101
115
  })
102
116
  if (!clean) return undefined
103
117
  records.set(clean.fingerprint, clean)
118
+ records = retainedVerdictMap(records.values())
104
119
  await persist()
105
- return clean
120
+ return records.get(clean.fingerprint)
106
121
  },
107
122
  async clear(fingerprint) {
108
123
  await ready
@@ -376,12 +376,14 @@ export function createVisionTurnMemory({
376
376
  maxScopes = 64,
377
377
  maxSessions = maxScopes,
378
378
  maxAttemptsPerScope = 64,
379
+ maxEvidencePerScope = 32,
379
380
  } = {}) {
380
381
  const scopes = new Map() // scope -> { failedKinds:Set, attempted: [], allFailed:false }
381
382
  const lastScopeBySession = new Map() // bounded LRU sessionId -> scope
382
383
  const scopeLimit = Math.max(1, Math.floor(Number(maxScopes) || 64))
383
384
  const sessionLimit = Math.max(1, Math.floor(Number(maxSessions) || scopeLimit))
384
385
  const attemptLimit = Math.max(1, Math.floor(Number(maxAttemptsPerScope) || 64))
386
+ const evidenceLimit = Math.max(1, Math.floor(Number(maxEvidencePerScope) || 32))
385
387
 
386
388
  const dropScope = (scope) => {
387
389
  scopes.delete(scope)
@@ -410,7 +412,14 @@ export function createVisionTurnMemory({
410
412
  const entry = (scope) => {
411
413
  let hit = scopes.get(scope)
412
414
  if (hit === undefined) {
413
- hit = { failedKinds: new Set(), attempted: [], allFailed: false }
415
+ hit = {
416
+ failedKinds: new Set(),
417
+ attempted: [],
418
+ allFailed: false,
419
+ localOcr: new Map(),
420
+ derivedArtifacts: new Map(),
421
+ degradedRefinements: new Map(),
422
+ }
414
423
  scopes.set(scope, hit)
415
424
  pruneScopes()
416
425
  } else {
@@ -453,6 +462,69 @@ export function createVisionTurnMemory({
453
462
  return hit === undefined ? [] : [...hit.attempted]
454
463
  },
455
464
 
465
+ resolveSource(scope, sourceKey) {
466
+ const key = typeof sourceKey === 'string' ? sourceKey : ''
467
+ const hit = scopes.get(scope)
468
+ if (hit === undefined || key === '') return key
469
+ for (const [artifactToken, rootSource] of hit.derivedArtifacts) {
470
+ if (key === artifactToken || key.includes(artifactToken)) return rootSource
471
+ }
472
+ return key
473
+ },
474
+
475
+ recordLocalOcr(scope, sourceKey, evidence = {}) {
476
+ const hit = entry(scope)
477
+ const key = this.resolveSource(scope, sourceKey)
478
+ if (key === '') return
479
+ hit.localOcr.delete(key)
480
+ hit.localOcr.set(key, Object.freeze({ uncertain: evidence.uncertain === true }))
481
+ while (hit.localOcr.size > evidenceLimit) hit.localOcr.delete(hit.localOcr.keys().next().value)
482
+ },
483
+
484
+ hasLocalOcr(scope, sourceKey) {
485
+ const hit = scopes.get(scope)
486
+ if (hit === undefined) return false
487
+ return hit.localOcr.has(this.resolveSource(scope, sourceKey))
488
+ },
489
+
490
+ degradedEvidenceTokens(scope) {
491
+ const hit = scopes.get(scope)
492
+ if (hit === undefined || !hit.allFailed || hit.localOcr.size === 0) return []
493
+ const tokens = [...hit.localOcr.keys()]
494
+ for (const [artifactToken, rootSource] of hit.derivedArtifacts) {
495
+ if (hit.localOcr.has(rootSource)) tokens.push(artifactToken)
496
+ }
497
+ return tokens
498
+ },
499
+
500
+ recordDerivedArtifact(scope, sourceKey, artifactToken) {
501
+ const token = typeof artifactToken === 'string' ? artifactToken : ''
502
+ if (token === '') return
503
+ const hit = entry(scope)
504
+ const rootSource = this.resolveSource(scope, sourceKey)
505
+ if (rootSource === '') return
506
+ hit.derivedArtifacts.delete(token)
507
+ hit.derivedArtifacts.set(token, rootSource)
508
+ while (hit.derivedArtifacts.size > evidenceLimit) hit.derivedArtifacts.delete(hit.derivedArtifacts.keys().next().value)
509
+ },
510
+
511
+ degradedRefinementCount(scope, sourceKey) {
512
+ const hit = scopes.get(scope)
513
+ if (hit === undefined) return 0
514
+ return hit.degradedRefinements.get(this.resolveSource(scope, sourceKey)) ?? 0
515
+ },
516
+
517
+ recordDegradedRefinement(scope, sourceKey) {
518
+ const hit = entry(scope)
519
+ const key = this.resolveSource(scope, sourceKey)
520
+ if (key === '') return 0
521
+ const count = (hit.degradedRefinements.get(key) ?? 0) + 1
522
+ hit.degradedRefinements.delete(key)
523
+ hit.degradedRefinements.set(key, count)
524
+ while (hit.degradedRefinements.size > evidenceLimit) hit.degradedRefinements.delete(hit.degradedRefinements.keys().next().value)
525
+ return count
526
+ },
527
+
456
528
  stats() {
457
529
  return { scopes: scopes.size, sessions: lastScopeBySession.size }
458
530
  },