dsh-vision-router 2.1.6 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -14
- package/README.zh.md +8 -14
- package/cordis.patch.yml +14 -0
- package/docs/architecture/compat-inventory.md +1 -1
- package/docs/architecture/dsh-compatibility-matrix.md +4 -1
- package/docs/architecture/dsh-support-window.md +1 -2
- package/docs/releases/v2.1.7.md +13 -0
- package/docs/releases/v2.2.0.md +25 -0
- package/docs/remote-settings.md +2 -0
- package/entry.js +2 -0
- package/index.js +387 -77
- package/lib/artifact-boundary.js +11 -0
- package/lib/artifact-io.js +60 -7
- package/lib/catalog-corrections.js +5 -0
- package/lib/client.js +293 -48
- package/lib/core-primitives.js +122 -7
- package/lib/degraded-local-evidence.js +39 -0
- package/lib/dsh-contract-compat.js +202 -0
- package/lib/dsh-support-window.js +1 -1
- package/lib/image-offload-compat.js +38 -0
- package/lib/live-model-discovery.js +125 -15
- package/lib/local-vision-stabilizer.js +15 -6
- package/lib/official-deepseek-catalog.js +120 -0
- package/lib/ollama-cold-start.js +3 -15
- package/lib/remote-settings-bridge.js +28 -5
- package/lib/replay-delegation.js +68 -42
- package/lib/runtime-composition.js +15 -2
- package/lib/runtime-config-normalizer.js +271 -1
- package/lib/runtime-i18n-boundary.js +4 -13
- package/lib/runtime-i18n.js +2 -2
- package/lib/session-surface-compat.js +40 -0
- package/lib/session-turn-resolver.js +57 -0
- package/lib/session-vision-index.js +206 -93
- package/lib/session-vision-mode-boundary.js +83 -6
- package/lib/session-vision-runtime.js +8 -2
- package/lib/session-vision-state.js +0 -13
- package/lib/tesseract-exec-compat.js +16 -2
- package/lib/twin-image-capability-fallback.js +2 -11
- package/lib/vision-artifact-store.js +16 -3
- package/lib/vision-attachment-handle-runtime.js +9 -21
- package/lib/vision-backend-runtime-policy.js +7 -6
- package/lib/vision-background-stop-store.js +30 -20
- package/lib/vision-breaker-shadow-health.js +15 -5
- package/lib/vision-capability-probe.js +24 -14
- package/lib/vision-evidence-guidance.js +39 -0
- package/lib/vision-image-input-verdict.js +22 -7
- package/lib/vision-resilience.js +73 -1
- package/lib/web-capability-boundary.js +85 -22
- package/package.json +14 -7
package/lib/core-primitives.js
CHANGED
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
} from './http-body-limit.js'
|
|
73
73
|
import { writeArtifactFile } from './artifact-boundary.js'
|
|
74
74
|
import { stripTrailingSlashes } from './string-normalization.js'
|
|
75
|
+
import { isOffloadedImageBlock, offloadedImagePlaceholder } from './image-offload-compat.js'
|
|
75
76
|
import { proxyHostMatchesAny } from './proxy-routing.js'
|
|
76
77
|
import { parseVersionComparator } from './version-range.js'
|
|
77
78
|
import { captureWindowsDesktop } from './windows-desktop-capture.js'
|
|
@@ -1454,6 +1455,108 @@ export async function ocrWithTesseract(bytes, timeoutMs = 60000) {
|
|
|
1454
1455
|
return String(stdout ?? '')
|
|
1455
1456
|
}
|
|
1456
1457
|
|
|
1458
|
+
const TESSERACT_OCR_PSMS = Object.freeze([6, 3, 11])
|
|
1459
|
+
const TESSERACT_REVIEW_THRESHOLD = 70
|
|
1460
|
+
const TESSERACT_CANDIDATE_MARGIN = 5
|
|
1461
|
+
const TESSERACT_CONFUSABLE_RUN = /[0O1IlL]{3,}/
|
|
1462
|
+
|
|
1463
|
+
function median(values) {
|
|
1464
|
+
if (!Array.isArray(values) || values.length === 0) return 0
|
|
1465
|
+
const ordered = [...values].sort((a, b) => a - b)
|
|
1466
|
+
const middle = Math.floor(ordered.length / 2)
|
|
1467
|
+
return ordered.length % 2 === 0
|
|
1468
|
+
? (ordered[middle - 1] + ordered[middle]) / 2
|
|
1469
|
+
: ordered[middle]
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
export function parseTesseractTsv(tsv) {
|
|
1473
|
+
const words = []
|
|
1474
|
+
for (const line of String(tsv ?? '').split(/\r?\n/).slice(1)) {
|
|
1475
|
+
const columns = line.split('\t')
|
|
1476
|
+
if (columns.length < 12) continue
|
|
1477
|
+
const confidence = Number(columns[10])
|
|
1478
|
+
const text = columns.slice(11).join('\t').trim()
|
|
1479
|
+
if (text === '' || !Number.isFinite(confidence) || confidence < 0) continue
|
|
1480
|
+
words.push({ text, confidence })
|
|
1481
|
+
}
|
|
1482
|
+
return words
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
export function assessTesseractOcr(text, tsv, psm = 6) {
|
|
1486
|
+
const words = parseTesseractTsv(tsv)
|
|
1487
|
+
const confidences = words.map((word) => word.confidence)
|
|
1488
|
+
const precisionWords = words.filter((word) => /[0-9$%#:/._-]/.test(word.text))
|
|
1489
|
+
const precisionConfidences = precisionWords.map((word) => word.confidence)
|
|
1490
|
+
const riskyTokens = words
|
|
1491
|
+
.map((word) => word.text)
|
|
1492
|
+
.filter((token) => TESSERACT_CONFUSABLE_RUN.test(token))
|
|
1493
|
+
const quality = precisionConfidences.length > 0
|
|
1494
|
+
? Math.min(...precisionConfidences)
|
|
1495
|
+
: median(confidences)
|
|
1496
|
+
return {
|
|
1497
|
+
psm,
|
|
1498
|
+
text: String(text ?? '').trim(),
|
|
1499
|
+
words: words.length,
|
|
1500
|
+
quality: Number.isFinite(quality) ? quality : 0,
|
|
1501
|
+
riskyTokens,
|
|
1502
|
+
uncertain: words.length === 0 || quality < TESSERACT_REVIEW_THRESHOLD || riskyTokens.length > 0,
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
export function selectTesseractOcrCandidate(candidates) {
|
|
1507
|
+
const usable = (Array.isArray(candidates) ? candidates : []).filter((candidate) => candidate && candidate.text)
|
|
1508
|
+
if (usable.length === 0) return undefined
|
|
1509
|
+
let selected = usable[0]
|
|
1510
|
+
for (const candidate of usable.slice(1)) {
|
|
1511
|
+
if (candidate.quality >= selected.quality + TESSERACT_CANDIDATE_MARGIN) selected = candidate
|
|
1512
|
+
}
|
|
1513
|
+
return selected
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
export async function ocrWithTesseractAdaptive(bytes, timeoutMs = 12000, options = {}) {
|
|
1517
|
+
const exec = typeof options.exec === 'function' ? options.exec : promisify(execFile)
|
|
1518
|
+
const requestedBudget = Number(timeoutMs)
|
|
1519
|
+
const budgetMs = Number.isFinite(requestedBudget) && requestedBudget > 0
|
|
1520
|
+
? Math.min(60000, requestedBudget)
|
|
1521
|
+
: 12000
|
|
1522
|
+
const started = Date.now()
|
|
1523
|
+
const attemptedPsms = []
|
|
1524
|
+
const remaining = () => Math.max(1, budgetMs - (Date.now() - started))
|
|
1525
|
+
const run = async (psm) => {
|
|
1526
|
+
attemptedPsms.push(psm)
|
|
1527
|
+
const common = ['stdin', 'stdout', '-l', 'chi_sim+eng', '--psm', String(psm)]
|
|
1528
|
+
const execOptions = () => ({
|
|
1529
|
+
timeout: remaining(),
|
|
1530
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
1531
|
+
input: bytes,
|
|
1532
|
+
})
|
|
1533
|
+
const { stdout: plain } = await exec('tesseract', common, execOptions())
|
|
1534
|
+
const text = String(plain ?? '').trim()
|
|
1535
|
+
if (text === '') return assessTesseractOcr('', '', psm)
|
|
1536
|
+
try {
|
|
1537
|
+
const { stdout: tsv } = await exec('tesseract', [...common, 'tsv'], execOptions())
|
|
1538
|
+
return assessTesseractOcr(text, String(tsv ?? ''), psm)
|
|
1539
|
+
} catch {
|
|
1540
|
+
return { psm, text, words: 0, quality: 0, riskyTokens: [], uncertain: true }
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const candidates = [await run(TESSERACT_OCR_PSMS[0])]
|
|
1545
|
+
if (candidates[0].text !== '' && !candidates[0].uncertain) {
|
|
1546
|
+
return { ...candidates[0], attemptedPsms }
|
|
1547
|
+
}
|
|
1548
|
+
for (const psm of TESSERACT_OCR_PSMS.slice(1)) {
|
|
1549
|
+
if (Date.now() - started >= budgetMs) break
|
|
1550
|
+
try {
|
|
1551
|
+
candidates.push(await run(psm))
|
|
1552
|
+
} catch {
|
|
1553
|
+
// Keep the first successful local OCR result when an optional review pass fails.
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
const selected = selectTesseractOcrCandidate(candidates) ?? candidates[0]
|
|
1557
|
+
return { ...selected, attemptedPsms }
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1457
1560
|
/** Rough token estimate for one message (no tokenizer; conservative on purpose). */
|
|
1458
1561
|
export function estimateTokens(message) {
|
|
1459
1562
|
let chars = 0
|
|
@@ -1467,7 +1570,10 @@ export function estimateTokens(message) {
|
|
|
1467
1570
|
if (typeof block.text === 'string') chars += block.text.length
|
|
1468
1571
|
if (typeof block.arguments === 'string') chars += block.arguments.length
|
|
1469
1572
|
if (typeof block.name === 'string') chars += block.name.length
|
|
1470
|
-
if (block.type === 'image')
|
|
1573
|
+
if (block.type === 'image') {
|
|
1574
|
+
if (isOffloadedImageBlock(block)) chars += offloadedImagePlaceholder(block).length
|
|
1575
|
+
else images += 1
|
|
1576
|
+
}
|
|
1471
1577
|
if (Array.isArray(block.content)) block.content.forEach(walk)
|
|
1472
1578
|
}
|
|
1473
1579
|
if (message === null || message === undefined) return 0
|
|
@@ -1949,6 +2055,9 @@ export function dedupeHttpProviders(pairs, httpProviders) {
|
|
|
1949
2055
|
/** Convert harness image/text blocks plus resolved image bytes into OpenAI wire content. */
|
|
1950
2056
|
export function toOpenAIContent(blocks, bytesOf) {
|
|
1951
2057
|
return blocks.map((block) => {
|
|
2058
|
+
if (isOffloadedImageBlock(block)) {
|
|
2059
|
+
return { type: 'text', text: offloadedImagePlaceholder(block) }
|
|
2060
|
+
}
|
|
1952
2061
|
if (block && block.type === 'image' && block.attachment) {
|
|
1953
2062
|
const bytes = bytesOf(block.attachment)
|
|
1954
2063
|
const data = Buffer.from(bytes).toString('base64')
|
|
@@ -2162,9 +2271,10 @@ export function launchEnvironmentLike(env) {
|
|
|
2162
2271
|
}
|
|
2163
2272
|
|
|
2164
2273
|
/**
|
|
2165
|
-
*
|
|
2166
|
-
*
|
|
2167
|
-
*
|
|
2274
|
+
* Legacy-only DeepSeek reconstruction for Host contracts that do not own the
|
|
2275
|
+
* official provider lifecycle. Modern Hosts keep `deepseek-official` Host-owned
|
|
2276
|
+
* because request-local attachment/file/image-access seams cannot be cloned
|
|
2277
|
+
* safely here. Remove this helper when the legacy takeover window is retired.
|
|
2168
2278
|
*/
|
|
2169
2279
|
export function createNativeDeepSeekAdapter(ctx) {
|
|
2170
2280
|
const env = launchEnvironmentLike(
|
|
@@ -2262,7 +2372,7 @@ export async function buildInstantLocalMap(ctx, messages, provider, options = {}
|
|
|
2262
2372
|
for (const message of messages) {
|
|
2263
2373
|
if (!message || !Array.isArray(message.content)) continue
|
|
2264
2374
|
for (const block of message.content) {
|
|
2265
|
-
if (!block || block.type !== 'image' || !block.attachment) continue
|
|
2375
|
+
if (!block || block.type !== 'image' || !block.attachment || isOffloadedImageBlock(block)) continue
|
|
2266
2376
|
const attachment = block.attachment
|
|
2267
2377
|
const id = attachment.attachmentId || attachment.id || ''
|
|
2268
2378
|
if (id === '' || seen.has(id)) continue
|
|
@@ -2502,11 +2612,16 @@ export function createWrapperStreamBody(ctx, { imageMemory, delegateProvider, pr
|
|
|
2502
2612
|
})
|
|
2503
2613
|
return result.changed ? { ...message, content: result.content } : message
|
|
2504
2614
|
})
|
|
2615
|
+
// Provider authority can be live (for example the main DeepSeek wrapper
|
|
2616
|
+
// switches to its hidden native route only after a delayed takeover).
|
|
2617
|
+
// Resolve it once per model call so metadata, reasoning memory and the
|
|
2618
|
+
// actual network dispatch all use one coherent delegate identity.
|
|
2619
|
+
const currentDelegateProvider = liveValue(delegateProvider)
|
|
2505
2620
|
// Remember per delegate+model rather than per delegate alone: the
|
|
2506
2621
|
// stream boundary carries no session id, so provider+model is the
|
|
2507
2622
|
// narrowest scope available and keeps two concurrent sessions on the
|
|
2508
2623
|
// same twin from sharing one memory slot.
|
|
2509
|
-
const effortKey = `${
|
|
2624
|
+
const effortKey = `${currentDelegateProvider}\u0000${options.model ?? ''}`
|
|
2510
2625
|
let effort = typeof options.reasoningEffort === 'string' && options.reasoningEffort !== ''
|
|
2511
2626
|
? options.reasoningEffort
|
|
2512
2627
|
: undefined
|
|
@@ -2517,7 +2632,7 @@ export function createWrapperStreamBody(ctx, { imageMemory, delegateProvider, pr
|
|
|
2517
2632
|
}
|
|
2518
2633
|
yield* ctx.llm.stream({
|
|
2519
2634
|
...(effort === undefined ? options : { ...options, reasoningEffort: effort }),
|
|
2520
|
-
provider:
|
|
2635
|
+
provider: currentDelegateProvider,
|
|
2521
2636
|
messages: rewritten,
|
|
2522
2637
|
})
|
|
2523
2638
|
},
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Bounded policy for stopping Host tools from re-parsing images after vision degradation. */
|
|
2
|
+
|
|
3
|
+
function flattenStrings(value, out = [], depth = 0) {
|
|
4
|
+
if (out.length >= 64 || depth > 6 || value == null) return out
|
|
5
|
+
if (typeof value === 'string') { out.push(value); return out }
|
|
6
|
+
if (Array.isArray(value)) {
|
|
7
|
+
for (const item of value) flattenStrings(item, out, depth + 1)
|
|
8
|
+
return out
|
|
9
|
+
}
|
|
10
|
+
if (typeof value === 'object') {
|
|
11
|
+
for (const item of Object.values(value)) flattenStrings(item, out, depth + 1)
|
|
12
|
+
}
|
|
13
|
+
return out
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizedEvidenceTokens(tokens) {
|
|
17
|
+
const out = []
|
|
18
|
+
for (const token of tokens) {
|
|
19
|
+
if (typeof token !== 'string' || token === '') continue
|
|
20
|
+
out.push(token)
|
|
21
|
+
if (token.startsWith('sha256:')) {
|
|
22
|
+
const digest = token.slice('sha256:'.length)
|
|
23
|
+
out.push(digest)
|
|
24
|
+
if (digest.length >= 16) out.push(digest.slice(0, 16))
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function shouldBlockDegradedHostTool(name, args, evidenceTokens = []) {
|
|
31
|
+
if (name !== 'bash' && name !== 'read_image') return false
|
|
32
|
+
const tokens = normalizedEvidenceTokens(evidenceTokens)
|
|
33
|
+
if (tokens.length === 0) return false
|
|
34
|
+
const strings = flattenStrings(args)
|
|
35
|
+
return strings.some((text) => {
|
|
36
|
+
if (text.includes('.dsh/attachments') || text.includes('/attachments/v1/objects/')) return true
|
|
37
|
+
return tokens.some(token => text.includes(token))
|
|
38
|
+
})
|
|
39
|
+
}
|
|
@@ -26,6 +26,197 @@ const VISION_ROUTER_NORMALIZED_BYTES = LEGACY_VISION_ATTACHMENT_BYTES
|
|
|
26
26
|
const HOST_CAPABILITIES_PATH = '/_dsh/vision-router/host-capabilities'
|
|
27
27
|
const capabilityRouteInstalls = new WeakSet()
|
|
28
28
|
|
|
29
|
+
function sessionQueryOf(ctx) {
|
|
30
|
+
if (!ctx || typeof ctx !== 'object') return undefined
|
|
31
|
+
try {
|
|
32
|
+
if (typeof ctx.get === 'function') {
|
|
33
|
+
const query = ctx.get('sessionQuery')
|
|
34
|
+
if (query && typeof query === 'object') return query
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// A partial/legacy Host may not expose SessionQuery through ctx.get().
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const query = ctx.sessionQuery
|
|
41
|
+
return query && typeof query === 'object' ? query : undefined
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the narrow bounded Session event reader used by durable surface repair.
|
|
49
|
+
* DSH rc.8+ exposes `sessionQuery.readEvent({ sessionId, seq })`; resolve the
|
|
50
|
+
* service at call time so Host service replacement/hot reload stays visible.
|
|
51
|
+
* Missing capability is explicit and lets isolated/legacy callers use their
|
|
52
|
+
* existing Session-local compatibility fallback. Once the Host advertises the
|
|
53
|
+
* reader, operational failures propagate instead of silently falling back to
|
|
54
|
+
* deprecated synchronous Session history.
|
|
55
|
+
*/
|
|
56
|
+
export function createSessionEventReader(ctx) {
|
|
57
|
+
return async function readSessionEvent(session, seq) {
|
|
58
|
+
const sessionId = session?.id
|
|
59
|
+
if (sessionId === undefined || sessionId === null) return { supported: false }
|
|
60
|
+
if (!Number.isSafeInteger(seq) || seq < 0 || Object.is(seq, -0)) {
|
|
61
|
+
throw new TypeError(`session event seq must be a non-negative safe integer, got ${String(seq)}`)
|
|
62
|
+
}
|
|
63
|
+
const query = sessionQueryOf(ctx)
|
|
64
|
+
if (!query || typeof query.readEvent !== 'function') return { supported: false }
|
|
65
|
+
const window = await query.readEvent({ sessionId, seq })
|
|
66
|
+
const event = window?.target
|
|
67
|
+
if (!event || typeof event !== 'object') {
|
|
68
|
+
throw new Error(`sessionQuery.readEvent returned no target for seq ${seq}`)
|
|
69
|
+
}
|
|
70
|
+
if (event.seq !== undefined && event.seq !== seq) {
|
|
71
|
+
throw new Error(`sessionQuery.readEvent returned seq ${String(event.seq)} for requested seq ${seq}`)
|
|
72
|
+
}
|
|
73
|
+
return { supported: true, event }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
const SESSION_EVENT_TAIL_INITIAL_AFTER = 50
|
|
79
|
+
const SESSION_EVENT_TAIL_MAX_EVENTS = 4096
|
|
80
|
+
const SESSION_EVENT_TAIL_MAX_READS = 128
|
|
81
|
+
|
|
82
|
+
function sessionQueryCode(error) {
|
|
83
|
+
return typeof error?.code === 'string' ? error.code : undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read raw Session events strictly after one known committed seq through the
|
|
88
|
+
* Host-owned bounded `sessionQuery.readEvent()` window contract.
|
|
89
|
+
*
|
|
90
|
+
* The reader adapts to deployments that lower SessionQuery's configurable
|
|
91
|
+
* `readWindowMax`, never materializes the full Session log, and caps both
|
|
92
|
+
* events and Host reads. Missing capability is explicit. Operational failures
|
|
93
|
+
* from an advertised capability propagate to the caller; a cap is reported as
|
|
94
|
+
* `truncated` so routing can fail safe without a synchronous-history fallback.
|
|
95
|
+
*/
|
|
96
|
+
export function createSessionEventTailReader(ctx) {
|
|
97
|
+
let afterHint = SESSION_EVENT_TAIL_INITIAL_AFTER
|
|
98
|
+
|
|
99
|
+
const readWindow = async (query, sessionId, seq) => {
|
|
100
|
+
let after = afterHint
|
|
101
|
+
while (true) {
|
|
102
|
+
try {
|
|
103
|
+
const request = after > 0 ? { sessionId, seq, after } : { sessionId, seq }
|
|
104
|
+
const window = await query.readEvent(request)
|
|
105
|
+
afterHint = after
|
|
106
|
+
return { window, after }
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (sessionQueryCode(error) !== 'SESSION_QUERY_INVALID_WINDOW' || after <= 0) throw error
|
|
109
|
+
after = after <= 1 ? 0 : Math.floor(after / 2)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return async function readSessionEventTail(session, anchorSeq, options = {}) {
|
|
115
|
+
const sessionId = session?.id
|
|
116
|
+
if (sessionId === undefined || sessionId === null) return { supported: false }
|
|
117
|
+
if (!Number.isSafeInteger(anchorSeq) || anchorSeq < 0 || Object.is(anchorSeq, -0)) {
|
|
118
|
+
throw new TypeError(`session tail anchor seq must be a non-negative safe integer, got ${String(anchorSeq)}`)
|
|
119
|
+
}
|
|
120
|
+
const query = sessionQueryOf(ctx)
|
|
121
|
+
if (!query || typeof query.readEvent !== 'function') return { supported: false }
|
|
122
|
+
|
|
123
|
+
const collect = options?.collect !== false
|
|
124
|
+
const events = []
|
|
125
|
+
let capturedThroughSeq = anchorSeq
|
|
126
|
+
let cursor = anchorSeq
|
|
127
|
+
let inspected = 0
|
|
128
|
+
let reads = 0
|
|
129
|
+
|
|
130
|
+
while (reads < SESSION_EVENT_TAIL_MAX_READS) {
|
|
131
|
+
let result
|
|
132
|
+
try {
|
|
133
|
+
result = await readWindow(query, sessionId, cursor)
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (sessionQueryCode(error) === 'SESSION_QUERY_EVENT_NOT_FOUND' && cursor > anchorSeq) {
|
|
136
|
+
return { supported: true, events, capturedThroughSeq, truncated: false }
|
|
137
|
+
}
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
reads += 1
|
|
141
|
+
|
|
142
|
+
const window = result.window
|
|
143
|
+
const target = window?.target
|
|
144
|
+
if (!target || typeof target !== 'object' || target.seq !== cursor) {
|
|
145
|
+
throw new Error(`sessionQuery.readEvent returned an invalid target for seq ${cursor}`)
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(window.events) || window.events.length === 0) {
|
|
148
|
+
throw new Error(`sessionQuery.readEvent returned no event window for seq ${cursor}`)
|
|
149
|
+
}
|
|
150
|
+
const windowEvents = window.events
|
|
151
|
+
let expectedSeq = cursor
|
|
152
|
+
let endSeq = cursor
|
|
153
|
+
for (const event of windowEvents) {
|
|
154
|
+
const seq = event?.seq
|
|
155
|
+
if (!Number.isSafeInteger(seq) || seq !== expectedSeq) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`sessionQuery.readEvent returned non-contiguous seq ${String(seq)}; expected ${expectedSeq}`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
expectedSeq += 1
|
|
161
|
+
endSeq = seq
|
|
162
|
+
if (seq <= capturedThroughSeq) continue
|
|
163
|
+
capturedThroughSeq = seq
|
|
164
|
+
inspected += 1
|
|
165
|
+
if (collect) events.push(event)
|
|
166
|
+
if (inspected >= SESSION_EVENT_TAIL_MAX_EVENTS) {
|
|
167
|
+
return { supported: true, events, capturedThroughSeq, truncated: true }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (Number.isSafeInteger(window?.endSeq) && window.endSeq !== endSeq) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`sessionQuery.readEvent returned endSeq ${String(window.endSeq)} for window ending at ${endSeq}`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (result.after > 0) {
|
|
177
|
+
if (endSeq <= cursor || windowEvents.length < result.after + 1) {
|
|
178
|
+
return { supported: true, events, capturedThroughSeq: Math.max(capturedThroughSeq, endSeq), truncated: false }
|
|
179
|
+
}
|
|
180
|
+
cursor = endSeq
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// `readWindowMax: 0` is a valid Host configuration. Walk exact seqs and
|
|
185
|
+
// use the typed EVENT_NOT_FOUND result as the logical tail sentinel.
|
|
186
|
+
cursor += 1
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { supported: true, events, capturedThroughSeq, truncated: true }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build the narrow asynchronous Session-log reader used only for cold
|
|
196
|
+
* canonical attachment recovery after the bounded cache evicts a ref.
|
|
197
|
+
* Supported Hosts own replay validation through `sessionQuery.readSession()`;
|
|
198
|
+
* callers receive detached events and never touch synchronous Session history.
|
|
199
|
+
*/
|
|
200
|
+
export function createSessionLogReader(ctx) {
|
|
201
|
+
return async function readSessionLog(session) {
|
|
202
|
+
const sessionId = session?.id
|
|
203
|
+
if (sessionId === undefined || sessionId === null) return { supported: false }
|
|
204
|
+
const query = sessionQueryOf(ctx)
|
|
205
|
+
if (!query || typeof query.readSession !== 'function') return { supported: false }
|
|
206
|
+
const snapshot = await query.readSession(sessionId)
|
|
207
|
+
if (!snapshot || !Array.isArray(snapshot.events)) {
|
|
208
|
+
throw new Error('sessionQuery.readSession returned no event log')
|
|
209
|
+
}
|
|
210
|
+
const observedId = snapshot.session?.id
|
|
211
|
+
if (observedId !== undefined && observedId !== sessionId) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`sessionQuery.readSession returned session ${String(observedId)} for requested ${String(sessionId)}`,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
return { supported: true, events: snapshot.events }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
29
220
|
function attachmentStoreOf(ctx) {
|
|
30
221
|
if (!ctx || typeof ctx !== 'object') return undefined
|
|
31
222
|
try {
|
|
@@ -111,6 +302,17 @@ export function hasBatchAttachmentContract(ctx) {
|
|
|
111
302
|
return !!store && typeof store.saveImages === 'function'
|
|
112
303
|
}
|
|
113
304
|
|
|
305
|
+
/**
|
|
306
|
+
* DSH Hosts on the canonical batch-attachment contract also keep the official
|
|
307
|
+
* DeepSeek provider Host-owned. Later generations add still more request-local
|
|
308
|
+
* file/image-access seams, so DVR may wrap this provider but must never rebuild
|
|
309
|
+
* or resurrect it. Keep this semantic fact
|
|
310
|
+
* centralized so Core does not grow a second Host-generation detector.
|
|
311
|
+
*/
|
|
312
|
+
export function hostOwnsOfficialDeepSeekProvider(ctx) {
|
|
313
|
+
return hasBatchAttachmentContract(ctx)
|
|
314
|
+
}
|
|
315
|
+
|
|
114
316
|
function exactAlphaNormalizationDefaults(policy) {
|
|
115
317
|
return !!policy && typeof policy === 'object' &&
|
|
116
318
|
policy.maxPixels === DSH_ALPHA_DEFAULT_NORMALIZED_PIXELS &&
|
|
@@ -6,7 +6,7 @@ export const DSH_SUPPORT_WINDOW = Object.freeze({
|
|
|
6
6
|
|
|
7
7
|
export const DSH_VERIFICATION_EVIDENCE = Object.freeze({
|
|
8
8
|
exactStable: '0.1.5-rc.2',
|
|
9
|
-
exactPreview: '0.1.
|
|
9
|
+
exactPreview: '0.1.6-alpha.1',
|
|
10
10
|
stableCanaryDistTag: 'latest',
|
|
11
11
|
previewCanaryDistTag: 'alpha',
|
|
12
12
|
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function boundedAttachmentId(block) {
|
|
2
|
+
const attachment = block && typeof block === 'object' ? block.attachment : undefined
|
|
3
|
+
const raw = attachment && (attachment.attachmentId ?? attachment.id)
|
|
4
|
+
if (typeof raw !== 'string' || raw.trim() === '') return undefined
|
|
5
|
+
return raw.trim().slice(0, 256)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** DSH 0.1.6+ marks durable request-limit omissions on the ImageBlock itself. */
|
|
9
|
+
export function isOffloadedImageBlock(block) {
|
|
10
|
+
return !!block && block.type === 'image' && block.offloaded === true
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function blocksHaveRetainedImage(content) {
|
|
14
|
+
if (!Array.isArray(content)) return false
|
|
15
|
+
for (const block of content) {
|
|
16
|
+
if (!block || typeof block !== 'object') continue
|
|
17
|
+
if (block.type === 'image' && block.offloaded !== true) return true
|
|
18
|
+
if (Array.isArray(block.content) && blocksHaveRetainedImage(block.content)) return true
|
|
19
|
+
}
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Dependency-free model-facing placeholder for an image DSH already offloaded.
|
|
25
|
+
*
|
|
26
|
+
* Do not import the 0.1.6 `offloadedImageText` helper here: DVR still supports
|
|
27
|
+
* older Hosts where that export does not exist. The durable attachment stays
|
|
28
|
+
* available to Vision Router tools, but custom wire serializers must never
|
|
29
|
+
* read or resend its bytes once the Host projection marks this occurrence.
|
|
30
|
+
*/
|
|
31
|
+
export function offloadedImagePlaceholder(block) {
|
|
32
|
+
const id = boundedAttachmentId(block)
|
|
33
|
+
if (id === undefined) {
|
|
34
|
+
return '[image omitted to fit request image limits; attachment id unavailable]'
|
|
35
|
+
}
|
|
36
|
+
return `[image omitted to fit request image limits; attachment ${id}. ` +
|
|
37
|
+
`To inspect it again, call vision_describe with attachmentIds: [${JSON.stringify(id)}].]`
|
|
38
|
+
}
|