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
@@ -1,11 +1,12 @@
1
1
  import { currentSessionSurfacePolicy } from './session-surface-policy.js'
2
+ import { sessionSurfaceReplacementIntent } from './session-surface-compat.js'
2
3
  import { createSessionVisionStateStore } from './session-vision-state.js'
3
4
 
4
5
  function isObject(value) {
5
6
  return value !== null && typeof value === 'object'
6
7
  }
7
8
 
8
- function sessionEvents(session) {
9
+ function legacySessionEvents(session) {
9
10
  try {
10
11
  // dsh 0.1.2-alpha.4 removed the bare `session.events` array in favor of
11
12
  // `session.snapshotEvents()`; both are handled so one build runs on both
@@ -42,19 +43,21 @@ function boundedMessage(error) {
42
43
  * Session visual data-plane index.
43
44
  *
44
45
  * SessionVisionStateStore remains the bounded storage owner. This object owns
45
- * incremental durable-log scan, target-only recovery after bounded-cache
46
- * eviction, tool-result image surface repair and expired structured guard-stop
47
- * surface repair.
46
+ * target-only durable attachment recovery after bounded-cache eviction,
47
+ * bounded Host-backed tool-result image surface repair and expired structured
48
+ * guard-stop surface repair.
48
49
  *
49
- * The index never replaces stateStore.lookupAttachment(). Durable recovery is
50
- * requested explicitly through index.lookupAttachment(), so storage and
51
- * recovery ownership remain visible at the call site.
50
+ * The index never replaces stateStore.lookupAttachment(). Cache lookup stays
51
+ * synchronous through lookupAttachment(); cold durable recovery is requested
52
+ * explicitly through async resolveAttachment(), so I/O ownership remains visible.
52
53
  */
53
54
  export function createSessionVisionIndex({
54
55
  stateStore,
55
56
  core,
56
57
  config = {},
57
58
  logger,
59
+ readSessionEvent,
60
+ readSessionLog,
58
61
  } = {}) {
59
62
  if (!core || typeof core !== 'object') throw new TypeError('session vision index requires core helpers')
60
63
 
@@ -70,59 +73,99 @@ export function createSessionVisionIndex({
70
73
  : undefined
71
74
  const toolSurfaceScans = new WeakMap()
72
75
  const guardSurfaceScans = new WeakMap()
76
+ const unsupportedSurfaceContracts = new WeakSet()
77
+ const repairReadWarnings = new WeakMap()
78
+ const attachmentRecoveryWarnings = new WeakMap()
73
79
 
74
80
  const recordAttachments = (session, refs) => {
75
81
  if (!session || !Array.isArray(refs) || refs.length === 0) return
76
82
  store.recordAttachments(session, refs)
77
83
  }
78
84
 
79
- const scanEventLog = (session) => {
80
- if (!session) return
81
- const events = sessionEvents(session)
82
- if (!events || events.length === 0) return
83
- const last = store.getScannedEventSeq(session)
84
- if (last >= events.length) return
85
- const refs = typeof core.collectEventAttachmentRefs === 'function'
86
- ? core.collectEventAttachmentRefs(events.slice(last))
87
- : []
88
- // Advance even when no refs are present so irrelevant durable history is
89
- // never rescanned indefinitely.
90
- store.setScannedEventSeq(session, events.length)
91
- if (Array.isArray(refs) && refs.length > 0) store.recordAttachments(session, refs)
92
- }
93
85
 
86
+ // Synchronous lookup is cache-only. Projected short handles first warm this
87
+ // cache from current derived messages and must not trigger historical I/O.
94
88
  const lookupAttachment = (session, id) => {
95
89
  if (!primitiveLookup) return undefined
96
- let hit = primitiveLookup(session, id)
97
- if (hit !== undefined) return hit
98
- if (session === undefined) return undefined
99
-
100
- scanEventLog(session)
101
- hit = primitiveLookup(session, id)
102
- if (hit !== undefined) return hit
90
+ return primitiveLookup(session, id)
91
+ }
103
92
 
104
- // Bounded cache eviction is a performance event, never a correctness
105
- // event. Recover only the requested durable ref instead of rebuilding an
106
- // unbounded attachment index.
107
- const events = sessionEvents(session)
108
- if (!events || events.length === 0 || typeof core.collectEventAttachmentRefs !== 'function') {
109
- return undefined
93
+ const recoverAttachmentsFromEvents = (session, ids, events) => {
94
+ const found = new Map()
95
+ if (!Array.isArray(events) || events.length === 0 || typeof core.collectEventAttachmentRefs !== 'function') {
96
+ return found
97
+ }
98
+ const wanted = new Set(ids.map((id) => String(id)))
99
+ for (const ref of core.collectEventAttachmentRefs(events)) {
100
+ if (!ref) continue
101
+ const id = String(ref.attachmentId ?? ref.id)
102
+ if (wanted.has(id) && !found.has(id)) found.set(id, ref)
110
103
  }
111
- const wanted = String(id)
112
- const recovered = core.collectEventAttachmentRefs(events).find(
113
- (ref) => ref && String(ref.attachmentId ?? ref.id) === wanted,
104
+ if (found.size > 0) store.recordAttachments(session, [...found.values()])
105
+ return found
106
+ }
107
+
108
+ const warnAttachmentRecoveryFailure = (session, id, error) => {
109
+ const message = boundedMessage(error)
110
+ const key = `${String(id)}:${message}`
111
+ if (attachmentRecoveryWarnings.get(session) === key) return
112
+ attachmentRecoveryWarnings.set(session, key)
113
+ logger?.warn?.(
114
+ 'vision-router: session attachment recovery failed id=%s error=%s',
115
+ String(id),
116
+ message,
114
117
  )
115
- if (recovered !== undefined) {
116
- store.recordAttachments(session, [recovered])
117
- return recovered
118
+ }
119
+
120
+ const resolveAttachments = async (session, ids) => {
121
+ const requested = [...new Set((Array.isArray(ids) ? ids : []).map((id) => String(id)))]
122
+ const resolved = new Map()
123
+ const missing = []
124
+ for (const id of requested) {
125
+ const cached = lookupAttachment(session, id)
126
+ if (cached !== undefined) resolved.set(id, cached)
127
+ else missing.push(id)
118
128
  }
119
- return undefined
129
+ if (missing.length === 0 || session === undefined) return resolved
130
+
131
+ let events
132
+ if (typeof readSessionLog === 'function') {
133
+ let result
134
+ try {
135
+ result = await readSessionLog(session)
136
+ } catch (error) {
137
+ warnAttachmentRecoveryFailure(session, missing[0], error)
138
+ return resolved
139
+ }
140
+ if (result?.supported === true) {
141
+ attachmentRecoveryWarnings.delete(session)
142
+ events = result.events
143
+ } else if (result?.supported !== false) {
144
+ warnAttachmentRecoveryFailure(
145
+ session,
146
+ missing[0],
147
+ new Error('Session log reader returned an invalid capability result'),
148
+ )
149
+ return resolved
150
+ }
151
+ }
152
+
153
+ // Historical/partial Hosts without SessionQuery keep the released fallback.
154
+ // Public rc8+ Hosts use readSessionLog above and never touch sync history.
155
+ if (events === undefined) events = legacySessionEvents(session)
156
+ const recovered = recoverAttachmentsFromEvents(session, missing, events)
157
+ for (const [id, ref] of recovered) resolved.set(id, ref)
158
+ return resolved
159
+ }
160
+
161
+ const resolveAttachment = async (session, id) => {
162
+ return (await resolveAttachments(session, [id])).get(String(id))
120
163
  }
121
164
 
122
- const nextSurfaceSeqs = (scans, session) => {
123
- if (!session) return []
165
+ const pendingSurfaceScan = (scans, session) => {
166
+ if (!session) return undefined
124
167
  const nodes = surfaceNodes(session)
125
- if (!nodes || nodes.length === 0) return []
168
+ if (!nodes || nodes.length === 0) return undefined
126
169
  let scan = scans.get(session)
127
170
  if (!scan) {
128
171
  scan = { count: 0 }
@@ -131,9 +174,54 @@ export function createSessionVisionIndex({
131
174
  // Compaction/resume may shrink/rebuild the surface. Reset the incremental
132
175
  // cursor rather than assuming old indices still refer to the new surface.
133
176
  if (scan.count > nodes.length) scan.count = 0
134
- const fresh = nodes.slice(scan.count)
135
- scan.count = nodes.length
136
- return fresh
177
+ const count = nodes.length
178
+ const seqs = nodes.slice(scan.count)
179
+ if (seqs.length === 0) return undefined
180
+ return {
181
+ seqs,
182
+ advance() {
183
+ if (scan.count < count) scan.count += 1
184
+ },
185
+ }
186
+ }
187
+
188
+ const warnRepairReadFailure = (session, seq, error) => {
189
+ const message = boundedMessage(error)
190
+ const previous = repairReadWarnings.get(session)
191
+ if (previous?.seq === seq && previous.message === message) return
192
+ repairReadWarnings.set(session, { seq, message })
193
+ logger?.warn?.(
194
+ 'vision-router: session surface event read failed seq=%s error=%s',
195
+ seq,
196
+ message,
197
+ )
198
+ }
199
+
200
+ const eventForSurfaceRepair = async (session, seq) => {
201
+ if (typeof readSessionEvent === 'function') {
202
+ let result
203
+ try {
204
+ result = await readSessionEvent(session, seq)
205
+ } catch (error) {
206
+ warnRepairReadFailure(session, seq, error)
207
+ return { readable: false }
208
+ }
209
+ if (result?.supported === true) {
210
+ repairReadWarnings.delete(session)
211
+ return { readable: true, event: result.event }
212
+ }
213
+ if (result?.supported !== false) {
214
+ warnRepairReadFailure(session, seq, new Error('Session event reader returned an invalid capability result'))
215
+ return { readable: false }
216
+ }
217
+ }
218
+
219
+ // Isolated/partial Hosts that do not expose SessionQuery retain the released
220
+ // Session-local compatibility path. Supported production Hosts use the
221
+ // bounded async reader above and never silently fall back after a read error.
222
+ const events = legacySessionEvents(session)
223
+ if (!events) return { readable: false }
224
+ return { readable: true, event: events[seq] }
137
225
  }
138
226
 
139
227
  const keepToolResultImages = () => {
@@ -143,60 +231,85 @@ export function createSessionVisionIndex({
143
231
  return policy.ownership === 'native-image' || policy.ownership === 'vision-router-owned'
144
232
  }
145
233
 
234
+ const replacementIntent = (session, seq) => {
235
+ const intent = sessionSurfaceReplacementIntent(session, seq)
236
+ if (intent !== undefined) return intent
237
+ if (!unsupportedSurfaceContracts.has(session)) {
238
+ unsupportedSurfaceContracts.add(session)
239
+ logger?.warn?.(
240
+ 'vision-router: skipping durable session surface repair for unsupported Session format version=%s',
241
+ session?.header?.version ?? 'unknown',
242
+ )
243
+ }
244
+ return undefined
245
+ }
246
+
146
247
  const repairToolResultSurface = async (session) => {
147
- const events = sessionEvents(session)
148
- if (!events || typeof session?.append !== 'function') return 0
149
- const seqs = nextSurfaceSeqs(toolSurfaceScans, session)
150
- if (seqs.length === 0 || typeof core.planToolResultImageShadows !== 'function') return 0
248
+ if (typeof session?.append !== 'function') return 0
249
+ const scan = pendingSurfaceScan(toolSurfaceScans, session)
250
+ if (!scan || typeof core.planToolResultImageShadows !== 'function') return 0
151
251
  const preserve = keepToolResultImages()
152
- const plans = core.planToolResultImageShadows(events, seqs, () => !preserve)
153
252
  let repaired = 0
154
- for (const plan of plans) {
155
- try {
156
- await session.append(
157
- 'tool/result',
158
- { ...plan.event.data, message: plan.message },
159
- {
160
- surfaceOp: { op: 'replace', start: plan.seq, end: plan.seq },
161
- sourceEventSeqs: [plan.seq],
162
- },
163
- )
164
- repaired += 1
165
- } catch (error) {
166
- logger?.warn?.(
167
- 'vision-router: session tool-result surface repair failed seq=%s error=%s',
168
- plan.seq,
169
- boundedMessage(error),
170
- )
253
+ for (const seq of scan.seqs) {
254
+ const read = await eventForSurfaceRepair(session, seq)
255
+ if (!read.readable) break
256
+ // A readable node is settled for this repair pass even when it needs no
257
+ // replacement or append later fails; only an unread node remains pending.
258
+ scan.advance()
259
+ const events = []
260
+ events[seq] = read.event
261
+ const plans = core.planToolResultImageShadows(events, [seq], () => !preserve)
262
+ for (const plan of plans) {
263
+ try {
264
+ const intent = replacementIntent(session, plan.seq)
265
+ if (intent === undefined) continue
266
+ await session.append(
267
+ 'tool/result',
268
+ { ...plan.event.data, message: plan.message },
269
+ intent,
270
+ )
271
+ repaired += 1
272
+ } catch (error) {
273
+ logger?.warn?.(
274
+ 'vision-router: session tool-result surface repair failed seq=%s error=%s',
275
+ plan.seq,
276
+ boundedMessage(error),
277
+ )
278
+ }
171
279
  }
172
280
  }
173
281
  return repaired
174
282
  }
175
283
 
176
284
  const repairGuardStopSurface = async (session) => {
177
- const events = sessionEvents(session)
178
- if (!events || typeof session?.append !== 'function') return 0
179
- const seqs = nextSurfaceSeqs(guardSurfaceScans, session)
180
- if (seqs.length === 0 || typeof core.planGuardStopShadows !== 'function') return 0
181
- const plans = core.planGuardStopShadows(events, seqs)
285
+ if (typeof session?.append !== 'function') return 0
286
+ const scan = pendingSurfaceScan(guardSurfaceScans, session)
287
+ if (!scan || typeof core.planGuardStopShadows !== 'function') return 0
182
288
  let repaired = 0
183
- for (const plan of plans) {
184
- try {
185
- await session.append(
186
- 'user/message',
187
- plan.data,
188
- {
189
- surfaceOp: { op: 'replace', start: plan.seq, end: plan.seq },
190
- sourceEventSeqs: [plan.seq],
191
- },
192
- )
193
- repaired += 1
194
- } catch (error) {
195
- logger?.warn?.(
196
- 'vision-router: session guard-stop surface repair failed seq=%s error=%s',
197
- plan.seq,
198
- boundedMessage(error),
199
- )
289
+ for (const seq of scan.seqs) {
290
+ const read = await eventForSurfaceRepair(session, seq)
291
+ if (!read.readable) break
292
+ scan.advance()
293
+ const events = []
294
+ events[seq] = read.event
295
+ const plans = core.planGuardStopShadows(events, [seq])
296
+ for (const plan of plans) {
297
+ try {
298
+ const intent = replacementIntent(session, plan.seq)
299
+ if (intent === undefined) continue
300
+ await session.append(
301
+ 'user/message',
302
+ plan.data,
303
+ intent,
304
+ )
305
+ repaired += 1
306
+ } catch (error) {
307
+ logger?.warn?.(
308
+ 'vision-router: session guard-stop surface repair failed seq=%s error=%s',
309
+ plan.seq,
310
+ boundedMessage(error),
311
+ )
312
+ }
200
313
  }
201
314
  }
202
315
  return repaired
@@ -213,7 +326,6 @@ export function createSessionVisionIndex({
213
326
  store.recordAttachments(session, found.attachments)
214
327
  }
215
328
  }
216
- scanEventLog(session)
217
329
  await repairToolResultSurface(session)
218
330
  await repairGuardStopSurface(session)
219
331
  return decision
@@ -221,8 +333,9 @@ export function createSessionVisionIndex({
221
333
 
222
334
  return Object.freeze({
223
335
  recordAttachments,
224
- scanEventLog,
225
336
  lookupAttachment,
337
+ resolveAttachment,
338
+ resolveAttachments,
226
339
  repairToolResultSurface,
227
340
  repairGuardStopSurface,
228
341
  prepareDecision,
@@ -9,6 +9,7 @@ import {
9
9
  } from './session-vision-mode-authority.js'
10
10
 
11
11
  const MODE_SYNC_VARIABLE = 'vision_router_mode_sync'
12
+ const VISION_ROUTER_TOOL_OWNER = Symbol.for('dsh-vision-router.tool-owner')
12
13
 
13
14
  function isObject(value) {
14
15
  return value !== null && typeof value === 'object'
@@ -59,6 +60,58 @@ function ownedVisionToolNames(owned) {
59
60
  return [...owned.keys()].sort()
60
61
  }
61
62
 
63
+ function restrictableOwnedVisionToolNames(tools, owned) {
64
+ const names = ownedVisionToolNames(owned)
65
+ const get = tools?.get
66
+ if (typeof get !== 'function') return names
67
+
68
+ const active = []
69
+ for (const name of names) {
70
+ try {
71
+ // DSH restrict() accepts only names that exist in the current global
72
+ // registry. Logical DVR ownership is broader: an intermediate boundary
73
+ // may capture a conditional definition without mounting it yet
74
+ // (vision_screenshot is the canonical example).
75
+ const definition = get.call(tools, name)
76
+ if (definition?.[VISION_ROUTER_TOOL_OWNER] === true) active.push(name)
77
+ } catch {
78
+ // Older/partial Hosts may not offer a safely readable registry view.
79
+ // Preserve the historical fail-closed path there; per-name fallback
80
+ // below still prevents one bad entry from exposing every other tool.
81
+ return names
82
+ }
83
+ }
84
+ return active
85
+ }
86
+
87
+ function assemblyOwnedVisionToolNames(registryTools, scopedTools, owned) {
88
+ const active = restrictableOwnedVisionToolNames(registryTools, owned)
89
+ const scopedGet = scopedTools?.get
90
+ if (typeof scopedGet !== 'function') return new Set(active)
91
+
92
+ const projected = new Set()
93
+ for (const name of active) {
94
+ try {
95
+ const visible = scopedGet.call(scopedTools, name)
96
+ // A foreign Agent-scoped definition is allowed to shadow DVR's global
97
+ // name. DSH restrictions deliberately leave scoped registrations visible,
98
+ // so the final assembly projection must preserve the same boundary.
99
+ if (
100
+ visible === undefined
101
+ || visible?.[VISION_ROUTER_TOOL_OWNER] === true
102
+ ) {
103
+ projected.add(name)
104
+ }
105
+ } catch {
106
+ // If the scoped view cannot be read, keep the conservative global DVR
107
+ // projection. This support-window fallback hides only a name whose global
108
+ // registration is proven DVR-owned.
109
+ projected.add(name)
110
+ }
111
+ }
112
+ return projected
113
+ }
114
+
62
115
  function rememberOwnedTool(owned, name) {
63
116
  owned.set(name, (owned.get(name) ?? 0) + 1)
64
117
  }
@@ -89,18 +142,30 @@ function releaseAgentToolRestriction(agent, restrictions) {
89
142
  restrictions.delete(agent)
90
143
  }
91
144
 
145
+ function restrictionFailureLabel(error) {
146
+ const name =
147
+ typeof error?.name === 'string' && error.name !== ''
148
+ ? error.name
149
+ : 'Error'
150
+ const code =
151
+ typeof error?.code === 'string' && error.code !== ''
152
+ ? `/${error.code}`
153
+ : ''
154
+ return `${name}${code}`
155
+ }
156
+
92
157
  function warnRestrictionFailure(agent, error, deny, failed = deny) {
93
158
  try {
94
159
  agent?.ctx?.logger?.warn?.(
95
- 'vision-router: failed to restrict Session vision tools while Vision mode is off: %s (deny=%s failed=%s)',
96
- error?.message ?? String(error),
97
- deny.join(','),
160
+ 'vision-router: failed to restrict Session vision tools while Vision mode is off: %s (attempted=%s failed=%s)',
161
+ restrictionFailureLabel(error),
162
+ String(deny.length),
98
163
  failed.join(','),
99
164
  )
100
165
  } catch {}
101
166
  }
102
167
 
103
- function syncAgentToolRestriction(agent, enabled, restrictions, owned) {
168
+ function syncAgentToolRestriction(agent, enabled, restrictions, owned, registryTools) {
104
169
  if (!isObject(agent)) return
105
170
  if (enabled) {
106
171
  releaseAgentToolRestriction(agent, restrictions)
@@ -109,7 +174,11 @@ function syncAgentToolRestriction(agent, enabled, restrictions, owned) {
109
174
 
110
175
  const tools = agent?.ctx?.tools
111
176
  const restrict = tools?.restrict
112
- const deny = ownedVisionToolNames(owned)
177
+ // Registration truth must come from the unscoped/global registry view.
178
+ // agent.ctx.tools.get() is restriction-aware: after this boundary denies a
179
+ // DVR tool it reads as absent for that Agent, so using it here would make a
180
+ // later sync tear down its own restriction and re-expose the capability.
181
+ const deny = restrictableOwnedVisionToolNames(registryTools, owned)
113
182
  if (typeof restrict !== 'function') return
114
183
  if (deny.length === 0) {
115
184
  releaseAgentToolRestriction(agent, restrictions)
@@ -185,6 +254,7 @@ function wrapTools(tools, ctx, config, owned) {
185
254
  const execute = definition.execute
186
255
  const registered = register.call(target, {
187
256
  ...definition,
257
+ [VISION_ROUTER_TOOL_OWNER]: true,
188
258
  async execute(args, exec) {
189
259
  if (
190
260
  exec?.agent &&
@@ -264,6 +334,7 @@ export function installSessionVisionModeBoundary(ctx, config = {}) {
264
334
  visionToolSurfaceEnabled(ctx, config, authority),
265
335
  restrictions,
266
336
  ownedVisionTools,
337
+ ctx?.tools,
267
338
  )
268
339
  }
269
340
 
@@ -282,6 +353,7 @@ export function installSessionVisionModeBoundary(ctx, config = {}) {
282
353
  visionToolSurfaceEnabled(ctx, config, authority),
283
354
  restrictions,
284
355
  ownedVisionTools,
356
+ ctx?.tools,
285
357
  )
286
358
  }
287
359
  return authority
@@ -314,10 +386,14 @@ export function installSessionVisionModeBoundary(ctx, config = {}) {
314
386
  if (!authority && isObject(context)) authority = captureForAssembly(context)
315
387
  if (!authority && isObject(agent)) authority = resolveForAgent(agent)
316
388
  const transformed = typeof next === 'function' ? await next() : assembly
389
+ const projectedOwned =
390
+ ctx?.tools
391
+ ? assemblyOwnedVisionToolNames(ctx.tools, agent?.ctx?.tools, ownedVisionTools)
392
+ : ownedVisionTools
317
393
  return projectAssemblyTools(
318
394
  transformed,
319
395
  visionToolSurfaceEnabled(ctx, config, authority),
320
- ownedVisionTools,
396
+ projectedOwned,
321
397
  )
322
398
  })
323
399
  } catch {
@@ -390,6 +466,7 @@ export function installSessionVisionModeBoundary(ctx, config = {}) {
390
466
  visionToolSurfaceEnabled(target, config, authority),
391
467
  restrictions,
392
468
  ownedVisionTools,
469
+ target?.tools,
393
470
  )
394
471
  return runWithSessionVisionModeAuthority(
395
472
  authority,
@@ -16,13 +16,17 @@ export const DEFAULT_SESSION_VISION_STATE_OPTIONS = Object.freeze({
16
16
  * It is not a general runtime service locator. Production composition creates
17
17
  * one instance and gives the same owner to the Session boundary and Core.
18
18
  *
19
- * Durable recovery is requested through index.lookupAttachment(); the index
20
- * never monkey-patches stateStore.lookupAttachment().
19
+ * Cache lookup stays synchronous through index.lookupAttachment(); cold durable
20
+ * recovery is explicit through index.resolveAttachment(). The index never
21
+ * monkey-patches stateStore.lookupAttachment(), and receives only narrow Host
22
+ * Session readers rather than the full Context.
21
23
  */
22
24
  export function createSessionVisionRuntime({
23
25
  core,
24
26
  config = {},
25
27
  logger,
28
+ readSessionEvent,
29
+ readSessionLog,
26
30
  stateStore,
27
31
  stateOptions = {},
28
32
  } = {}) {
@@ -39,6 +43,8 @@ export function createSessionVisionRuntime({
39
43
  core,
40
44
  config,
41
45
  logger,
46
+ readSessionEvent,
47
+ readSessionLog,
42
48
  })
43
49
 
44
50
  return Object.freeze({
@@ -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