dsh-vision-router 2.1.7 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) 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/releases/v2.2.1.md +22 -0
  8. package/docs/remote-settings.md +2 -0
  9. package/entry.js +2 -0
  10. package/index.js +355 -64
  11. package/lib/artifact-boundary.js +11 -0
  12. package/lib/artifact-io.js +60 -7
  13. package/lib/catalog-corrections.js +5 -0
  14. package/lib/client.js +22 -17
  15. package/lib/core-primitives.js +126 -5
  16. package/lib/degraded-local-evidence.js +39 -0
  17. package/lib/dsh-contract-compat.js +225 -0
  18. package/lib/dsh-support-window.js +1 -1
  19. package/lib/fetch-wrapper-lifecycle.js +56 -0
  20. package/lib/image-offload-compat.js +38 -0
  21. package/lib/legacy-global-proxy-boundary.js +4 -3
  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/pi-ai-bridge-wire-compat.js +4 -3
  26. package/lib/pixel-diff-stream.js +12 -6
  27. package/lib/remote-settings-bridge.js +19 -1
  28. package/lib/replay-delegation.js +41 -21
  29. package/lib/runtime-composition.js +15 -2
  30. package/lib/runtime-i18n-boundary.js +4 -13
  31. package/lib/runtime-i18n.js +2 -2
  32. package/lib/session-surface-compat.js +40 -0
  33. package/lib/session-turn-resolver.js +57 -0
  34. package/lib/session-vision-index.js +438 -97
  35. package/lib/session-vision-mode-boundary.js +83 -6
  36. package/lib/session-vision-runtime.js +8 -2
  37. package/lib/session-vision-state.js +0 -13
  38. package/lib/tesseract-exec-compat.js +16 -2
  39. package/lib/twin-image-capability-fallback.js +2 -11
  40. package/lib/vision-artifact-store.js +16 -3
  41. package/lib/vision-attachment-handle-runtime.js +9 -21
  42. package/lib/vision-backend-runtime-policy.js +7 -6
  43. package/lib/vision-breaker-shadow-health.js +26 -6
  44. package/lib/vision-evidence-guidance.js +39 -0
  45. package/lib/vision-resilience.js +73 -1
  46. package/package.json +12 -7
@@ -1,11 +1,14 @@
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
 
5
+ const MAX_PENDING_REPAIR_EVENTS = 256
6
+
4
7
  function isObject(value) {
5
8
  return value !== null && typeof value === 'object'
6
9
  }
7
10
 
8
- function sessionEvents(session) {
11
+ function legacySessionEvents(session) {
9
12
  try {
10
13
  // dsh 0.1.2-alpha.4 removed the bare `session.events` array in favor of
11
14
  // `session.snapshotEvents()`; both are handled so one build runs on both
@@ -38,23 +41,35 @@ function boundedMessage(error) {
38
41
  return String(text ?? '').slice(0, 400)
39
42
  }
40
43
 
44
+ function isReplacementEvent(event) {
45
+ const op = event?.surfaceOp
46
+ return !!op && typeof op === 'object' && !Array.isArray(op) && op.op === 'replace'
47
+ }
48
+
41
49
  /**
42
50
  * Session visual data-plane index.
43
51
  *
44
52
  * 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.
53
+ * target-only durable attachment recovery, event-feed-backed tool-result image
54
+ * surface repair and expired structured guard-stop surface repair.
55
+ *
56
+ * Supported DSH Hosts expose the post-commit `session/event` feed. Production
57
+ * surface repair consumes only the exact frozen events observed on that feed;
58
+ * it never asks SessionQuery to materialize or clone the live Session history.
59
+ * The older reader path remains only for isolated/partial callers that do not
60
+ * install the event feed boundary.
48
61
  *
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.
62
+ * The index never replaces stateStore.lookupAttachment(). Cache lookup stays
63
+ * synchronous through lookupAttachment(); cold durable recovery is requested
64
+ * explicitly through async resolveAttachment(), so I/O ownership remains visible.
52
65
  */
53
66
  export function createSessionVisionIndex({
54
67
  stateStore,
55
68
  core,
56
69
  config = {},
57
70
  logger,
71
+ readSessionEvent,
72
+ readSessionLog,
58
73
  } = {}) {
59
74
  if (!core || typeof core !== 'object') throw new TypeError('session vision index requires core helpers')
60
75
 
@@ -70,59 +85,103 @@ export function createSessionVisionIndex({
70
85
  : undefined
71
86
  const toolSurfaceScans = new WeakMap()
72
87
  const guardSurfaceScans = new WeakMap()
88
+ const pendingToolRepairEvents = new WeakMap()
89
+ const pendingGuardRepairEvents = new WeakMap()
90
+ const unsupportedSurfaceContracts = new WeakSet()
91
+ const repairReadWarnings = new WeakMap()
92
+ const repairFeedOverflowWarnings = new WeakSet()
93
+ const repairFeedObserverWarnings = new WeakMap()
94
+ const attachmentRecoveryWarnings = new WeakMap()
95
+ let surfaceEventFeedActive = false
73
96
 
74
97
  const recordAttachments = (session, refs) => {
75
98
  if (!session || !Array.isArray(refs) || refs.length === 0) return
76
99
  store.recordAttachments(session, refs)
77
100
  }
78
101
 
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
-
102
+ // Synchronous lookup is cache-only. Projected short handles first warm this
103
+ // cache from current derived messages and must not trigger historical I/O.
94
104
  const lookupAttachment = (session, id) => {
95
105
  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
103
-
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
106
+ return primitiveLookup(session, id)
107
+ }
108
+
109
+ const recoverAttachmentsFromEvents = (session, ids, events) => {
110
+ const found = new Map()
111
+ if (!Array.isArray(events) || events.length === 0 || typeof core.collectEventAttachmentRefs !== 'function') {
112
+ return found
110
113
  }
111
- const wanted = String(id)
112
- const recovered = core.collectEventAttachmentRefs(events).find(
113
- (ref) => ref && String(ref.attachmentId ?? ref.id) === wanted,
114
+ const wanted = new Set(ids.map((id) => String(id)))
115
+ for (const ref of core.collectEventAttachmentRefs(events)) {
116
+ if (!ref) continue
117
+ const id = String(ref.attachmentId ?? ref.id)
118
+ if (wanted.has(id) && !found.has(id)) found.set(id, ref)
119
+ }
120
+ if (found.size > 0) store.recordAttachments(session, [...found.values()])
121
+ return found
122
+ }
123
+
124
+ const warnAttachmentRecoveryFailure = (session, id, error) => {
125
+ const message = boundedMessage(error)
126
+ const key = `${String(id)}:${message}`
127
+ if (attachmentRecoveryWarnings.get(session) === key) return
128
+ attachmentRecoveryWarnings.set(session, key)
129
+ logger?.warn?.(
130
+ 'vision-router: session attachment recovery failed id=%s error=%s',
131
+ String(id),
132
+ message,
114
133
  )
115
- if (recovered !== undefined) {
116
- store.recordAttachments(session, [recovered])
117
- return recovered
134
+ }
135
+
136
+ const resolveAttachments = async (session, ids) => {
137
+ const requested = [...new Set((Array.isArray(ids) ? ids : []).map((id) => String(id)))]
138
+ const resolved = new Map()
139
+ const missing = []
140
+ for (const id of requested) {
141
+ const cached = lookupAttachment(session, id)
142
+ if (cached !== undefined) resolved.set(id, cached)
143
+ else missing.push(id)
118
144
  }
119
- return undefined
145
+ if (missing.length === 0 || session === undefined) return resolved
146
+
147
+ let events
148
+ if (typeof readSessionLog === 'function') {
149
+ let result
150
+ try {
151
+ result = await readSessionLog(session)
152
+ } catch (error) {
153
+ warnAttachmentRecoveryFailure(session, missing[0], error)
154
+ return resolved
155
+ }
156
+ if (result?.supported === true) {
157
+ attachmentRecoveryWarnings.delete(session)
158
+ events = result.events
159
+ } else if (result?.supported !== false) {
160
+ warnAttachmentRecoveryFailure(
161
+ session,
162
+ missing[0],
163
+ new Error('Session log reader returned an invalid capability result'),
164
+ )
165
+ return resolved
166
+ }
167
+ }
168
+
169
+ // Historical/partial Hosts without SessionQuery keep the released fallback.
170
+ // Public rc8+ Hosts use readSessionLog above and never touch sync history.
171
+ if (events === undefined) events = legacySessionEvents(session)
172
+ const recovered = recoverAttachmentsFromEvents(session, missing, events)
173
+ for (const [id, ref] of recovered) resolved.set(id, ref)
174
+ return resolved
120
175
  }
121
176
 
122
- const nextSurfaceSeqs = (scans, session) => {
123
- if (!session) return []
177
+ const resolveAttachment = async (session, id) => {
178
+ return (await resolveAttachments(session, [id])).get(String(id))
179
+ }
180
+
181
+ const pendingSurfaceScan = (scans, session) => {
182
+ if (!session) return undefined
124
183
  const nodes = surfaceNodes(session)
125
- if (!nodes || nodes.length === 0) return []
184
+ if (!nodes || nodes.length === 0) return undefined
126
185
  let scan = scans.get(session)
127
186
  if (!scan) {
128
187
  scan = { count: 0 }
@@ -131,9 +190,88 @@ export function createSessionVisionIndex({
131
190
  // Compaction/resume may shrink/rebuild the surface. Reset the incremental
132
191
  // cursor rather than assuming old indices still refer to the new surface.
133
192
  if (scan.count > nodes.length) scan.count = 0
134
- const fresh = nodes.slice(scan.count)
135
- scan.count = nodes.length
136
- return fresh
193
+ const count = nodes.length
194
+ const seqs = nodes.slice(scan.count)
195
+ if (seqs.length === 0) return undefined
196
+ return {
197
+ seqs,
198
+ advance() {
199
+ if (scan.count < count) scan.count += 1
200
+ },
201
+ }
202
+ }
203
+
204
+ const warnRepairReadFailure = (session, seq, error) => {
205
+ const message = boundedMessage(error)
206
+ const previous = repairReadWarnings.get(session)
207
+ if (previous?.seq === seq && previous.message === message) return
208
+ repairReadWarnings.set(session, { seq, message })
209
+ logger?.warn?.(
210
+ 'vision-router: session surface event read failed seq=%s error=%s',
211
+ seq,
212
+ message,
213
+ )
214
+ }
215
+
216
+ const eventForSurfaceRepair = async (session, seq) => {
217
+ if (typeof readSessionEvent === 'function') {
218
+ let result
219
+ try {
220
+ result = await readSessionEvent(session, seq)
221
+ } catch (error) {
222
+ warnRepairReadFailure(session, seq, error)
223
+ return { readable: false }
224
+ }
225
+ if (result?.supported === true) {
226
+ repairReadWarnings.delete(session)
227
+ return { readable: true, event: result.event }
228
+ }
229
+ if (result?.supported !== false) {
230
+ warnRepairReadFailure(session, seq, new Error('Session event reader returned an invalid capability result'))
231
+ return { readable: false }
232
+ }
233
+ }
234
+
235
+ // Isolated/partial Hosts that do not expose SessionQuery retain the released
236
+ // Session-local compatibility path. Supported production Hosts install the
237
+ // event feed and never enter this compatibility reader.
238
+ const events = legacySessionEvents(session)
239
+ if (!events) return { readable: false }
240
+ return { readable: true, event: events[seq] }
241
+ }
242
+
243
+ const surfaceRepairSnapshot = async (session, seq) => {
244
+ if (typeof readSessionLog !== 'function') return undefined
245
+ let result
246
+ try {
247
+ result = await readSessionLog(session)
248
+ } catch (error) {
249
+ warnRepairReadFailure(session, seq, error)
250
+ return { readable: false }
251
+ }
252
+ if (result?.supported === true) {
253
+ if (!Array.isArray(result.events)) {
254
+ warnRepairReadFailure(session, seq, new Error('Session log reader returned no event log'))
255
+ return { readable: false }
256
+ }
257
+ repairReadWarnings.delete(session)
258
+ return { readable: true, events: result.events }
259
+ }
260
+ if (result?.supported !== false) {
261
+ warnRepairReadFailure(session, seq, new Error('Session log reader returned an invalid capability result'))
262
+ return { readable: false }
263
+ }
264
+ return undefined
265
+ }
266
+
267
+ const eventFromRepairSnapshot = (session, events, seq) => {
268
+ const event = events?.[seq]
269
+ if (!event || typeof event !== 'object' || (event.seq !== undefined && event.seq !== seq)) {
270
+ warnRepairReadFailure(session, seq, new Error(`Session repair snapshot has no event at seq ${seq}`))
271
+ return { readable: false }
272
+ }
273
+ repairReadWarnings.delete(session)
274
+ return { readable: true, event }
137
275
  }
138
276
 
139
277
  const keepToolResultImages = () => {
@@ -143,60 +281,241 @@ export function createSessionVisionIndex({
143
281
  return policy.ownership === 'native-image' || policy.ownership === 'vision-router-owned'
144
282
  }
145
283
 
284
+ const replacementIntent = (session, seq) => {
285
+ const intent = sessionSurfaceReplacementIntent(session, seq)
286
+ if (intent !== undefined) return intent
287
+ if (!unsupportedSurfaceContracts.has(session)) {
288
+ unsupportedSurfaceContracts.add(session)
289
+ logger?.warn?.(
290
+ 'vision-router: skipping durable session surface repair for unsupported Session format version=%s',
291
+ session?.header?.version ?? 'unknown',
292
+ )
293
+ }
294
+ return undefined
295
+ }
296
+
297
+ const cachePendingRepairEvent = (pendingBySession, session, event) => {
298
+ if (!session || !Number.isSafeInteger(event?.seq) || event.seq < 0 || Object.is(event.seq, -0)) return
299
+ let pending = pendingBySession.get(session)
300
+ if (!pending) {
301
+ pending = new Map()
302
+ pendingBySession.set(session, pending)
303
+ }
304
+ if (!pending.has(event.seq) && pending.size >= MAX_PENDING_REPAIR_EVENTS) {
305
+ const oldest = pending.keys().next().value
306
+ if (oldest !== undefined) pending.delete(oldest)
307
+ if (!repairFeedOverflowWarnings.has(session)) {
308
+ repairFeedOverflowWarnings.add(session)
309
+ logger?.warn?.(
310
+ 'vision-router: session surface repair event feed exceeded %s pending events; dropping oldest entry',
311
+ MAX_PENDING_REPAIR_EVENTS,
312
+ )
313
+ }
314
+ }
315
+ pending.set(event.seq, event)
316
+ }
317
+
318
+ const planAgainstOneEvent = (event, planner) => {
319
+ if (!Number.isSafeInteger(event?.seq) || event.seq < 0 || Object.is(event.seq, -0)) return []
320
+ const events = { [event.seq]: event }
321
+ return planner(events, [event.seq])
322
+ }
323
+
324
+ const recordSessionEvent = (session, event) => {
325
+ if (!session || !event || typeof event !== 'object') return
326
+ try {
327
+ if (typeof core.collectEventAttachmentRefs === 'function') {
328
+ const refs = core.collectEventAttachmentRefs([event])
329
+ if (Array.isArray(refs) && refs.length > 0) recordAttachments(session, refs)
330
+ }
331
+
332
+ // Replacement events are consequences of this repair path and must never
333
+ // be re-enqueued, otherwise a guard-stop replacement would shadow itself
334
+ // forever because its stable plugin-owned id is intentionally preserved.
335
+ if (isReplacementEvent(event)) return
336
+
337
+ if (event.type === 'tool/result' && typeof core.planToolResultImageShadows === 'function') {
338
+ const plans = planAgainstOneEvent(
339
+ event,
340
+ (events, seqs) => core.planToolResultImageShadows(events, seqs, () => true),
341
+ )
342
+ if (plans.length > 0) cachePendingRepairEvent(pendingToolRepairEvents, session, event)
343
+ } else if (event.type === 'user/message' && typeof core.planGuardStopShadows === 'function') {
344
+ const plans = planAgainstOneEvent(event, (events, seqs) => core.planGuardStopShadows(events, seqs))
345
+ if (plans.length > 0) cachePendingRepairEvent(pendingGuardRepairEvents, session, event)
346
+ }
347
+ repairFeedObserverWarnings.delete(session)
348
+ } catch (error) {
349
+ const message = boundedMessage(error)
350
+ if (repairFeedObserverWarnings.get(session) === message) return
351
+ repairFeedObserverWarnings.set(session, message)
352
+ logger?.warn?.('vision-router: session event feed observation failed error=%s', message)
353
+ }
354
+ }
355
+
356
+ const activateSurfaceEventFeed = () => {
357
+ surfaceEventFeedActive = true
358
+ }
359
+
360
+ const consumePendingRepairEvents = (pendingBySession, session) => {
361
+ const pending = pendingBySession.get(session)
362
+ if (!pending || pending.size === 0) return []
363
+ const nodes = surfaceNodes(session)
364
+ if (!nodes || nodes.length === 0) {
365
+ pending.clear()
366
+ return []
367
+ }
368
+ const current = new Set(nodes)
369
+ for (const seq of pending.keys()) {
370
+ if (!current.has(seq)) pending.delete(seq)
371
+ }
372
+ const entries = []
373
+ for (const seq of nodes) {
374
+ const event = pending.get(seq)
375
+ if (event === undefined) continue
376
+ pending.delete(seq)
377
+ entries.push({ seq, event })
378
+ }
379
+ return entries
380
+ }
381
+
382
+ const repairToolResultSurfaceFromFeed = async (session) => {
383
+ if (typeof session?.append !== 'function' || typeof core.planToolResultImageShadows !== 'function') return 0
384
+ const pending = consumePendingRepairEvents(pendingToolRepairEvents, session)
385
+ if (pending.length === 0) return 0
386
+ const preserve = keepToolResultImages()
387
+ let repaired = 0
388
+ for (const { seq, event } of pending) {
389
+ const events = { [seq]: event }
390
+ const plans = core.planToolResultImageShadows(events, [seq], () => !preserve)
391
+ for (const plan of plans) {
392
+ try {
393
+ const intent = replacementIntent(session, plan.seq)
394
+ if (intent === undefined) continue
395
+ await session.append(
396
+ 'tool/result',
397
+ { ...plan.event.data, message: plan.message },
398
+ intent,
399
+ )
400
+ repaired += 1
401
+ } catch (error) {
402
+ logger?.warn?.(
403
+ 'vision-router: session tool-result surface repair failed seq=%s error=%s',
404
+ plan.seq,
405
+ boundedMessage(error),
406
+ )
407
+ }
408
+ }
409
+ }
410
+ return repaired
411
+ }
412
+
413
+ const repairGuardStopSurfaceFromFeed = async (session) => {
414
+ if (typeof session?.append !== 'function' || typeof core.planGuardStopShadows !== 'function') return 0
415
+ const pending = consumePendingRepairEvents(pendingGuardRepairEvents, session)
416
+ if (pending.length === 0) return 0
417
+ let repaired = 0
418
+ for (const { seq, event } of pending) {
419
+ const events = { [seq]: event }
420
+ const plans = core.planGuardStopShadows(events, [seq])
421
+ for (const plan of plans) {
422
+ try {
423
+ const intent = replacementIntent(session, plan.seq)
424
+ if (intent === undefined) continue
425
+ await session.append(
426
+ 'user/message',
427
+ plan.data,
428
+ intent,
429
+ )
430
+ repaired += 1
431
+ } catch (error) {
432
+ logger?.warn?.(
433
+ 'vision-router: session guard-stop surface repair failed seq=%s error=%s',
434
+ plan.seq,
435
+ boundedMessage(error),
436
+ )
437
+ }
438
+ }
439
+ }
440
+ return repaired
441
+ }
442
+
146
443
  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
444
+ if (surfaceEventFeedActive) return repairToolResultSurfaceFromFeed(session)
445
+ if (typeof session?.append !== 'function') return 0
446
+ const scan = pendingSurfaceScan(toolSurfaceScans, session)
447
+ if (!scan || typeof core.planToolResultImageShadows !== 'function') return 0
151
448
  const preserve = keepToolResultImages()
152
- const plans = core.planToolResultImageShadows(events, seqs, () => !preserve)
449
+ const snapshot = await surfaceRepairSnapshot(session, scan.seqs[0])
450
+ if (snapshot?.readable === false) return 0
153
451
  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
- )
452
+ for (const seq of scan.seqs) {
453
+ const read = snapshot?.readable === true
454
+ ? eventFromRepairSnapshot(session, snapshot.events, seq)
455
+ : await eventForSurfaceRepair(session, seq)
456
+ if (!read.readable) break
457
+ // A readable node is settled for this repair pass even when it needs no
458
+ // replacement or append later fails; only an unread node remains pending.
459
+ scan.advance()
460
+ const events = []
461
+ events[seq] = read.event
462
+ const plans = core.planToolResultImageShadows(events, [seq], () => !preserve)
463
+ for (const plan of plans) {
464
+ try {
465
+ const intent = replacementIntent(session, plan.seq)
466
+ if (intent === undefined) continue
467
+ await session.append(
468
+ 'tool/result',
469
+ { ...plan.event.data, message: plan.message },
470
+ intent,
471
+ )
472
+ repaired += 1
473
+ } catch (error) {
474
+ logger?.warn?.(
475
+ 'vision-router: session tool-result surface repair failed seq=%s error=%s',
476
+ plan.seq,
477
+ boundedMessage(error),
478
+ )
479
+ }
171
480
  }
172
481
  }
173
482
  return repaired
174
483
  }
175
484
 
176
485
  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)
486
+ if (surfaceEventFeedActive) return repairGuardStopSurfaceFromFeed(session)
487
+ if (typeof session?.append !== 'function') return 0
488
+ const scan = pendingSurfaceScan(guardSurfaceScans, session)
489
+ if (!scan || typeof core.planGuardStopShadows !== 'function') return 0
490
+ const snapshot = await surfaceRepairSnapshot(session, scan.seqs[0])
491
+ if (snapshot?.readable === false) return 0
182
492
  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
- )
493
+ for (const seq of scan.seqs) {
494
+ const read = snapshot?.readable === true
495
+ ? eventFromRepairSnapshot(session, snapshot.events, seq)
496
+ : await eventForSurfaceRepair(session, seq)
497
+ if (!read.readable) break
498
+ scan.advance()
499
+ const events = []
500
+ events[seq] = read.event
501
+ const plans = core.planGuardStopShadows(events, [seq])
502
+ for (const plan of plans) {
503
+ try {
504
+ const intent = replacementIntent(session, plan.seq)
505
+ if (intent === undefined) continue
506
+ await session.append(
507
+ 'user/message',
508
+ plan.data,
509
+ intent,
510
+ )
511
+ repaired += 1
512
+ } catch (error) {
513
+ logger?.warn?.(
514
+ 'vision-router: session guard-stop surface repair failed seq=%s error=%s',
515
+ plan.seq,
516
+ boundedMessage(error),
517
+ )
518
+ }
200
519
  }
201
520
  }
202
521
  return repaired
@@ -213,7 +532,6 @@ export function createSessionVisionIndex({
213
532
  store.recordAttachments(session, found.attachments)
214
533
  }
215
534
  }
216
- scanEventLog(session)
217
535
  await repairToolResultSurface(session)
218
536
  await repairGuardStopSurface(session)
219
537
  return decision
@@ -221,8 +539,11 @@ export function createSessionVisionIndex({
221
539
 
222
540
  return Object.freeze({
223
541
  recordAttachments,
224
- scanEventLog,
542
+ recordSessionEvent,
543
+ activateSurfaceEventFeed,
225
544
  lookupAttachment,
545
+ resolveAttachment,
546
+ resolveAttachments,
226
547
  repairToolResultSurface,
227
548
  repairGuardStopSurface,
228
549
  prepareDecision,
@@ -231,8 +552,9 @@ export function createSessionVisionIndex({
231
552
 
232
553
  /**
233
554
  * Intercept core's pre-step registration and decorate only its downstream
234
- * `next()` result. Production passes `options.index`; direct/test callers get
235
- * an isolated index/store unless they explicitly provide `options.stateStore`.
555
+ * `next()` result. Production also subscribes the index to DSH's post-commit
556
+ * `session/event` feed before activating feed-backed surface repair. Direct/test
557
+ * callers without a usable event feed retain the compatibility reader path.
236
558
  */
237
559
  export function installSessionVisionIndexBoundary(ctx, config, core, options = {}) {
238
560
  if (!isObject(ctx)) return ctx
@@ -250,6 +572,25 @@ export function installSessionVisionIndexBoundary(ctx, config, core, options = {
250
572
  logger: options.logger,
251
573
  })
252
574
 
575
+ try {
576
+ const on = Reflect.get(ctx, 'on', ctx)
577
+ if (
578
+ typeof on === 'function'
579
+ && typeof index.recordSessionEvent === 'function'
580
+ && typeof index.activateSurfaceEventFeed === 'function'
581
+ ) {
582
+ on.call(ctx, 'session/event', (session, event) => {
583
+ index.recordSessionEvent(session, event)
584
+ })
585
+ index.activateSurfaceEventFeed()
586
+ }
587
+ } catch (error) {
588
+ options.logger?.warn?.(
589
+ 'vision-router: session event feed unavailable; retaining compatibility surface reader error=%s',
590
+ boundedMessage(error),
591
+ )
592
+ }
593
+
253
594
  return new Proxy(ctx, {
254
595
  get(target, property) {
255
596
  if (property === 'on') {