dsh-vision-router 2.3.0 → 3.0.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 (103) hide show
  1. package/README.md +12 -3
  2. package/README.zh.md +12 -3
  3. package/docs/architecture/3.0-architecture-plan.md +252 -0
  4. package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
  5. package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
  6. package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
  7. package/docs/architecture/3.0-r11-adversarial-review.md +441 -0
  8. package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
  9. package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
  10. package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
  11. package/docs/architecture/3.0-r7-public-api.md +151 -0
  12. package/docs/architecture/3.0-r8-ci-release.md +248 -0
  13. package/docs/architecture/3.0-r9-final-audit.md +370 -0
  14. package/docs/architecture/dsh-support-window.md +5 -3
  15. package/docs/releases/v3.0.0.md +85 -0
  16. package/entry.js +1 -1
  17. package/index.js +108 -71
  18. package/lib/agent-request-route-authority.js +1 -20
  19. package/lib/core-apply-lifecycle.js +37 -43
  20. package/lib/core-cache.js +87 -0
  21. package/lib/core-primitives.js +3 -143
  22. package/lib/core-vision-surface-domain.js +63 -0
  23. package/lib/core-vision-surface.js +11 -77
  24. package/lib/delegated-call-config.js +15 -24
  25. package/lib/doctor-cli.js +0 -0
  26. package/lib/doctor-runtime.js +4 -1
  27. package/lib/dsh-contract-compat.js +26 -230
  28. package/lib/dsh-host-capabilities.js +3 -9
  29. package/lib/dsh-settings-017-compat.js +17 -10
  30. package/lib/file-logger.js +8 -3
  31. package/lib/grounding-coordinate-runtime.js +1 -2
  32. package/lib/http-body-limit.js +97 -79
  33. package/lib/image-offload-compat.js +30 -21
  34. package/lib/image-resource-governor.js +210 -228
  35. package/lib/legacy-global-proxy-boundary.js +46 -3
  36. package/lib/live-model-client-prelude.js +12 -4
  37. package/lib/live-model-discovery.js +134 -43
  38. package/lib/local-vision-stabilizer.js +31 -17
  39. package/lib/maintenance-routes.js +1 -3
  40. package/lib/native-image-coexistence.js +15 -3
  41. package/lib/ollama-cold-start.js +87 -23
  42. package/lib/pi-ai-bridge-wire-compat.js +3 -2
  43. package/lib/proxy-dispatcher-pool.js +151 -92
  44. package/lib/proxy-routing.js +75 -51
  45. package/lib/proxy-url-compat.js +3 -10
  46. package/lib/public-config.js +23 -44
  47. package/lib/public-entry.d.ts +148 -0
  48. package/lib/public-entry.js +17 -4
  49. package/lib/replay-delegation.js +12 -7
  50. package/lib/replay-envelope-v2-compat.js +12 -7
  51. package/lib/runtime-composition.js +22 -4
  52. package/lib/screenshot-source-boundary.js +2 -2
  53. package/lib/session-affinity-runtime.js +113 -97
  54. package/lib/session-affinity.js +89 -76
  55. package/lib/session-event-history-compat.js +30 -0
  56. package/lib/session-query-readers.js +263 -0
  57. package/lib/session-surface-compat.js +30 -31
  58. package/lib/session-surface-policy-domain.js +85 -0
  59. package/lib/session-surface-policy.js +2 -111
  60. package/lib/session-turn-resolver.js +2 -6
  61. package/lib/session-vision-attachment-index.js +143 -0
  62. package/lib/session-vision-delegation-boundary.js +34 -0
  63. package/lib/session-vision-delegation.js +22 -0
  64. package/lib/session-vision-index.js +25 -107
  65. package/lib/session-vision-mode-authority.js +229 -192
  66. package/lib/session-vision-mode-boundary.js +65 -22
  67. package/lib/session-vision-policy-domain.js +304 -0
  68. package/lib/session-vision-policy.js +104 -0
  69. package/lib/session-vision-state.js +368 -378
  70. package/lib/settings-client-017-compat.js +9 -7
  71. package/lib/structured-flow-hardening.js +22 -4
  72. package/lib/tesseract-exec-compat.js +9 -1
  73. package/lib/turn-budget-context.js +47 -41
  74. package/lib/update-check.js +11 -2
  75. package/lib/vision-attachment-handle-runtime.js +176 -156
  76. package/lib/vision-backend-runtime-policy.js +16 -0
  77. package/lib/vision-background-benchmark.js +41 -4
  78. package/lib/vision-background-failure-policy.js +41 -41
  79. package/lib/vision-background-stop-store-core.js +217 -0
  80. package/lib/vision-background-stop-store.js +8 -179
  81. package/lib/vision-capability-benchmark.js +11 -61
  82. package/lib/vision-capability-evidence.js +35 -0
  83. package/lib/vision-capability-fingerprint.js +72 -0
  84. package/lib/vision-capability-identity.js +116 -61
  85. package/lib/vision-capability-router.js +13 -33
  86. package/lib/vision-chunk-assembler.js +95 -0
  87. package/lib/vision-diagnostics-routes.js +34 -4
  88. package/lib/vision-execution-order-apply.js +45 -55
  89. package/lib/vision-execution-order.js +19 -19
  90. package/lib/vision-execution-policy.js +12 -7
  91. package/lib/vision-model-registry.js +30 -17
  92. package/lib/vision-provider-transport.js +137 -147
  93. package/lib/vision-routing-authority.js +47 -48
  94. package/lib/vision-routing-product.js +26 -29
  95. package/lib/vision-routing-settings-prelude.js +8 -3
  96. package/lib/vision-runtime-performance-store.js +166 -0
  97. package/lib/vision-runtime-performance.js +13 -142
  98. package/lib/vision-tool-runtime-boundary.js +99 -30
  99. package/lib/web/dsh-settings-017-root-transport.js +27 -12
  100. package/lib/web/remote-settings-client.js +4 -7
  101. package/lib/web-capability-boundary.js +17 -7
  102. package/lib/wrapper-directory.js +14 -23
  103. package/package.json +37 -11
@@ -552,6 +552,7 @@ export function createBackgroundCapabilityProfiler({
552
552
  let lastForegroundAt = Number(now())
553
553
  let runningController
554
554
  let runningWork
555
+ let tickTask
555
556
  let topologyDirty = false
556
557
  let lastAuthority = resolveVisionRoutingAuthority(activeSettings(ctx, config))
557
558
  const backoff = new Map()
@@ -689,7 +690,7 @@ export function createBackgroundCapabilityProfiler({
689
690
  }
690
691
  }
691
692
 
692
- const tick = async () => {
693
+ const runTick = async () => {
693
694
  if (stopped || runningController) return
694
695
  const currentNow = Number(now())
695
696
  const current = activeSettings(ctx, config)
@@ -864,6 +865,32 @@ export function createBackgroundCapabilityProfiler({
864
865
  }
865
866
  }
866
867
 
868
+ const tick = () => {
869
+ if (tickTask) return tickTask
870
+ let tracked
871
+ // `tracked` is assigned before any rejection can be observed: the timer
872
+ // callback and the lifecycle disposer both call tick(), and a background
873
+ // scan that throws must degrade to a logged failure instead of an
874
+ // unhandled process-level rejection.
875
+ tracked = Promise.resolve()
876
+ .then(runTick)
877
+ .catch((error) => {
878
+ try {
879
+ logger?.warn?.(
880
+ 'vision-router: background benchmark tick failed: %s',
881
+ bounded(error?.message ?? error),
882
+ )
883
+ } catch {
884
+ /* diagnostics only */
885
+ }
886
+ })
887
+ .finally(() => {
888
+ if (tickTask === tracked) tickTask = undefined
889
+ })
890
+ tickTask = tracked
891
+ return tracked
892
+ }
893
+
867
894
  const foregroundStart = () => {
868
895
  activeForeground += 1
869
896
  lastForegroundAt = Number(now())
@@ -893,6 +920,7 @@ export function createBackgroundCapabilityProfiler({
893
920
  if (timer !== undefined) clearTimer(timer)
894
921
  timer = undefined
895
922
  yieldBackground('background profiler stopped')
923
+ return tickTask ?? Promise.resolve()
896
924
  }
897
925
 
898
926
  schedule(idleMs)
@@ -1022,17 +1050,26 @@ export function installBackgroundCapabilityProfiling(ctx, config, core, store, o
1022
1050
  // Event-driven wakeups are an optimization; the short authority scan remains.
1023
1051
  }
1024
1052
 
1053
+ let lifecycleOwned = true
1025
1054
  try {
1026
- ctx?.effect?.(() => () => {
1055
+ ctx?.effect?.(() => async () => {
1027
1056
  for (const dispose of eventDisposers.splice(0)) {
1028
1057
  try { dispose() } catch {}
1029
1058
  }
1030
- profiler.stop()
1059
+ await profiler.stop()
1031
1060
  }, 'vision-router: background capability profiler')
1032
1061
  } catch {
1033
- // Cleanup registration is best-effort; the profiler remains process-scoped.
1062
+ lifecycleOwned = false
1063
+ for (const dispose of eventDisposers.splice(0)) {
1064
+ try { dispose() } catch {}
1065
+ }
1066
+ void profiler.stop().catch(() => {})
1034
1067
  }
1035
1068
 
1069
+ // An inactive/unowned generation must not continue admitting client scripts,
1070
+ // routes, or tool wrappers after its profiler ownership was rejected.
1071
+ if (!lifecycleOwned) return { ctx, profiler }
1072
+
1036
1073
  installVisionExactCheckClient(ctx)
1037
1074
 
1038
1075
  try {
@@ -1,47 +1,47 @@
1
- export const BACKGROUND_AUTH_STOP_TTL_MS = 10 * 60 * 1000
2
- export const BACKGROUND_ENDPOINT_STOP_TTL_MS = 6 * 60 * 60 * 1000
3
- export const BACKGROUND_TRANSIENT_RETRY_MS = 30 * 60 * 1000
4
-
1
+ export const BACKGROUND_AUTH_STOP_TTL_MS = 10 * 60 * 1000;
2
+ export const BACKGROUND_ENDPOINT_STOP_TTL_MS = 6 * 60 * 60 * 1000;
3
+ export const BACKGROUND_TRANSIENT_RETRY_MS = 30 * 60 * 1000;
5
4
  const DEFAULT_POLICY = Object.freeze({
6
- retryable: true,
7
- persist: false,
8
- retryAfterMs: undefined,
9
- ttlMs: undefined,
10
- })
11
-
12
- const POLICIES = Object.freeze({
13
- auth: Object.freeze({
14
- retryable: false,
15
- persist: false,
16
- ttlMs: BACKGROUND_AUTH_STOP_TTL_MS,
17
- }),
18
- protocol: Object.freeze({
19
- retryable: false,
20
- persist: true,
21
- ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
22
- }),
23
- unavailable: Object.freeze({
24
- retryable: false,
25
- persist: true,
26
- ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
27
- }),
28
- 'unsupported-image': Object.freeze({
29
- retryable: false,
30
- persist: false,
31
- }),
32
- 'visual-proof': Object.freeze({
33
5
  retryable: true,
34
6
  persist: false,
35
- retryAfterMs: BACKGROUND_TRANSIENT_RETRY_MS,
36
- }),
37
- infrastructure: Object.freeze({
38
- retryable: true,
39
- persist: false,
40
- retryAfterMs: BACKGROUND_TRANSIENT_RETRY_MS,
41
- }),
42
- })
43
-
7
+ retryAfterMs: undefined,
8
+ ttlMs: undefined,
9
+ });
10
+ const POLICIES = Object.freeze({
11
+ auth: Object.freeze({
12
+ retryable: false,
13
+ persist: false,
14
+ ttlMs: BACKGROUND_AUTH_STOP_TTL_MS,
15
+ }),
16
+ protocol: Object.freeze({
17
+ retryable: false,
18
+ persist: true,
19
+ ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
20
+ }),
21
+ unavailable: Object.freeze({
22
+ retryable: false,
23
+ persist: true,
24
+ ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
25
+ }),
26
+ 'unsupported-image': Object.freeze({
27
+ retryable: false,
28
+ persist: false,
29
+ }),
30
+ 'visual-proof': Object.freeze({
31
+ retryable: true,
32
+ persist: false,
33
+ retryAfterMs: BACKGROUND_TRANSIENT_RETRY_MS,
34
+ }),
35
+ infrastructure: Object.freeze({
36
+ retryable: true,
37
+ persist: false,
38
+ retryAfterMs: BACKGROUND_TRANSIENT_RETRY_MS,
39
+ }),
40
+ });
44
41
  /** Failure lifetime is separate from failure classification. */
45
42
  export function backgroundFailurePolicy(errorClass) {
46
- return POLICIES[String(errorClass ?? '')] ?? DEFAULT_POLICY
43
+ const key = String(errorClass ?? '');
44
+ return Object.prototype.hasOwnProperty.call(POLICIES, key)
45
+ ? POLICIES[key]
46
+ : DEFAULT_POLICY;
47
47
  }
@@ -0,0 +1,217 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import { BENCHMARK_AXES, } from './vision-capability-evidence.js';
6
+ import { CAPABILITY_BENCHMARK_SUITE_REVISION } from './vision-capability-fingerprint.js';
7
+ const CACHE_VERSION = 3;
8
+ const MAX_ENTRIES = 256;
9
+ function propertyBag(value) {
10
+ return value !== null && typeof value === 'object'
11
+ ? value
12
+ : undefined;
13
+ }
14
+ function cleanText(value, max = 256) {
15
+ return typeof value === 'string' && value.trim() !== ''
16
+ ? value.trim().slice(0, max)
17
+ : '';
18
+ }
19
+ function benchmarkAxis(value) {
20
+ return BENCHMARK_AXES.includes(value)
21
+ ? value
22
+ : undefined;
23
+ }
24
+ function persistentFailureClass(value) {
25
+ return value === 'unavailable' || value === 'protocol'
26
+ ? value
27
+ : undefined;
28
+ }
29
+ function cleanStop(value) {
30
+ const record = propertyBag(value);
31
+ if (record === undefined)
32
+ return undefined;
33
+ if (Number(record.suiteRevision) !== CAPABILITY_BENCHMARK_SUITE_REVISION) {
34
+ return undefined;
35
+ }
36
+ const fingerprint = cleanText(record.fingerprint, 64);
37
+ if (!/^ep2_[0-9a-f]{32}$/.test(fingerprint))
38
+ return undefined;
39
+ const key = cleanText(record.key);
40
+ const provider = cleanText(record.provider);
41
+ const model = cleanText(record.model);
42
+ const axis = benchmarkAxis(cleanText(record.axis, 32));
43
+ const errorClass = persistentFailureClass(cleanText(record.errorClass, 48));
44
+ const errorCode = cleanText(record.errorCode, 96);
45
+ const recordedAt = Number(record.recordedAt);
46
+ const expiresAt = Number(record.expiresAt);
47
+ if (!key || !provider || !model || axis === undefined || errorClass === undefined) {
48
+ return undefined;
49
+ }
50
+ if (!Number.isFinite(recordedAt) || recordedAt <= 0)
51
+ return undefined;
52
+ if (!Number.isFinite(expiresAt) || expiresAt <= recordedAt)
53
+ return undefined;
54
+ return {
55
+ fingerprint,
56
+ key,
57
+ provider,
58
+ model,
59
+ axis,
60
+ errorClass,
61
+ ...(errorCode ? { errorCode } : {}),
62
+ recordedAt,
63
+ expiresAt,
64
+ suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
65
+ };
66
+ }
67
+ function recordKey(value) {
68
+ return `${value.fingerprint}\u0000${value.axis}`;
69
+ }
70
+ function selectRetainedStops(values, at, maxEntries = MAX_ENTRIES) {
71
+ const seen = new Set();
72
+ return [...values]
73
+ .map(cleanStop)
74
+ .filter((item) => item !== undefined && item.expiresAt > at)
75
+ .sort((a, b) => b.recordedAt - a.recordedAt
76
+ || recordKey(a).localeCompare(recordKey(b)))
77
+ .filter((item) => {
78
+ const key = recordKey(item);
79
+ if (seen.has(key))
80
+ return false;
81
+ seen.add(key);
82
+ return true;
83
+ })
84
+ .slice(0, maxEntries);
85
+ }
86
+ function retainedStopMap(values, at, maxEntries = MAX_ENTRIES) {
87
+ return new Map(selectRetainedStops(values, at, maxEntries)
88
+ .map((item) => [recordKey(item), item]));
89
+ }
90
+ async function save(file, records, fsOps, at) {
91
+ const temporary = `${file}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
92
+ const stops = selectRetainedStops(records.values(), at);
93
+ await fsOps.mkdir(path.dirname(file), { recursive: true });
94
+ await fsOps.writeFile(temporary, JSON.stringify({ version: CACHE_VERSION, stops }), { encoding: 'utf8', mode: 0o600 });
95
+ await fsOps.rename(temporary, file);
96
+ }
97
+ async function load(file, fsOps, at) {
98
+ try {
99
+ const raw = await fsOps.readFile(file, 'utf8');
100
+ const body = propertyBag(JSON.parse(raw));
101
+ const stops = body?.stops;
102
+ const version = Number(body?.version);
103
+ if (!Array.isArray(stops)
104
+ || (version !== 2 && version !== CACHE_VERSION)) {
105
+ return new Map();
106
+ }
107
+ const records = retainedStopMap(stops, at);
108
+ if (version === 2)
109
+ await save(file, records, fsOps, at);
110
+ return records;
111
+ }
112
+ catch (error) {
113
+ if (propertyBag(error)?.code === 'ENOENT')
114
+ return new Map();
115
+ return new Map();
116
+ }
117
+ }
118
+ /**
119
+ * Durable stop owner for unattended capability measurement.
120
+ *
121
+ * Only endpoint/model evidence that is benchmark-axis-scoped and safe to
122
+ * persist (protocol or unavailable) can enter this store. AUTH remains a
123
+ * process-local access condition and is rejected by runtime validation even
124
+ * when legacy v2 cache data contains it.
125
+ */
126
+ export function createBackgroundBenchmarkStopStoreCore(options) {
127
+ const file = options.file;
128
+ const fsOps = {
129
+ readFile: options.fsOps?.readFile ?? readFile,
130
+ mkdir: options.fsOps?.mkdir ?? mkdir,
131
+ writeFile: options.fsOps?.writeFile ?? writeFile,
132
+ rename: options.fsOps?.rename ?? rename,
133
+ };
134
+ const logger = options.logger;
135
+ const now = typeof options.now === 'function' ? options.now : Date.now;
136
+ let records = new Map();
137
+ let saveTail = Promise.resolve();
138
+ const ready = load(file, fsOps, Number(now()))
139
+ .then((loaded) => {
140
+ records = loaded;
141
+ });
142
+ const persist = () => {
143
+ const at = Number(now());
144
+ saveTail = saveTail
145
+ .then(() => save(file, records, fsOps, at))
146
+ .catch((error) => {
147
+ logger?.warn?.('vision-router: background stop cache write failed: %s', cleanText(propertyBag(error)?.message ?? error, 240));
148
+ });
149
+ return saveTail;
150
+ };
151
+ const reconcile = () => {
152
+ const at = Number(now());
153
+ const retained = retainedStopMap(records.values(), at);
154
+ const changed = retained.size !== records.size
155
+ || [...retained.keys()].some((key) => !records.has(key));
156
+ records = retained;
157
+ return changed;
158
+ };
159
+ return {
160
+ file,
161
+ async list() {
162
+ await ready;
163
+ if (reconcile())
164
+ await persist();
165
+ return [...records.values()]
166
+ .sort((a, b) => b.recordedAt - a.recordedAt);
167
+ },
168
+ async mark({ fingerprint, key, provider, model, axis, errorClass, errorCode, recordedAt = Number(now()), expiresAt, } = {}) {
169
+ await ready;
170
+ reconcile();
171
+ const clean = cleanStop({
172
+ fingerprint,
173
+ key,
174
+ provider,
175
+ model,
176
+ axis,
177
+ errorClass,
178
+ errorCode,
179
+ recordedAt,
180
+ expiresAt,
181
+ suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
182
+ });
183
+ if (clean === undefined)
184
+ return undefined;
185
+ const keyOfStop = recordKey(clean);
186
+ records.set(keyOfStop, clean);
187
+ reconcile();
188
+ await persist();
189
+ return records.get(keyOfStop);
190
+ },
191
+ async clearStop(fingerprint, axis) {
192
+ await ready;
193
+ const removed = records.delete(`${String(fingerprint ?? '')}\u0000${String(axis ?? '')}`);
194
+ if (removed)
195
+ await persist();
196
+ return removed;
197
+ },
198
+ async clearFingerprint(fingerprint) {
199
+ await ready;
200
+ const wanted = String(fingerprint ?? '');
201
+ let removed = false;
202
+ for (const [key, record] of records.entries()) {
203
+ if (record.fingerprint !== wanted)
204
+ continue;
205
+ records.delete(key);
206
+ removed = true;
207
+ }
208
+ if (removed)
209
+ await persist();
210
+ return removed;
211
+ },
212
+ async flush() {
213
+ await ready;
214
+ await saveTail;
215
+ },
216
+ };
217
+ }
@@ -1,190 +1,19 @@
1
- import { randomUUID } from 'node:crypto'
2
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
3
1
  import path from 'node:path'
4
- import process from 'node:process'
5
2
  import { resolveDshHome } from './doctor.js'
6
- import { CAPABILITY_BENCHMARK_SUITE_REVISION } from './vision-capability-benchmark.js'
7
-
8
- // v1 stops had no expiry; v2 AUTH stops also persisted a secret-derived
9
- // credential fingerprint. Do not migrate either format: v3 persists only
10
- // non-secret endpoint/model stop evidence.
11
- const CACHE_VERSION = 3
12
- const MAX_ENTRIES = 256
13
- const AXES = new Set(['ocr', 'general', 'document', 'structured', 'grounding'])
14
- const CLASSES = new Set(['unavailable', 'protocol'])
15
-
16
- function cleanText(value, max = 256) {
17
- return typeof value === 'string' && value.trim() !== '' ? value.trim().slice(0, max) : ''
18
- }
19
-
20
- function cleanStop(value) {
21
- if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
22
- if (Number(value.suiteRevision) !== CAPABILITY_BENCHMARK_SUITE_REVISION) return undefined
23
- const fingerprint = cleanText(value.fingerprint, 64)
24
- if (!/^ep2_[0-9a-f]{32}$/.test(fingerprint)) return undefined
25
- const key = cleanText(value.key)
26
- const provider = cleanText(value.provider)
27
- const model = cleanText(value.model)
28
- const axis = cleanText(value.axis, 32)
29
- const errorClass = cleanText(value.errorClass, 48)
30
- const errorCode = cleanText(value.errorCode, 96)
31
- const recordedAt = Number(value.recordedAt)
32
- const expiresAt = Number(value.expiresAt)
33
- if (!key || !provider || !model || !AXES.has(axis) || !CLASSES.has(errorClass)) return undefined
34
- if (!Number.isFinite(recordedAt) || recordedAt <= 0) return undefined
35
- if (!Number.isFinite(expiresAt) || expiresAt <= recordedAt) return undefined
36
- return {
37
- fingerprint,
38
- key,
39
- provider,
40
- model,
41
- axis,
42
- errorClass,
43
- ...(errorCode ? { errorCode } : {}),
44
- recordedAt,
45
- expiresAt,
46
- suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
47
- }
48
- }
49
-
50
- function recordKey(value) {
51
- return `${value.fingerprint}\u0000${value.axis}`
52
- }
53
-
54
- function selectRetainedStops(values, at, maxEntries = MAX_ENTRIES) {
55
- const seen = new Set()
56
- return [...values]
57
- .map(cleanStop)
58
- .filter((item) => item && item.expiresAt > at)
59
- .sort((a, b) => b.recordedAt - a.recordedAt || recordKey(a).localeCompare(recordKey(b)))
60
- .filter((item) => {
61
- const key = recordKey(item)
62
- if (seen.has(key)) return false
63
- seen.add(key)
64
- return true
65
- })
66
- .slice(0, maxEntries)
67
- }
68
-
69
- function retainedStopMap(values, at, maxEntries = MAX_ENTRIES) {
70
- return new Map(selectRetainedStops(values, at, maxEntries).map((item) => [recordKey(item), item]))
71
- }
3
+ import { createBackgroundBenchmarkStopStoreCore } from './vision-background-stop-store-core.js'
72
4
 
73
5
  export function backgroundBenchmarkStopCachePath(dshHome = resolveDshHome()) {
74
6
  return path.join(dshHome, 'cache', 'vision-router', 'background-benchmark-stops.json')
75
7
  }
76
8
 
77
- async function load(file, fsOps, at) {
78
- try {
79
- const raw = await fsOps.readFile(file, 'utf8')
80
- const body = JSON.parse(raw)
81
- if (!body || !Array.isArray(body.stops) || ![2, CACHE_VERSION].includes(body.version)) return new Map()
82
- const records = retainedStopMap(body.stops, at)
83
- if (body.version === 2) await save(file, records, fsOps, at)
84
- return records
85
- } catch (error) {
86
- if (error?.code === 'ENOENT') return new Map()
87
- return new Map()
88
- }
89
- }
90
-
91
- async function save(file, records, fsOps, at) {
92
- const temporary = `${file}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`
93
- const stops = selectRetainedStops(records.values(), at)
94
- await fsOps.mkdir(path.dirname(file), { recursive: true })
95
- await fsOps.writeFile(temporary, JSON.stringify({ version: CACHE_VERSION, stops }), { encoding: 'utf8', mode: 0o600 })
96
- await fsOps.rename(temporary, file)
97
- }
98
-
9
+ /**
10
+ * Keep DSH-home discovery at the dynamic Host/path boundary. The typed core
11
+ * owns durable stop validation, migration, retention and persistence.
12
+ */
99
13
  export function createBackgroundBenchmarkStopStore(options = {}) {
100
14
  const file = options.cacheFile ?? backgroundBenchmarkStopCachePath(options.dshHome)
101
- const fsOps = {
102
- readFile: options.fsOps?.readFile ?? readFile,
103
- mkdir: options.fsOps?.mkdir ?? mkdir,
104
- writeFile: options.fsOps?.writeFile ?? writeFile,
105
- rename: options.fsOps?.rename ?? rename,
106
- }
107
- const logger = options.logger
108
- const now = typeof options.now === 'function' ? options.now : Date.now
109
- let records = new Map()
110
- let saveTail = Promise.resolve()
111
- const ready = load(file, fsOps, Number(now())).then((loaded) => { records = loaded })
112
-
113
- const persist = () => {
114
- const at = Number(now())
115
- saveTail = saveTail
116
- .then(() => save(file, records, fsOps, at))
117
- .catch((error) => logger?.warn?.('vision-router: background stop cache write failed: %s', cleanText(error?.message ?? error, 240)))
118
- return saveTail
119
- }
120
-
121
- const reconcile = () => {
122
- const at = Number(now())
123
- const retained = retainedStopMap(records.values(), at)
124
- const changed = retained.size !== records.size || [...retained.keys()].some((key) => !records.has(key))
125
- records = retained
126
- return changed
127
- }
128
-
129
- return {
15
+ return createBackgroundBenchmarkStopStoreCore({
16
+ ...options,
130
17
  file,
131
- async list() {
132
- await ready
133
- if (reconcile()) await persist()
134
- return [...records.values()].sort((a, b) => b.recordedAt - a.recordedAt)
135
- },
136
- async mark({
137
- fingerprint,
138
- key,
139
- provider,
140
- model,
141
- axis,
142
- errorClass,
143
- errorCode,
144
- recordedAt = Number(now()),
145
- expiresAt,
146
- } = {}) {
147
- await ready
148
- reconcile()
149
- const clean = cleanStop({
150
- fingerprint,
151
- key,
152
- provider,
153
- model,
154
- axis,
155
- errorClass,
156
- errorCode,
157
- recordedAt,
158
- expiresAt,
159
- suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
160
- })
161
- if (!clean) return undefined
162
- records.set(recordKey(clean), clean)
163
- reconcile()
164
- await persist()
165
- return records.get(recordKey(clean))
166
- },
167
- async clearStop(fingerprint, axis) {
168
- await ready
169
- const removed = records.delete(`${String(fingerprint ?? '')}\u0000${String(axis ?? '')}`)
170
- if (removed) await persist()
171
- return removed
172
- },
173
- async clearFingerprint(fingerprint) {
174
- await ready
175
- const wanted = String(fingerprint ?? '')
176
- let removed = false
177
- for (const [key, record] of records.entries()) {
178
- if (record.fingerprint !== wanted) continue
179
- records.delete(key)
180
- removed = true
181
- }
182
- if (removed) await persist()
183
- return removed
184
- },
185
- async flush() {
186
- await ready
187
- await saveTail
188
- },
189
- }
18
+ })
190
19
  }
@@ -1,15 +1,19 @@
1
- import { createHash } from 'node:crypto'
2
- import process from 'node:process'
3
1
  import { VISION_INTENTS } from './vision-capability-router.js'
2
+ import {
3
+ CAPABILITY_BENCHMARK_RENDERER_SCOPE,
4
+ CAPABILITY_BENCHMARK_SUITE_REVISION,
5
+ capabilityBenchmarkFingerprint,
6
+ } from './vision-capability-fingerprint.js'
7
+
8
+ export {
9
+ CAPABILITY_BENCHMARK_RENDERER_SCOPE,
10
+ CAPABILITY_BENCHMARK_SUITE_REVISION,
11
+ capabilityBenchmarkFingerprint,
12
+ } from './vision-capability-fingerprint.js'
4
13
 
5
14
  const WIDTH = 768
6
15
  const HEIGHT = 512
7
16
 
8
- export const CAPABILITY_BENCHMARK_SUITE_REVISION = 5
9
- // Renderer-only fixes must invalidate prior evidence even when the scoring and
10
- // prompt contract (the suite revision) is unchanged.
11
- export const CAPABILITY_BENCHMARK_RENDERER_SCOPE = `${process.platform}/${process.arch}-proof-badge-v2`
12
-
13
17
  function esc(value) {
14
18
  return String(value).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[ch])
15
19
  }
@@ -160,60 +164,6 @@ export function listCapabilityBenchmarkFixtures(intents = CORE_BENCHMARK_INTENTS
160
164
  return fixtures
161
165
  }
162
166
 
163
- function isSensitiveKey(key) {
164
- return /(^|[_-])(api[_-]?key|key|token|secret|password|authorization|auth|signature|sig)([_-]|$)/i.test(String(key ?? ''))
165
- }
166
-
167
- function normalizeEndpoint(value) {
168
- const raw = String(value ?? '').trim()
169
- if (raw === '') return ''
170
- try {
171
- const url = new URL(raw)
172
- url.username = ''
173
- url.password = ''
174
- url.hash = ''
175
- for (const key of [...url.searchParams.keys()]) {
176
- if (isSensitiveKey(key)) url.searchParams.delete(key)
177
- }
178
- url.searchParams.sort()
179
- const pathname = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, '')
180
- return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${pathname}${url.search}`
181
- } catch {
182
- return raw.replace(/\/+$/, '')
183
- }
184
- }
185
-
186
- function sanitizeFingerprintValue(value, keyHint = '') {
187
- if (value === null || value === undefined) return value ?? null
188
- if (Array.isArray(value)) return value.map((item) => sanitizeFingerprintValue(item))
189
- if (typeof value === 'object') {
190
- return Object.fromEntries(
191
- Object.entries(value)
192
- .filter(([key]) => !isSensitiveKey(key))
193
- .sort(([a], [b]) => a.localeCompare(b))
194
- .map(([key, child]) => [key, sanitizeFingerprintValue(child, key)]),
195
- )
196
- }
197
- if (typeof value === 'string' && /(endpoint|base[_-]?url|url)$/i.test(keyHint)) return normalizeEndpoint(value)
198
- if (typeof value === 'bigint') return value.toString()
199
- if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value
200
- return String(value)
201
- }
202
-
203
- export function capabilityBenchmarkFingerprint({ provider, model, endpoint, config, credentialFingerprint } = {}) {
204
- const payload = JSON.stringify({
205
- schema: 'vision-capability-endpoint-v2',
206
- suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
207
- rendererScope: CAPABILITY_BENCHMARK_RENDERER_SCOPE,
208
- provider: String(provider ?? '').trim(),
209
- model: String(model ?? '').trim(),
210
- endpoint: normalizeEndpoint(endpoint),
211
- config: sanitizeFingerprintValue(config && typeof config === 'object' ? config : null),
212
- credentialFingerprint: String(credentialFingerprint ?? 'none'),
213
- })
214
- return `ep2_${createHash('sha256').update(payload).digest('hex').slice(0, 32)}`
215
- }
216
-
217
167
  function clamp01(value) {
218
168
  return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0))
219
169
  }