dsh-vision-router 2.2.0 → 2.2.2

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.
@@ -2,6 +2,8 @@ import { currentSessionSurfacePolicy } from './session-surface-policy.js'
2
2
  import { sessionSurfaceReplacementIntent } from './session-surface-compat.js'
3
3
  import { createSessionVisionStateStore } from './session-vision-state.js'
4
4
 
5
+ const MAX_PENDING_REPAIR_EVENTS = 256
6
+
5
7
  function isObject(value) {
6
8
  return value !== null && typeof value === 'object'
7
9
  }
@@ -39,13 +41,23 @@ function boundedMessage(error) {
39
41
  return String(text ?? '').slice(0, 400)
40
42
  }
41
43
 
44
+ function isReplacementEvent(event) {
45
+ const op = event?.surfaceOp
46
+ return !!op && typeof op === 'object' && !Array.isArray(op) && op.op === 'replace'
47
+ }
48
+
42
49
  /**
43
50
  * Session visual data-plane index.
44
51
  *
45
52
  * SessionVisionStateStore remains the bounded storage owner. This object owns
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.
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.
49
61
  *
50
62
  * The index never replaces stateStore.lookupAttachment(). Cache lookup stays
51
63
  * synchronous through lookupAttachment(); cold durable recovery is requested
@@ -73,16 +85,20 @@ export function createSessionVisionIndex({
73
85
  : undefined
74
86
  const toolSurfaceScans = new WeakMap()
75
87
  const guardSurfaceScans = new WeakMap()
88
+ const pendingToolRepairEvents = new WeakMap()
89
+ const pendingGuardRepairEvents = new WeakMap()
76
90
  const unsupportedSurfaceContracts = new WeakSet()
77
91
  const repairReadWarnings = new WeakMap()
92
+ const repairFeedOverflowWarnings = new WeakSet()
93
+ const repairFeedObserverWarnings = new WeakMap()
78
94
  const attachmentRecoveryWarnings = new WeakMap()
95
+ let surfaceEventFeedActive = false
79
96
 
80
97
  const recordAttachments = (session, refs) => {
81
98
  if (!session || !Array.isArray(refs) || refs.length === 0) return
82
99
  store.recordAttachments(session, refs)
83
100
  }
84
101
 
85
-
86
102
  // Synchronous lookup is cache-only. Projected short handles first warm this
87
103
  // cache from current derived messages and must not trigger historical I/O.
88
104
  const lookupAttachment = (session, id) => {
@@ -217,13 +233,47 @@ export function createSessionVisionIndex({
217
233
  }
218
234
 
219
235
  // 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.
236
+ // Session-local compatibility path. Supported production Hosts install the
237
+ // event feed and never enter this compatibility reader.
222
238
  const events = legacySessionEvents(session)
223
239
  if (!events) return { readable: false }
224
240
  return { readable: true, event: events[seq] }
225
241
  }
226
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 }
275
+ }
276
+
227
277
  const keepToolResultImages = () => {
228
278
  const policy = currentSessionSurfacePolicy(
229
279
  typeof config === 'function' ? config() : config,
@@ -244,14 +294,165 @@ export function createSessionVisionIndex({
244
294
  return undefined
245
295
  }
246
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
+
247
443
  const repairToolResultSurface = async (session) => {
444
+ if (surfaceEventFeedActive) return repairToolResultSurfaceFromFeed(session)
248
445
  if (typeof session?.append !== 'function') return 0
249
446
  const scan = pendingSurfaceScan(toolSurfaceScans, session)
250
447
  if (!scan || typeof core.planToolResultImageShadows !== 'function') return 0
251
448
  const preserve = keepToolResultImages()
449
+ const snapshot = await surfaceRepairSnapshot(session, scan.seqs[0])
450
+ if (snapshot?.readable === false) return 0
252
451
  let repaired = 0
253
452
  for (const seq of scan.seqs) {
254
- const read = await eventForSurfaceRepair(session, seq)
453
+ const read = snapshot?.readable === true
454
+ ? eventFromRepairSnapshot(session, snapshot.events, seq)
455
+ : await eventForSurfaceRepair(session, seq)
255
456
  if (!read.readable) break
256
457
  // A readable node is settled for this repair pass even when it needs no
257
458
  // replacement or append later fails; only an unread node remains pending.
@@ -282,12 +483,17 @@ export function createSessionVisionIndex({
282
483
  }
283
484
 
284
485
  const repairGuardStopSurface = async (session) => {
486
+ if (surfaceEventFeedActive) return repairGuardStopSurfaceFromFeed(session)
285
487
  if (typeof session?.append !== 'function') return 0
286
488
  const scan = pendingSurfaceScan(guardSurfaceScans, session)
287
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
288
492
  let repaired = 0
289
493
  for (const seq of scan.seqs) {
290
- const read = await eventForSurfaceRepair(session, seq)
494
+ const read = snapshot?.readable === true
495
+ ? eventFromRepairSnapshot(session, snapshot.events, seq)
496
+ : await eventForSurfaceRepair(session, seq)
291
497
  if (!read.readable) break
292
498
  scan.advance()
293
499
  const events = []
@@ -333,6 +539,8 @@ export function createSessionVisionIndex({
333
539
 
334
540
  return Object.freeze({
335
541
  recordAttachments,
542
+ recordSessionEvent,
543
+ activateSurfaceEventFeed,
336
544
  lookupAttachment,
337
545
  resolveAttachment,
338
546
  resolveAttachments,
@@ -344,8 +552,9 @@ export function createSessionVisionIndex({
344
552
 
345
553
  /**
346
554
  * Intercept core's pre-step registration and decorate only its downstream
347
- * `next()` result. Production passes `options.index`; direct/test callers get
348
- * 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.
349
558
  */
350
559
  export function installSessionVisionIndexBoundary(ctx, config, core, options = {}) {
351
560
  if (!isObject(ctx)) return ctx
@@ -363,6 +572,25 @@ export function installSessionVisionIndexBoundary(ctx, config, core, options = {
363
572
  logger: options.logger,
364
573
  })
365
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
+
366
594
  return new Proxy(ctx, {
367
595
  get(target, property) {
368
596
  if (property === 'on') {
@@ -0,0 +1,331 @@
1
+ import { htmlHasScriptMarker } from './html-script-marker.js'
2
+ import { LOCAL_SETTINGS_PATH } from './dsh-settings-017-compat.js'
3
+
4
+ const SETTINGS_017_MARK = 'data-vision-router-settings-017-compat'
5
+
6
+ export const SETTINGS_017_CLIENT_PRELUDE = String.raw`(function(){
7
+ 'use strict';
8
+ var TARGET = 'dsh-vision-router';
9
+ var ENDPOINT = '${LOCAL_SETTINGS_PATH}';
10
+ var contextCache = typeof WeakMap === 'function' ? new WeakMap() : undefined;
11
+ var scopeCache = typeof WeakMap === 'function' ? new WeakMap() : undefined;
12
+ var binderCache = typeof WeakMap === 'function' ? new WeakMap() : undefined;
13
+ var connectionCache = typeof WeakMap === 'function' ? new WeakMap() : undefined;
14
+
15
+ function safeGet(ctx, name) {
16
+ if (!ctx) return undefined;
17
+ try {
18
+ if (typeof ctx.get === 'function') {
19
+ var value = ctx.get(name);
20
+ if (value !== undefined && value !== null) return value;
21
+ }
22
+ } catch (_) {}
23
+ try { return ctx[name]; } catch (_) { return undefined; }
24
+ }
25
+
26
+ function isLoopbackLocation(locationLike) {
27
+ var hostname = locationLike && typeof locationLike.hostname === 'string'
28
+ ? locationLike.hostname.toLowerCase().replace(/^\[|\]$/g, '')
29
+ : '';
30
+ if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '::1') return true;
31
+ return /^127(?:\.\d{1,3}){3}$/.test(hostname);
32
+ }
33
+
34
+ function normalizeConnection(connection) {
35
+ if (!connection || typeof connection !== 'object') return connection;
36
+ var locationLike;
37
+ try { locationLike = window && window.location; } catch (_) { locationLike = undefined; }
38
+ if (!isLoopbackLocation(locationLike) || connection.isLoopback !== false) return connection;
39
+ if (connectionCache && connectionCache.has(connection)) return connectionCache.get(connection);
40
+ var wrapped = new Proxy(connection, {
41
+ get: function(target, property) {
42
+ if (property === 'isLoopback') return true;
43
+ var value = Reflect.get(target, property, target);
44
+ return typeof value === 'function' ? value.bind(target) : value;
45
+ }
46
+ });
47
+ if (connectionCache) connectionCache.set(connection, wrapped);
48
+ return wrapped;
49
+ }
50
+
51
+ function errorOf(value, fallback) {
52
+ var detail = value && value.error;
53
+ var error = new Error(detail && detail.message ? detail.message : fallback);
54
+ if (detail && detail.code) error.code = detail.code;
55
+ if (detail && detail.details !== undefined) error.details = detail.details;
56
+ return error;
57
+ }
58
+
59
+ function createScope() {
60
+ var listeners = new Set();
61
+ var writeTail = Promise.resolve();
62
+ var inFlight;
63
+ var snapshot = Object.freeze({
64
+ status: 'loading', value: undefined, base: undefined, user: undefined,
65
+ revision: undefined, writable: false, mode: 'host'
66
+ });
67
+ function publish(next) {
68
+ snapshot = Object.freeze(next);
69
+ listeners.forEach(function(listener){ try { listener(); } catch (_) {} });
70
+ }
71
+ function accept(view) {
72
+ if (!view || !view.value || typeof view.value !== 'object' || Array.isArray(view.value)
73
+ || !Number.isInteger(view.revision) || view.revision < 0) {
74
+ throw new Error('Vision Router local settings returned an invalid view');
75
+ }
76
+ publish({
77
+ status: 'ready', value: view.value, base: view.base, user: view.user,
78
+ revision: view.revision, writable: view.writable === true, mode: 'host'
79
+ });
80
+ }
81
+ async function request(method, payload) {
82
+ if (typeof fetch !== 'function') throw new Error('Vision Router local settings transport is unavailable');
83
+ var response = await fetch(ENDPOINT, {
84
+ method: method,
85
+ headers: { accept: 'application/json', ...(method === 'POST' ? { 'content-type': 'application/json' } : {}) },
86
+ cache: 'no-store',
87
+ credentials: 'same-origin',
88
+ ...(method === 'POST' ? { body: JSON.stringify(payload) } : {})
89
+ });
90
+ var body;
91
+ try { body = await response.json(); } catch (_) { body = undefined; }
92
+ if (!response.ok || !body || body.ok !== true) throw errorOf(body, 'Vision Router local settings request failed');
93
+ return body.value;
94
+ }
95
+ function load(restart) {
96
+ if (!restart && inFlight) return inFlight;
97
+ var task = request('GET').then(accept, function(error){
98
+ publish({
99
+ status: 'unavailable', value: undefined, base: undefined, user: undefined,
100
+ revision: undefined, writable: false, mode: 'host', error: error && error.message ? error.message : String(error)
101
+ });
102
+ throw error;
103
+ });
104
+ var held = task.finally(function(){ if (inFlight === held) inFlight = undefined; });
105
+ inFlight = held;
106
+ return held;
107
+ }
108
+ function writeOps(ops) {
109
+ if (!Array.isArray(ops) || ops.length === 0) return Promise.reject(new TypeError('settings operations must be a non-empty array'));
110
+ var task = writeTail.then(async function(){
111
+ if (snapshot.status !== 'ready' || !Number.isInteger(snapshot.revision)) await load(true);
112
+ if (snapshot.status !== 'ready' || !Number.isInteger(snapshot.revision)) throw new Error('Vision Router local settings are not ready');
113
+ if (!snapshot.writable) throw new Error('Vision Router settings provider is read-only');
114
+ try {
115
+ // One ConfigEditor edit may reload DVR. Keep all fields from one UI
116
+ // Save inside that single Host transaction so a second request cannot
117
+ // get stranded between plugin generations.
118
+ var view = await request('POST', { ops: ops, expectedRevision: snapshot.revision });
119
+ accept(view);
120
+ } catch (error) {
121
+ try { await load(true); } catch (_) {}
122
+ throw error;
123
+ }
124
+ });
125
+ writeTail = task.catch(function(){});
126
+ return task;
127
+ }
128
+ function planOps(items) {
129
+ if (!Array.isArray(items) || items.length === 0) throw new TypeError('settings plan must be a non-empty array');
130
+ return items.map(function(item){
131
+ if (!item || typeof item.key !== 'string' || item.key.length === 0 || !item.run) {
132
+ throw new TypeError('settings plan item is invalid');
133
+ }
134
+ return item.run.clear
135
+ ? { op: 'unset', path: [item.key] }
136
+ : { op: 'set', path: [item.key], value: item.run.value };
137
+ });
138
+ }
139
+ var scope = {
140
+ getSnapshot: function(){ return snapshot; },
141
+ subscribe: function(listener){
142
+ if (typeof listener !== 'function') return function(){};
143
+ listeners.add(listener);
144
+ return function(){ listeners.delete(listener); };
145
+ },
146
+ load: function(){ return load(false); },
147
+ reload: function(){ return load(true); },
148
+ set: function(field, value){ return writeOps([{ op: 'set', path: [field], value: value }]); },
149
+ unset: function(field){ return writeOps([{ op: 'unset', path: [field] }]); },
150
+ __visionRouterWritePlan: function(items){ return writeOps(planOps(items)); },
151
+ dispose: async function(){ listeners.clear(); await Promise.allSettled([inFlight, writeTail].filter(Boolean)); }
152
+ };
153
+ void load(false).catch(function(){});
154
+ return scope;
155
+ }
156
+
157
+ function scopeFor(ctx) {
158
+ if (scopeCache && scopeCache.has(ctx)) return scopeCache.get(ctx);
159
+ var scope = createScope();
160
+ if (scopeCache) scopeCache.set(ctx, scope);
161
+ return scope;
162
+ }
163
+
164
+ function syntheticBinder(ctx, original) {
165
+ return {
166
+ bind: function(spec) {
167
+ if (spec && spec.namespace === 'vision-router') return scopeFor(ctx);
168
+ if (original && typeof original.bind === 'function') return original.bind(spec);
169
+ throw new Error('Settings scope ' + String(spec && spec.namespace) + ' is unavailable');
170
+ }
171
+ };
172
+ }
173
+
174
+ function configFormsBinder(ctx) {
175
+ var forms = safeGet(ctx, 'configForms');
176
+ if (!forms || typeof forms.get !== 'function') return undefined;
177
+ if (binderCache && binderCache.has(forms)) return binderCache.get(forms);
178
+ var binder = {
179
+ bind: function(spec) {
180
+ var namespace = spec && spec.namespace;
181
+ if (typeof namespace !== 'string' || namespace.length === 0) {
182
+ throw new TypeError('settings namespace must be a non-empty string');
183
+ }
184
+ return forms.get(namespace);
185
+ }
186
+ };
187
+ if (binderCache) binderCache.set(forms, binder);
188
+ return binder;
189
+ }
190
+
191
+ function wrapContext(ctx) {
192
+ if (!ctx || typeof ctx !== 'object') return ctx;
193
+ if (contextCache && contextCache.has(ctx)) return contextCache.get(ctx);
194
+ var wrapped = new Proxy(ctx, {
195
+ get: function(target, property) {
196
+ if (property === 'settingsScope') {
197
+ var original = safeGet(target, 'settingsScope');
198
+ if (original && typeof original.bind === 'function') return original;
199
+ // DSH 0.1.7 configForms only mirrors Config fields declared volatile.
200
+ // DVR must keep its public Config ordinary for the older supported Host
201
+ // window, so its namespace intentionally uses the local-only ConfigEditor
202
+ // bridge while other namespaces may still delegate to native configForms.
203
+ var official = configFormsBinder(target);
204
+ return syntheticBinder(ctx, official);
205
+ }
206
+ if (property === 'get') {
207
+ var getter = Reflect.get(target, property, target);
208
+ if (typeof getter !== 'function') return getter;
209
+ return function(name) {
210
+ var value = getter.call(target, name);
211
+ return name === 'connection' ? normalizeConnection(value) : value;
212
+ };
213
+ }
214
+ var value = Reflect.get(target, property, target);
215
+ return typeof value === 'function' ? value.bind(target) : value;
216
+ }
217
+ });
218
+ if (contextCache) contextCache.set(ctx, wrapped);
219
+ return wrapped;
220
+ }
221
+
222
+ function requireConfigForms(plugin) {
223
+ if (!plugin || !Array.isArray(plugin.inject)) return plugin;
224
+ var inject = [];
225
+ var inserted = false;
226
+ for (var index = 0; index < plugin.inject.length; index += 1) {
227
+ var service = plugin.inject[index];
228
+ if (service === 'settingsScope') {
229
+ if (!inserted) {
230
+ inject.push('configForms');
231
+ inserted = true;
232
+ }
233
+ continue;
234
+ }
235
+ if (service === 'configForms') inserted = true;
236
+ if (inject.indexOf(service) === -1) inject.push(service);
237
+ }
238
+ // Another loader compatibility layer may already have removed the legacy
239
+ // dependency before this factory runs. DSH 0.1.7 still requires the native
240
+ // configForms service to be activation-ready before Vision Router applies.
241
+ if (!inserted) inject.unshift('configForms');
242
+ try {
243
+ plugin.inject = inject;
244
+ return plugin;
245
+ } catch (_) {
246
+ return Object.assign({}, plugin, { inject: inject });
247
+ }
248
+ }
249
+
250
+ function patchLoader(loader) {
251
+ if (!loader || typeof loader.load !== 'function' || loader.load.__visionRouterSettings017Compat) return;
252
+ var original = loader.load;
253
+ function load(spec) {
254
+ if (spec && spec.id === TARGET && typeof spec.factory === 'function') {
255
+ var factory = spec.factory;
256
+ spec = Object.assign({}, spec, {
257
+ factory: function(require) {
258
+ var plugin = requireConfigForms(factory(require));
259
+ if (plugin && typeof plugin.apply === 'function' && !plugin.apply.__visionRouterSettings017Compat) {
260
+ var apply = plugin.apply;
261
+ var wrappedApply = function(ctx) {
262
+ var rest = Array.prototype.slice.call(arguments, 1);
263
+ return apply.apply(plugin, [wrapContext(ctx)].concat(rest));
264
+ };
265
+ Object.defineProperty(wrappedApply, '__visionRouterSettings017Compat', { value: true });
266
+ plugin.apply = wrappedApply;
267
+ }
268
+ return plugin;
269
+ }
270
+ });
271
+ }
272
+ return original.call(this, spec);
273
+ }
274
+ Object.defineProperty(load, '__visionRouterSettings017Compat', { value: true });
275
+ loader.load = load;
276
+ }
277
+
278
+ function patchCreate(loader) {
279
+ if (!loader || typeof loader.create !== 'function' || loader.create.__visionRouterSettings017Compat) return;
280
+ var original = loader.create;
281
+ function create() {
282
+ var result = original.apply(this, arguments);
283
+ patchLoader(loader);
284
+ if (result && result !== loader) patchLoader(result);
285
+ return result;
286
+ }
287
+ Object.defineProperty(create, '__visionRouterSettings017Compat', { value: true });
288
+ loader.create = create;
289
+ if (loader.mode === 'live') patchLoader(loader);
290
+ }
291
+
292
+ function install() {
293
+ var descriptor = Object.getOwnPropertyDescriptor(window, '__ModuleLoader__');
294
+ var stored = window.__ModuleLoader__;
295
+ if (stored) patchCreate(stored);
296
+ if (descriptor && descriptor.configurable === false) return;
297
+ var previousGet = descriptor && descriptor.get;
298
+ var previousSet = descriptor && descriptor.set;
299
+ Object.defineProperty(window, '__ModuleLoader__', {
300
+ configurable: true,
301
+ enumerable: !descriptor || descriptor.enumerable !== false,
302
+ get: function(){ return previousGet ? previousGet.call(window) : stored; },
303
+ set: function(value) {
304
+ if (previousSet) previousSet.call(window, value); else stored = value;
305
+ try { patchCreate(previousGet ? previousGet.call(window) : value); } catch (_) {}
306
+ }
307
+ });
308
+ }
309
+
310
+ try { install(); } catch (_) {}
311
+ })();`
312
+
313
+ export function injectSettings017ClientPrelude(html) {
314
+ if (typeof html !== 'string' || htmlHasScriptMarker(html, SETTINGS_017_MARK)) return html
315
+ const safe = SETTINGS_017_CLIENT_PRELUDE.replace(/<\/script/gi, '<\\/script')
316
+ const script = `<script ${SETTINGS_017_MARK}>${safe}</script>`
317
+ const closeHead = html.indexOf('</head>')
318
+ return closeHead === -1 ? `${html}${script}` : `${html.slice(0, closeHead)}${script}${html.slice(closeHead)}`
319
+ }
320
+
321
+ export function installSettings017ClientCompatibility(ctx) {
322
+ if (!ctx || typeof ctx.inject !== 'function') return
323
+ // ConfigEditor is the generation fence: old supported Hosts keep their native
324
+ // settingsScope activation contract and never see this client shim.
325
+ ctx.inject(['configEditor', 'webServer'], (webCtx) => {
326
+ webCtx.effect(
327
+ () => webCtx.webServer.tapIndex(injectSettings017ClientPrelude),
328
+ 'vision-router: DSH 0.1.7 settings client compatibility',
329
+ )
330
+ })
331
+ }