@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.
@@ -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 degrade to an explanatory block with `ok: false`, so the
383
- * caller can decide what a failure means (the cache refuses to memoize it).
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 currently serving Vision Toolkit runtime, if ready.
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 outcome and its model-facing replacement block.
339
+ * @returns the model-facing replacement block.
390
340
  */
391
341
  async function readImageBlock(
392
342
  ctx: Context,
393
- runtime: () => VisionToolkitRuntime | undefined,
343
+ runtime: () => CapturedEvidenceRuntime | undefined,
394
344
  block: ImageBlock,
395
345
  query: string,
396
346
  sessionId?: string,
397
- ): Promise<{ ok: boolean; block: ContentBlock }> {
347
+ ): Promise<ContentBlock> {
398
348
  const attachments = ctx.get('attachments')
399
349
  const current = runtime()
400
350
  if (attachments === undefined) {
401
- return { ok: false, block: { type: 'text', text: `${UNAVAILABLE_PREFIX}the DSH attachment service is not ready] The vision tool is temporarily unavailable; let the user know.` } }
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 { ok: false, block: { type: 'text', text: `${UNAVAILABLE_PREFIX}unsupported image media type ${block.attachment.mediaType}] The vision tool is temporarily unavailable; let the user know.` } }
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 { ok: true, block: { type: 'text', text: `${pathEvidence}${pathEvidence === '' ? '' : '\n'}${DESCRIPTION_PREFIX}${answer}` } }
380
+ return { type: 'text', text: `${pathEvidence}${pathEvidence === '' ? '' : '\n'}${DESCRIPTION_PREFIX}${answer}` }
434
381
  } catch (error) {
435
382
  return {
436
- ok: false,
437
- block: {
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 currently serving runtime (lazily read per conversion).
455
- * @param cache - shared per-adapter description cache.
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: () => VisionToolkitRuntime | undefined,
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(cacheKey(String(block.attachment.attachmentId), query, sessionId), () =>
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
- if (current !== this.lastRuntime) {
601
- this.cache.clear()
602
- this.lastRuntime = current
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
- this.runtime,
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
- new EvidenceCache(EVIDENCE_CACHE_LIMIT),
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
  }