@anionex/dsh-vision-toolkit 0.1.37 → 0.1.39
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.i18n.yaml +2 -2
- package/README.md +13 -42
- package/README.zh.md +12 -41
- package/lib/evidence-cache.js +350 -0
- package/lib/evidence-cache.js.map +1 -0
- package/lib/image-input-variants.js +49 -81
- package/lib/image-input-variants.js.map +1 -1
- package/lib/runtime-install.js +57 -15
- package/lib/runtime-install.js.map +1 -1
- package/lib/runtime.js +18 -1
- package/lib/runtime.js.map +1 -1
- package/lib/types/evidence-cache.d.ts +91 -0
- package/lib/types/evidence-cache.d.ts.map +1 -0
- package/lib/types/image-input-variants.d.ts +8 -26
- package/lib/types/image-input-variants.d.ts.map +1 -1
- package/lib/types/runtime-install.d.ts +13 -0
- package/lib/types/runtime-install.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +10 -0
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/upstream.d.ts.map +1 -1
- package/lib/upstream.js +10 -0
- package/lib/upstream.js.map +1 -1
- package/package.json +4 -2
- package/src/evidence-cache.ts +418 -0
- package/src/image-input-variants.ts +58 -89
- package/src/runtime-install.ts +63 -15
- package/src/runtime.ts +35 -1
- package/src/upstream.ts +10 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/** Durable, Session-scoped cache for image descriptions used by model variants. */
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
|
5
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
6
|
+
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
|
7
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
|
8
|
+
import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import type { ResolvedVisionToolkitConfig } from './config.ts'
|
|
11
|
+
import { UPSTREAM_COMMIT } from './version.ts'
|
|
12
|
+
|
|
13
|
+
/** Bump only when the model-visible evidence contract changes incompatibly. */
|
|
14
|
+
export const EVIDENCE_CONTRACT_VERSION = 1
|
|
15
|
+
|
|
16
|
+
/** Persistent cache bounds keep the Profile storage proportional and predictable. */
|
|
17
|
+
const DEFAULT_PERSISTED_ENTRY_LIMIT = 512
|
|
18
|
+
const DEFAULT_PERSISTED_BYTE_LIMIT = 8 * 1024 * 1024
|
|
19
|
+
const DEFAULT_PERSISTED_ENTRY_BYTE_LIMIT = 64 * 1024
|
|
20
|
+
const MAX_SCHEMA_TEXT_CHARS = 256 * 1024
|
|
21
|
+
const MAX_SCHEMA_RECORD_CHARS = 512 * 1024
|
|
22
|
+
|
|
23
|
+
const hexDigestSchema = z.string().regex(/^[0-9a-f]{64}$/u)
|
|
24
|
+
const sessionIdentitySchema = z.object({
|
|
25
|
+
createdAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
|
26
|
+
cwd: z.string().optional(),
|
|
27
|
+
})
|
|
28
|
+
const evidenceRecordSchema = z.object({
|
|
29
|
+
contractVersion: z.number().int().nonnegative(),
|
|
30
|
+
sessionId: z.string().min(1),
|
|
31
|
+
session: sessionIdentitySchema,
|
|
32
|
+
attachmentId: z.string().min(1),
|
|
33
|
+
promptHash: hexDigestSchema,
|
|
34
|
+
runtimeHash: hexDigestSchema,
|
|
35
|
+
text: z.string().min(1).max(MAX_SCHEMA_TEXT_CHARS),
|
|
36
|
+
storedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
type EvidenceRecord = z.infer<typeof evidenceRecordSchema>
|
|
40
|
+
type EvidenceRecordKey = string & { readonly __evidenceRecordKey: unique symbol }
|
|
41
|
+
|
|
42
|
+
/** Plugin-owned sidecar domain; the host backend handles atomicity and file safety. */
|
|
43
|
+
export const evidenceCacheDomainSpec = defineDomain({
|
|
44
|
+
name: 'vision_toolkit_evidence',
|
|
45
|
+
version: 0,
|
|
46
|
+
tables: {
|
|
47
|
+
// Keep the durable boundary tolerant of one damaged cache payload: each
|
|
48
|
+
// record is parsed and validated independently below, so corruption causes
|
|
49
|
+
// a miss instead of preventing the entire optional domain from opening.
|
|
50
|
+
evidence: domainTable<EvidenceRecordKey, string>(z.string().max(MAX_SCHEMA_RECORD_CHARS)),
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/** Stable metadata for one description lookup. Raw focus prompts are never persisted. */
|
|
55
|
+
export interface EvidenceCacheKey {
|
|
56
|
+
readonly digest: string
|
|
57
|
+
readonly contractVersion: number
|
|
58
|
+
readonly sessionId?: string
|
|
59
|
+
readonly sessionCreatedAt?: number
|
|
60
|
+
readonly sessionCwd?: string
|
|
61
|
+
readonly attachmentId: string
|
|
62
|
+
readonly promptHash: string
|
|
63
|
+
readonly runtimeHash: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Optional durable layer behind the process-local promise/LRU cache. */
|
|
67
|
+
export interface EvidencePersistence {
|
|
68
|
+
read(key: EvidenceCacheKey): Promise<ContentBlock | undefined>
|
|
69
|
+
write(key: EvidenceCacheKey, block: ContentBlock): Promise<void>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hash(value: string): string {
|
|
73
|
+
return createHash('sha256').update(value).digest('hex')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Fingerprint every runtime setting that can change the generated description. */
|
|
77
|
+
export function evidenceRuntimeFingerprint(
|
|
78
|
+
config: ResolvedVisionToolkitConfig,
|
|
79
|
+
credentialSha256?: string,
|
|
80
|
+
sslVerify?: string,
|
|
81
|
+
): string {
|
|
82
|
+
return hash(JSON.stringify({
|
|
83
|
+
upstreamCommit: UPSTREAM_COMMIT,
|
|
84
|
+
provider: {
|
|
85
|
+
baseUrl: config.provider.baseUrl,
|
|
86
|
+
credential: {
|
|
87
|
+
ref: String(config.provider.credential),
|
|
88
|
+
sha256: credentialSha256 ?? null,
|
|
89
|
+
},
|
|
90
|
+
model: config.provider.model,
|
|
91
|
+
protocol: config.provider.protocol,
|
|
92
|
+
anthropicThinking: config.provider.anthropicThinking,
|
|
93
|
+
sslVerify: sslVerify ?? null,
|
|
94
|
+
userAgent: config.provider.userAgent,
|
|
95
|
+
},
|
|
96
|
+
language: config.language,
|
|
97
|
+
timeoutMs: config.timeoutMs,
|
|
98
|
+
concurrency: config.concurrency,
|
|
99
|
+
maxImageBytes: config.maxImageBytes,
|
|
100
|
+
maxImagePixels: config.maxImagePixels,
|
|
101
|
+
runtime: config.runtime,
|
|
102
|
+
}))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Build a non-secret cache key from the Session, attachment, focus, and runtime contract. */
|
|
106
|
+
export function createEvidenceCacheKey(input: {
|
|
107
|
+
sessionId?: string
|
|
108
|
+
sessionIdentity?: SessionIdentity
|
|
109
|
+
attachmentId: string
|
|
110
|
+
prompt: string
|
|
111
|
+
runtimeHash: string
|
|
112
|
+
}): EvidenceCacheKey {
|
|
113
|
+
const promptHash = hash(input.prompt)
|
|
114
|
+
const digest = hash(JSON.stringify([
|
|
115
|
+
EVIDENCE_CONTRACT_VERSION,
|
|
116
|
+
input.sessionId ?? '',
|
|
117
|
+
input.sessionIdentity?.createdAt ?? '',
|
|
118
|
+
input.sessionIdentity?.cwd ?? '',
|
|
119
|
+
input.attachmentId,
|
|
120
|
+
promptHash,
|
|
121
|
+
input.runtimeHash,
|
|
122
|
+
]))
|
|
123
|
+
return Object.freeze({
|
|
124
|
+
digest,
|
|
125
|
+
contractVersion: EVIDENCE_CONTRACT_VERSION,
|
|
126
|
+
...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }),
|
|
127
|
+
...(input.sessionIdentity === undefined ? {} : { sessionCreatedAt: input.sessionIdentity.createdAt }),
|
|
128
|
+
...(input.sessionIdentity?.cwd === undefined ? {} : { sessionCwd: input.sessionIdentity.cwd }),
|
|
129
|
+
attachmentId: input.attachmentId,
|
|
130
|
+
promptHash,
|
|
131
|
+
runtimeHash: input.runtimeHash,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Bounded promise cache; concurrent readers join one load and rejected loads are evicted. */
|
|
136
|
+
export class EvidenceCache {
|
|
137
|
+
private readonly entries = new Map<string, Promise<ContentBlock>>()
|
|
138
|
+
|
|
139
|
+
constructor(
|
|
140
|
+
private readonly limit: number,
|
|
141
|
+
private readonly persistence?: EvidencePersistence,
|
|
142
|
+
) {}
|
|
143
|
+
|
|
144
|
+
/** Read a memory/durable hit or compute and persist one model-visible result. */
|
|
145
|
+
read(key: string | EvidenceCacheKey, load: () => Promise<ContentBlock>): Promise<ContentBlock> {
|
|
146
|
+
const memoryKey = typeof key === 'string' ? key : key.digest
|
|
147
|
+
const existing = this.entries.get(memoryKey)
|
|
148
|
+
if (existing !== undefined) {
|
|
149
|
+
// Refresh recency: Map iteration order is insertion order.
|
|
150
|
+
this.entries.delete(memoryKey)
|
|
151
|
+
this.entries.set(memoryKey, existing)
|
|
152
|
+
return existing
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const pending = (async (): Promise<ContentBlock> => {
|
|
156
|
+
if (typeof key !== 'string' && this.persistence !== undefined) {
|
|
157
|
+
try {
|
|
158
|
+
const persisted = await this.persistence.read(key)
|
|
159
|
+
if (persisted !== undefined) return persisted
|
|
160
|
+
} catch {
|
|
161
|
+
// Persistence is an optimization; a damaged/unavailable sidecar must
|
|
162
|
+
// never block the model request from recomputing evidence.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const block = await load()
|
|
167
|
+
if (typeof key !== 'string' && this.persistence !== undefined) {
|
|
168
|
+
try {
|
|
169
|
+
await this.persistence.write(key, block)
|
|
170
|
+
} catch {
|
|
171
|
+
// Keep the process-local result even when durability fails.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return block
|
|
175
|
+
})().then(
|
|
176
|
+
block => block,
|
|
177
|
+
(error: unknown) => {
|
|
178
|
+
// Only evict our own entry: this promise may have been LRU-evicted and
|
|
179
|
+
// the key re-populated by a newer read meanwhile.
|
|
180
|
+
if (this.entries.get(memoryKey) === pending) {
|
|
181
|
+
this.entries.delete(memoryKey)
|
|
182
|
+
}
|
|
183
|
+
throw error
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
this.entries.set(memoryKey, pending)
|
|
188
|
+
while (this.entries.size > this.limit) {
|
|
189
|
+
const oldest = this.entries.keys().next().value
|
|
190
|
+
if (oldest === undefined) break
|
|
191
|
+
this.entries.delete(oldest)
|
|
192
|
+
}
|
|
193
|
+
return pending
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Drop process-local descriptions; durable rows stay versioned by their runtime fingerprint. */
|
|
197
|
+
clear(): void {
|
|
198
|
+
this.entries.clear()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface StorageBinding {
|
|
203
|
+
table: KvTable<EvidenceRecordKey, string>
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface SessionIdentity {
|
|
207
|
+
createdAt: number
|
|
208
|
+
cwd?: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface SessionEvidenceStoreOptions {
|
|
212
|
+
maxEntries?: number
|
|
213
|
+
maxBytes?: number
|
|
214
|
+
maxEntryBytes?: number
|
|
215
|
+
now?: () => number
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function identityOf(header: SessionHeader): SessionIdentity {
|
|
219
|
+
return Object.freeze({
|
|
220
|
+
createdAt: header.createdAt,
|
|
221
|
+
...(header.cwd === undefined ? {} : { cwd: header.cwd }),
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function sameIdentity(record: EvidenceRecord, header: SessionHeader): boolean {
|
|
226
|
+
return record.session.createdAt === header.createdAt && record.session.cwd === header.cwd
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function matchesKey(record: EvidenceRecord, key: EvidenceCacheKey): boolean {
|
|
230
|
+
return record.contractVersion === key.contractVersion
|
|
231
|
+
&& record.sessionId === key.sessionId
|
|
232
|
+
&& record.attachmentId === key.attachmentId
|
|
233
|
+
&& record.promptHash === key.promptHash
|
|
234
|
+
&& record.runtimeHash === key.runtimeHash
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function byteLength(text: string): number {
|
|
238
|
+
return Buffer.byteLength(text, 'utf8')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function messageOf(error: unknown): string {
|
|
242
|
+
return error instanceof Error ? error.message : String(error)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function parseRecord(value: string): EvidenceRecord | undefined {
|
|
246
|
+
try {
|
|
247
|
+
const parsed: unknown = JSON.parse(value)
|
|
248
|
+
const result = evidenceRecordSchema.safeParse(parsed)
|
|
249
|
+
return result.success ? result.data : undefined
|
|
250
|
+
} catch {
|
|
251
|
+
return undefined
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Official DSH storage-domain sidecar used to survive Profile restarts. */
|
|
256
|
+
export class SessionEvidenceStore implements EvidencePersistence {
|
|
257
|
+
private storage: StorageBinding | undefined
|
|
258
|
+
private storageFiber: (Fiber & PromiseLike<Fiber>) | undefined
|
|
259
|
+
private storageReady: Promise<void> | undefined
|
|
260
|
+
private mutationTail: Promise<void> = Promise.resolve()
|
|
261
|
+
private readonly flushes = new Map<string, Promise<boolean>>()
|
|
262
|
+
private warned = false
|
|
263
|
+
private readonly maxEntries: number
|
|
264
|
+
private readonly maxBytes: number
|
|
265
|
+
private readonly maxEntryBytes: number
|
|
266
|
+
private readonly now: () => number
|
|
267
|
+
|
|
268
|
+
constructor(
|
|
269
|
+
private readonly ctx: Context,
|
|
270
|
+
options: SessionEvidenceStoreOptions = {},
|
|
271
|
+
) {
|
|
272
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_PERSISTED_ENTRY_LIMIT
|
|
273
|
+
this.maxBytes = options.maxBytes ?? DEFAULT_PERSISTED_BYTE_LIMIT
|
|
274
|
+
this.maxEntryBytes = options.maxEntryBytes ?? DEFAULT_PERSISTED_ENTRY_BYTE_LIMIT
|
|
275
|
+
this.now = options.now ?? Date.now
|
|
276
|
+
|
|
277
|
+
if (typeof ctx.inject !== 'function') return
|
|
278
|
+
this.storageFiber = ctx.inject(['storageDomain'], async (storageCtx: Context) => {
|
|
279
|
+
const domain = await storageCtx.storageDomain.open(evidenceCacheDomainSpec)
|
|
280
|
+
const binding: StorageBinding = { table: domain.table('evidence') }
|
|
281
|
+
try {
|
|
282
|
+
await this.enqueueMutation(async () => { await this.prune(binding.table) })
|
|
283
|
+
this.storage = binding
|
|
284
|
+
} catch (error) {
|
|
285
|
+
await domain.close()
|
|
286
|
+
throw error
|
|
287
|
+
}
|
|
288
|
+
return async () => {
|
|
289
|
+
if (this.storage === binding) this.storage = undefined
|
|
290
|
+
await this.mutationTail
|
|
291
|
+
await domain.close()
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
this.storageReady = Promise.resolve(this.storageFiber).then(
|
|
295
|
+
() => undefined,
|
|
296
|
+
(error: unknown) => { this.warnOnce(error) },
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Release the optional storage binding with the owning variant lifecycle. */
|
|
301
|
+
dispose(): void {
|
|
302
|
+
const fiber = this.storageFiber
|
|
303
|
+
this.storageFiber = undefined
|
|
304
|
+
this.storageReady = undefined
|
|
305
|
+
if (fiber !== undefined) void fiber.dispose().catch(error => { this.warnOnce(error) })
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async read(key: EvidenceCacheKey): Promise<ContentBlock | undefined> {
|
|
309
|
+
const session = this.sessionFor(key)
|
|
310
|
+
if (session === undefined) return undefined
|
|
311
|
+
const binding = await this.prepareStorage()
|
|
312
|
+
if (binding === undefined) return undefined
|
|
313
|
+
const stored = binding.table.get(key.digest as EvidenceRecordKey)
|
|
314
|
+
const record = stored === undefined ? undefined : parseRecord(stored)
|
|
315
|
+
if (record === undefined || !matchesKey(record, key) || !sameIdentity(record, session.header)) {
|
|
316
|
+
return undefined
|
|
317
|
+
}
|
|
318
|
+
if (byteLength(record.text) > this.maxEntryBytes) return undefined
|
|
319
|
+
return { type: 'text', text: record.text }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async write(key: EvidenceCacheKey, block: ContentBlock): Promise<void> {
|
|
323
|
+
if (block.type !== 'text' || block.text.length === 0 || byteLength(block.text) > this.maxEntryBytes) return
|
|
324
|
+
const session = this.sessionFor(key)
|
|
325
|
+
if (session === undefined) return
|
|
326
|
+
const binding = await this.prepareStorage()
|
|
327
|
+
if (binding === undefined) return
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const participated = await this.flushSession(session)
|
|
331
|
+
if (!participated) return
|
|
332
|
+
const record: EvidenceRecord = Object.freeze({
|
|
333
|
+
contractVersion: key.contractVersion,
|
|
334
|
+
sessionId: key.sessionId as string,
|
|
335
|
+
session: identityOf(session.header),
|
|
336
|
+
attachmentId: key.attachmentId,
|
|
337
|
+
promptHash: key.promptHash,
|
|
338
|
+
runtimeHash: key.runtimeHash,
|
|
339
|
+
text: block.text,
|
|
340
|
+
storedAt: this.now(),
|
|
341
|
+
})
|
|
342
|
+
await this.enqueueMutation(async () => {
|
|
343
|
+
await binding.table.put(key.digest as EvidenceRecordKey, JSON.stringify(record))
|
|
344
|
+
await this.prune(binding.table)
|
|
345
|
+
})
|
|
346
|
+
} catch (error) {
|
|
347
|
+
this.warnOnce(error)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private sessionFor(key: EvidenceCacheKey): ReturnType<Context['sessions']['get']> {
|
|
352
|
+
if (key.sessionId === undefined) return undefined
|
|
353
|
+
const session = this.ctx.sessions.get(key.sessionId as SessionId)
|
|
354
|
+
if (session === undefined) return undefined
|
|
355
|
+
if (key.sessionCreatedAt !== undefined
|
|
356
|
+
&& (session.header.createdAt !== key.sessionCreatedAt || session.header.cwd !== key.sessionCwd)) {
|
|
357
|
+
return undefined
|
|
358
|
+
}
|
|
359
|
+
return session
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private async prepareStorage(): Promise<StorageBinding | undefined> {
|
|
363
|
+
if (this.storage !== undefined) return this.storage
|
|
364
|
+
if (this.ctx.get('storageDomain') === undefined) return undefined
|
|
365
|
+
await this.storageReady
|
|
366
|
+
return this.storage
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private enqueueMutation(operation: () => Promise<void>): Promise<void> {
|
|
370
|
+
const result = this.mutationTail.then(operation)
|
|
371
|
+
this.mutationTail = result.then(() => undefined, () => undefined)
|
|
372
|
+
return result
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private flushSession(session: NonNullable<ReturnType<Context['sessions']['get']>>): Promise<boolean> {
|
|
376
|
+
const key = `${session.id}\u0000${session.header.createdAt}\u0000${session.header.cwd ?? ''}`
|
|
377
|
+
const existing = this.flushes.get(key)
|
|
378
|
+
if (existing !== undefined) return existing
|
|
379
|
+
const pending = this.ctx.sessions.flush(session).finally(() => {
|
|
380
|
+
if (this.flushes.get(key) === pending) this.flushes.delete(key)
|
|
381
|
+
})
|
|
382
|
+
this.flushes.set(key, pending)
|
|
383
|
+
return pending
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private async prune(table: KvTable<EvidenceRecordKey, string>): Promise<void> {
|
|
387
|
+
const records = [...table.entries()].map(([key, stored]) => {
|
|
388
|
+
const record = parseRecord(stored)
|
|
389
|
+
return {
|
|
390
|
+
key,
|
|
391
|
+
bytes: byteLength(stored),
|
|
392
|
+
storedAt: record?.storedAt ?? -1,
|
|
393
|
+
valid: record !== undefined && byteLength(record.text) <= this.maxEntryBytes,
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
let totalBytes = records.reduce((sum, record) => sum + record.bytes, 0)
|
|
397
|
+
let totalEntries = records.length
|
|
398
|
+
records.sort((left, right) => Number(left.valid) - Number(right.valid)
|
|
399
|
+
|| left.storedAt - right.storedAt
|
|
400
|
+
|| left.key.localeCompare(right.key))
|
|
401
|
+
for (const record of records) {
|
|
402
|
+
if (record.valid && totalEntries <= this.maxEntries && totalBytes <= this.maxBytes) break
|
|
403
|
+
if (await table.delete(record.key)) {
|
|
404
|
+
totalEntries -= 1
|
|
405
|
+
totalBytes -= record.bytes
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private warnOnce(error: unknown): void {
|
|
411
|
+
if (this.warned) return
|
|
412
|
+
this.warned = true
|
|
413
|
+
this.ctx.logger?.warn(
|
|
414
|
+
'dsh-vision-toolkit: persistent image evidence cache is unavailable; using the process cache only. %s',
|
|
415
|
+
messageOf(error).slice(0, 500),
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -29,8 +29,15 @@ import type {
|
|
|
29
29
|
import type {} from '@deepseek-ai/dsh-session'
|
|
30
30
|
import type {} from '@deepseek-ai/dsh-attachment'
|
|
31
31
|
import type { ResolvedVisionToolkitConfig } from './config.ts'
|
|
32
|
+
import {
|
|
33
|
+
createEvidenceCacheKey,
|
|
34
|
+
EvidenceCache,
|
|
35
|
+
SessionEvidenceStore,
|
|
36
|
+
} from './evidence-cache.ts'
|
|
32
37
|
import { sessionPasteRoot, type PasteSelectionQuery, type PasteVerdict } from './paste-images.ts'
|
|
33
|
-
import type { VisionToolkitRuntime } from './runtime.ts'
|
|
38
|
+
import type { CapturedEvidenceRuntime, VisionToolkitRuntime } from './runtime.ts'
|
|
39
|
+
|
|
40
|
+
export { EvidenceCache } from './evidence-cache.ts'
|
|
34
41
|
|
|
35
42
|
/** Provider-id prefix for the variant routes this plugin registers. */
|
|
36
43
|
export const VARIANT_PROVIDER_PREFIX = 'vision-toolkit-'
|
|
@@ -189,69 +196,12 @@ function assistantMessageText(message: Message): string {
|
|
|
189
196
|
.join('\n\n')
|
|
190
197
|
}
|
|
191
198
|
|
|
192
|
-
function cacheKey(attachmentId: string, prompt: string, sessionId?: string): string {
|
|
193
|
-
return `${sessionId ?? ''}\u0000${attachmentId}\u0000${prompt}`
|
|
194
|
-
}
|
|
195
|
-
|
|
196
199
|
function contentHasText(blocks: readonly ContentBlock[], text: string): boolean {
|
|
197
200
|
return blocks.some(block =>
|
|
198
201
|
(block.type === 'text' && block.text === text)
|
|
199
202
|
|| (block.type === 'tool-result' && contentHasText(block.content, text)))
|
|
200
203
|
}
|
|
201
204
|
|
|
202
|
-
/** Bounded promise cache for one attachment's description; failed reads are not retained. */
|
|
203
|
-
export class EvidenceCache {
|
|
204
|
-
private readonly entries = new Map<string, Promise<ContentBlock>>()
|
|
205
|
-
|
|
206
|
-
constructor(private readonly limit: number) {}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Read one attachment-and-prompt key's entry or compute it. Concurrent readers join the in-flight
|
|
210
|
-
* computation; a settled failure is evicted so a fixed configuration gets a
|
|
211
|
-
* fresh chance.
|
|
212
|
-
* @param key - the attachment identity plus the exact focus prompt.
|
|
213
|
-
* @param load - computes the description; must resolve `{ ok, block }` and never reject.
|
|
214
|
-
* @returns the cached or computed block.
|
|
215
|
-
*/
|
|
216
|
-
read(key: string, load: () => Promise<{ ok: boolean; block: ContentBlock }>): Promise<ContentBlock> {
|
|
217
|
-
const existing = this.entries.get(key)
|
|
218
|
-
if (existing !== undefined) {
|
|
219
|
-
// Refresh recency: Map iteration order is insertion order.
|
|
220
|
-
this.entries.delete(key)
|
|
221
|
-
this.entries.set(key, existing)
|
|
222
|
-
return existing
|
|
223
|
-
}
|
|
224
|
-
const pending = load().then(
|
|
225
|
-
(result) => {
|
|
226
|
-
// Only evict our own entry: this promise may have been LRU-evicted and
|
|
227
|
-
// the key re-populated by a newer read meanwhile.
|
|
228
|
-
if (!result.ok && this.entries.get(key) === pending) {
|
|
229
|
-
this.entries.delete(key)
|
|
230
|
-
}
|
|
231
|
-
return result.block
|
|
232
|
-
},
|
|
233
|
-
(error: unknown) => {
|
|
234
|
-
if (this.entries.get(key) === pending) {
|
|
235
|
-
this.entries.delete(key)
|
|
236
|
-
}
|
|
237
|
-
throw error
|
|
238
|
-
},
|
|
239
|
-
)
|
|
240
|
-
this.entries.set(key, pending)
|
|
241
|
-
while (this.entries.size > this.limit) {
|
|
242
|
-
const oldest = this.entries.keys().next().value
|
|
243
|
-
if (oldest === undefined) break
|
|
244
|
-
this.entries.delete(oldest)
|
|
245
|
-
}
|
|
246
|
-
return pending
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Drop every cached description (runtime reconfiguration invalidates provider-specific reads). */
|
|
250
|
-
clear(): void {
|
|
251
|
-
this.entries.clear()
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
205
|
/**
|
|
256
206
|
* Wait on a shared promise without inheriting its lifetime: the caller's
|
|
257
207
|
* abort rejects this wait immediately, while the underlying read keeps
|
|
@@ -379,30 +329,30 @@ function createLimiter(limit: number): <T>(task: () => Promise<T>, signal?: Abor
|
|
|
379
329
|
|
|
380
330
|
/**
|
|
381
331
|
* Read one image block into a Vision Toolkit description text block. Never
|
|
382
|
-
* throws: failures
|
|
383
|
-
*
|
|
332
|
+
* throws: failures become model-visible explanatory blocks and are cached like
|
|
333
|
+
* successful descriptions so replayed history stays byte-identical.
|
|
384
334
|
* @param ctx - plugin context; reads the optional `attachments` service.
|
|
385
|
-
* @param runtime - the
|
|
335
|
+
* @param runtime - the immutable runtime snapshot captured for this conversion.
|
|
386
336
|
* @param block - the image block to describe.
|
|
387
337
|
* @param query - the exact focus-hinted prompt sent to the vision model.
|
|
388
338
|
* @param sessionId - the live Session identity, used to keep a model-visible copy.
|
|
389
|
-
* @returns the
|
|
339
|
+
* @returns the model-facing replacement block.
|
|
390
340
|
*/
|
|
391
341
|
async function readImageBlock(
|
|
392
342
|
ctx: Context,
|
|
393
|
-
runtime: () =>
|
|
343
|
+
runtime: () => CapturedEvidenceRuntime | undefined,
|
|
394
344
|
block: ImageBlock,
|
|
395
345
|
query: string,
|
|
396
346
|
sessionId?: string,
|
|
397
|
-
): Promise<
|
|
347
|
+
): Promise<ContentBlock> {
|
|
398
348
|
const attachments = ctx.get('attachments')
|
|
399
349
|
const current = runtime()
|
|
400
350
|
if (attachments === undefined) {
|
|
401
|
-
return {
|
|
351
|
+
return { type: 'text', text: `${UNAVAILABLE_PREFIX}the DSH attachment service is not ready] The vision tool is temporarily unavailable; let the user know.` }
|
|
402
352
|
}
|
|
403
353
|
const extension = MEDIA_EXTENSIONS[block.attachment.mediaType]
|
|
404
354
|
if (extension === undefined) {
|
|
405
|
-
return {
|
|
355
|
+
return { type: 'text', text: `${UNAVAILABLE_PREFIX}unsupported image media type ${block.attachment.mediaType}] The vision tool is temporarily unavailable; let the user know.` }
|
|
406
356
|
}
|
|
407
357
|
let temporaryDirectory: string | undefined
|
|
408
358
|
let pathEvidence = ''
|
|
@@ -416,10 +366,7 @@ async function readImageBlock(
|
|
|
416
366
|
// can still use the path with a later visual-tool call if the bridge is
|
|
417
367
|
// temporarily unavailable on this turn.
|
|
418
368
|
if (current === undefined) {
|
|
419
|
-
return {
|
|
420
|
-
ok: false,
|
|
421
|
-
block: { type: 'text', text: `${pathEvidence}${pathEvidence === '' ? '' : '\n'}${UNAVAILABLE_PREFIX}the Vision Toolkit runtime is not ready] The vision tool is temporarily unavailable; let the user know.` },
|
|
422
|
-
}
|
|
369
|
+
return { type: 'text', text: `${pathEvidence}${pathEvidence === '' ? '' : '\n'}${UNAVAILABLE_PREFIX}the Vision Toolkit runtime is not ready] The vision tool is temporarily unavailable; let the user know.` }
|
|
423
370
|
}
|
|
424
371
|
// A fresh signal on purpose: the cached run must not die with its first
|
|
425
372
|
// caller (their abort used to cancel every concurrent joiner); the runtime
|
|
@@ -430,14 +377,11 @@ async function readImageBlock(
|
|
|
430
377
|
)
|
|
431
378
|
const answer = result.answer.trim()
|
|
432
379
|
if (answer.length === 0) throw new Error('the Vision Toolkit returned an empty description')
|
|
433
|
-
return {
|
|
380
|
+
return { type: 'text', text: `${pathEvidence}${pathEvidence === '' ? '' : '\n'}${DESCRIPTION_PREFIX}${answer}` }
|
|
434
381
|
} catch (error) {
|
|
435
382
|
return {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
type: 'text',
|
|
439
|
-
text: `${pathEvidence}${pathEvidence === '' ? '' : '\n'}${UNAVAILABLE_PREFIX}${messageOf(error).slice(0, 300)}] The vision tool is temporarily unavailable; let the user know.`,
|
|
440
|
-
},
|
|
383
|
+
type: 'text',
|
|
384
|
+
text: `${pathEvidence}${pathEvidence === '' ? '' : '\n'}${UNAVAILABLE_PREFIX}${messageOf(error).slice(0, 300)}] The vision tool is temporarily unavailable; let the user know.`,
|
|
441
385
|
}
|
|
442
386
|
} finally {
|
|
443
387
|
if (temporaryDirectory !== undefined) {
|
|
@@ -451,21 +395,30 @@ async function readImageBlock(
|
|
|
451
395
|
* The original messages are returned untouched when nothing carries an image;
|
|
452
396
|
* converted messages are new objects, so the durable request stays immutable.
|
|
453
397
|
* @param ctx - plugin context for the attachments service.
|
|
454
|
-
* @param runtime - the
|
|
455
|
-
* @param cache - shared
|
|
398
|
+
* @param runtime - the immutable runtime snapshot captured for this conversion.
|
|
399
|
+
* @param cache - shared process/durable description cache.
|
|
456
400
|
* @param messages - the assembled request messages.
|
|
457
401
|
* @param signal - the caller's cancellation for this conversion pass.
|
|
458
402
|
* @param sessionId - the live Session identity, when available.
|
|
403
|
+
* @param runtimeHash - stable fingerprint of the vision provider and evidence runtime.
|
|
459
404
|
* @returns the rewritten message list.
|
|
460
405
|
*/
|
|
461
406
|
export async function convertImagesToEvidence(
|
|
462
407
|
ctx: Context,
|
|
463
|
-
runtime: () =>
|
|
408
|
+
runtime: () => CapturedEvidenceRuntime | undefined,
|
|
464
409
|
cache: EvidenceCache,
|
|
465
410
|
messages: readonly Message[],
|
|
466
411
|
signal?: AbortSignal,
|
|
467
412
|
sessionId?: string,
|
|
413
|
+
runtimeHash = 'process-only-runtime',
|
|
468
414
|
): Promise<Message[]> {
|
|
415
|
+
const session = sessionId === undefined ? undefined : ctx.sessions.get(sessionId as never)
|
|
416
|
+
const sessionIdentity = session === undefined
|
|
417
|
+
? undefined
|
|
418
|
+
: {
|
|
419
|
+
createdAt: session.header.createdAt,
|
|
420
|
+
...(session.header.cwd === undefined ? {} : { cwd: session.header.cwd }),
|
|
421
|
+
}
|
|
469
422
|
const plans: Array<{ message: Message; query?: string }> = []
|
|
470
423
|
let lastUserText = ''
|
|
471
424
|
let lastAssistantText = ''
|
|
@@ -507,7 +460,13 @@ export async function convertImagesToEvidence(
|
|
|
507
460
|
if (query === undefined) return message
|
|
508
461
|
const content = await convertBlocks(message.content, (block) => abortableWait(
|
|
509
462
|
limit(
|
|
510
|
-
() => cache.read(
|
|
463
|
+
() => cache.read(createEvidenceCacheKey({
|
|
464
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
465
|
+
...(sessionIdentity === undefined ? {} : { sessionIdentity }),
|
|
466
|
+
attachmentId: String(block.attachment.attachmentId),
|
|
467
|
+
prompt: query,
|
|
468
|
+
runtimeHash,
|
|
469
|
+
}), () =>
|
|
511
470
|
readImageBlock(ctx, runtime, block, query, sessionId)),
|
|
512
471
|
signal,
|
|
513
472
|
),
|
|
@@ -537,8 +496,6 @@ export async function convertImagesToEvidence(
|
|
|
537
496
|
* retry policy, and replay handling still apply).
|
|
538
497
|
*/
|
|
539
498
|
export class ImageInputVariantAdapter extends LlmAdapter {
|
|
540
|
-
private lastRuntime: VisionToolkitRuntime | undefined
|
|
541
|
-
|
|
542
499
|
constructor(
|
|
543
500
|
private readonly ctx: Context,
|
|
544
501
|
private readonly llm: LlmService,
|
|
@@ -594,20 +551,29 @@ export class ImageInputVariantAdapter extends LlmAdapter {
|
|
|
594
551
|
}
|
|
595
552
|
|
|
596
553
|
override async *stream(options: GenerateOptions): AsyncGenerator<StreamChunk> {
|
|
597
|
-
// A reconfigured runtime is a NEW instance; descriptions read through the
|
|
598
|
-
// previous provider must not be replayed for the new one.
|
|
599
554
|
const current = this.runtime()
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
555
|
+
let captured: CapturedEvidenceRuntime | undefined
|
|
556
|
+
if (current !== undefined && options.messages.some(message => contentHasImage(message.content))) {
|
|
557
|
+
try {
|
|
558
|
+
captured = await current.captureEvidenceRuntime()
|
|
559
|
+
} catch (error) {
|
|
560
|
+
// Keep the established graceful-degradation path. The unavailable block
|
|
561
|
+
// is cached under this fallback fingerprint so historical replay stays
|
|
562
|
+
// stable; a later successful credential capture produces a different key.
|
|
563
|
+
captured = Object.freeze({
|
|
564
|
+
evidenceFingerprint: current.evidenceFingerprint,
|
|
565
|
+
glance: async () => { throw error },
|
|
566
|
+
})
|
|
567
|
+
}
|
|
603
568
|
}
|
|
604
569
|
const messages = await convertImagesToEvidence(
|
|
605
570
|
this.ctx,
|
|
606
|
-
|
|
571
|
+
() => captured,
|
|
607
572
|
this.cache,
|
|
608
573
|
options.messages,
|
|
609
574
|
options.signal,
|
|
610
575
|
options.sessionId === undefined ? undefined : String(options.sessionId),
|
|
576
|
+
captured?.evidenceFingerprint ?? 'process-only-runtime',
|
|
611
577
|
)
|
|
612
578
|
// Delegate through the host service under the upstream route: the variant
|
|
613
579
|
// is a wire-only facade, and the upstream route owns retry and replay.
|
|
@@ -844,6 +810,8 @@ export function installImageInputVariants(
|
|
|
844
810
|
getConfig: () => ResolvedVisionToolkitConfig,
|
|
845
811
|
getRuntime: () => VisionToolkitRuntime | undefined,
|
|
846
812
|
): { dispose: () => void; reconcile: () => void } {
|
|
813
|
+
const evidenceStore = new SessionEvidenceStore(ctx)
|
|
814
|
+
const evidenceCache = new EvidenceCache(EVIDENCE_CACHE_LIMIT, evidenceStore)
|
|
847
815
|
const registrations = new Map<string, () => void>()
|
|
848
816
|
// The host snapshots adapter provider metadata (including the group display
|
|
849
817
|
// name) at registration time, so a transparent-routing toggle must rebuild
|
|
@@ -975,7 +943,7 @@ export function installImageInputVariants(
|
|
|
975
943
|
upstream,
|
|
976
944
|
provider.name,
|
|
977
945
|
getRuntime,
|
|
978
|
-
|
|
946
|
+
evidenceCache,
|
|
979
947
|
() => getConfig().imageInputVariants.hidden,
|
|
980
948
|
),
|
|
981
949
|
)
|
|
@@ -1007,6 +975,7 @@ export function installImageInputVariants(
|
|
|
1007
975
|
disposed = true
|
|
1008
976
|
clearInterval(timer)
|
|
1009
977
|
releaseAll()
|
|
978
|
+
evidenceStore.dispose()
|
|
1010
979
|
},
|
|
1011
980
|
reconcile: () => { sweep() },
|
|
1012
981
|
}
|