mlx-bun 0.3.0 → 0.5.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 (231) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +58 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  5. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  6. package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
  7. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  8. package/docs/design/batching.md +1535 -50
  9. package/docs/design/decode-speed-program.md +394 -1171
  10. package/docs/design/generic-model-support.md +195 -3
  11. package/docs/design/kv-cache.md +538 -225
  12. package/docs/design/speculative-decoding.md +552 -50
  13. package/docs/design/turboquant.md +140 -17
  14. package/docs/design/unified-engine-frontier-plan.md +259 -150
  15. package/docs/planning/PRODUCT_ROADMAP.md +21 -6
  16. package/docs/planning/public-readiness-handoff.md +244 -0
  17. package/docs/planning/public-readiness.md +149 -0
  18. package/docs/reference/benchmarks.md +3537 -1112
  19. package/docs/reference/cli.md +102 -2
  20. package/docs/reference/distribution.md +45 -13
  21. package/docs/reference/environment.md +159 -13
  22. package/docs/reference/library-api.md +18 -4
  23. package/docs/reference/memory.md +1 -1
  24. package/docs/reference/models.md +28 -7
  25. package/docs/reference/server-api.md +257 -33
  26. package/docs/reference/server-config.md +372 -151
  27. package/docs/reference/troubleshooting.md +13 -21
  28. package/package.json +2 -2
  29. package/src/audio/audiotoolbox.ts +112 -0
  30. package/src/audio/conformer.ts +8 -0
  31. package/src/audio/mic-capture.ts +98 -0
  32. package/src/audio/silero-vad.ts +379 -0
  33. package/src/audio/whisper-decode.ts +774 -0
  34. package/src/audio/whisper-fast.ts +732 -0
  35. package/src/audio/whisper-format.ts +73 -0
  36. package/src/audio/whisper-mel.ts +185 -0
  37. package/src/audio/whisper-timing.ts +274 -0
  38. package/src/audio/whisper-tokenizer.ts +239 -0
  39. package/src/audio/whisper-transcribe.ts +410 -0
  40. package/src/backends/mlx/assistant-target.ts +46 -0
  41. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  42. package/src/backends/mlx/autoregressive.ts +22 -1
  43. package/src/backends/mlx/batch-group.ts +464 -236
  44. package/src/backends/mlx/cache-attachment.ts +67 -0
  45. package/src/backends/mlx/cache-layout.ts +31 -0
  46. package/src/backends/mlx/checkpoint-state.ts +53 -0
  47. package/src/backends/mlx/continuation-persistence.ts +107 -0
  48. package/src/backends/mlx/continuation-request.ts +43 -0
  49. package/src/backends/mlx/continuation.ts +77 -0
  50. package/src/backends/mlx/draft-target.ts +7 -22
  51. package/src/backends/mlx/encoder-cache.ts +25 -0
  52. package/src/backends/mlx/fill-append.ts +21 -8
  53. package/src/backends/mlx/fill-group.ts +497 -0
  54. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  55. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  56. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  57. package/src/backends/mlx/gateway-binding.ts +122 -19
  58. package/src/backends/mlx/kv-codec.ts +9 -0
  59. package/src/backends/mlx/kv-maintenance.ts +161 -0
  60. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  61. package/src/backends/mlx/mixed-iteration.ts +62 -0
  62. package/src/backends/mlx/model-prompt.ts +46 -32
  63. package/src/backends/mlx/model-serving.ts +5 -4
  64. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  65. package/src/backends/mlx/prefill-cohort.ts +100 -0
  66. package/src/backends/mlx/prefill-policy.ts +26 -0
  67. package/src/backends/mlx/prefill-rows.ts +244 -0
  68. package/src/backends/mlx/prefill.ts +1 -1
  69. package/src/backends/mlx/projected-context-rows.ts +72 -0
  70. package/src/backends/mlx/prompt-input.ts +29 -0
  71. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  72. package/src/backends/mlx/request-state-policy.ts +54 -0
  73. package/src/backends/mlx/rollback.ts +45 -4
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +51 -73
  76. package/src/backends/mlx/speculative-group.ts +438 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +24 -7
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +21 -1
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
  83. package/src/cli.ts +333 -36
  84. package/src/contracts/execution.ts +21 -0
  85. package/src/contracts/object-cache.ts +8 -0
  86. package/src/contracts/pi-provider.ts +1 -1
  87. package/src/contracts/pi.ts +3 -1
  88. package/src/contracts/prefix-cache.ts +33 -0
  89. package/src/contracts/resources.ts +7 -0
  90. package/src/contracts/sampling.ts +23 -0
  91. package/src/contracts/scheduling.ts +27 -0
  92. package/src/engine/execution-plan.ts +38 -25
  93. package/src/engine/execution-tasks.ts +44 -0
  94. package/src/engine/namespaced-cache.ts +13 -0
  95. package/src/engine/resources.ts +12 -1
  96. package/src/engine/scheduler.ts +32 -7
  97. package/src/fill/fill-session.ts +14 -3
  98. package/src/fit.ts +29 -15
  99. package/src/generate.ts +105 -195
  100. package/src/grammar.ts +13 -0
  101. package/src/inference/draft-acceptance.ts +41 -0
  102. package/src/inference/draft-output.ts +49 -0
  103. package/src/inference/prefill.ts +6 -0
  104. package/src/inference/rollback.ts +2 -2
  105. package/src/kv-scheme.ts +92 -12
  106. package/src/kv-store.ts +183 -93
  107. package/src/lab/README.md +5 -1
  108. package/src/lab/paged-kv/paged-attention.ts +117 -0
  109. package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
  110. package/src/lab/paged-kv/paged-kv.ts +126 -46
  111. package/src/md-modules.d.ts +5 -0
  112. package/src/mlx/array.ts +39 -1
  113. package/src/mlx/ffi.ts +19 -0
  114. package/src/mlx/normalized-argmax.ts +116 -0
  115. package/src/mlx/ops.ts +97 -10
  116. package/src/model/artifact-identity.ts +73 -0
  117. package/src/model/batched-kv.ts +235 -0
  118. package/src/model/batched-mask.ts +12 -4
  119. package/src/model/batched-quant.ts +8 -2
  120. package/src/model/batched-quantized-kv.ts +144 -0
  121. package/src/model/batched-rotating-quant.ts +76 -15
  122. package/src/model/batched-rotating-state.ts +76 -0
  123. package/src/model/batched-rotating.ts +64 -22
  124. package/src/model/batched-row-storage.ts +52 -0
  125. package/src/model/batched-ssm.ts +52 -0
  126. package/src/model/batched-turboquant-kv.ts +94 -0
  127. package/src/model/compiled-decode.ts +7 -1
  128. package/src/model/decoded-kv-donor.ts +12 -0
  129. package/src/model/delayed-quantized-kv.ts +72 -0
  130. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  131. package/src/model/delayed-turboquant-kv.ts +57 -0
  132. package/src/model/factory.ts +26 -0
  133. package/src/model/fingerprint.ts +12 -0
  134. package/src/model/full-prefill-padding.ts +56 -0
  135. package/src/model/full-prefill-row.ts +105 -0
  136. package/src/model/gemma4-base.ts +183 -82
  137. package/src/model/gemma4.ts +116 -28
  138. package/src/model/generated/gemma4-12b.ts +4 -1
  139. package/src/model/generated/gemma4-26b.ts +4 -1
  140. package/src/model/generated/gemma4-e4b.ts +4 -1
  141. package/src/model/glm52-cache.ts +80 -4
  142. package/src/model/glm52-mla.ts +12 -6
  143. package/src/model/glm52.ts +19 -40
  144. package/src/model/kv-attention-view.ts +70 -0
  145. package/src/model/minicpm5.ts +11 -7
  146. package/src/model/profile.ts +29 -4
  147. package/src/model/quantized-append-attention.ts +31 -0
  148. package/src/model/qwen3-delta-state.ts +74 -0
  149. package/src/model/qwen3-delta.ts +59 -36
  150. package/src/model/qwen3-mrope.ts +7 -9
  151. package/src/model/qwen3_5.ts +177 -75
  152. package/src/model/rotating-kv-donor.ts +35 -0
  153. package/src/model/rotating-kv-layout.ts +262 -0
  154. package/src/model/rotating-row-transaction.ts +35 -0
  155. package/src/model/speculative-rotating-kv.ts +101 -0
  156. package/src/model/ssm-prefill-padding.ts +39 -0
  157. package/src/model/support.ts +8 -0
  158. package/src/model/token-groups.ts +66 -0
  159. package/src/model/trellis-linear.ts +4 -4
  160. package/src/model/turboquant-codec.ts +89 -0
  161. package/src/model/universal/dense.ts +14 -13
  162. package/src/model/whisper.ts +435 -0
  163. package/src/native/mic_capture.swift +92 -0
  164. package/src/native-pack.ts +6 -5
  165. package/src/pi-web.ts +9 -1
  166. package/src/preflight.ts +31 -15
  167. package/src/prompt-cache.ts +306 -86
  168. package/src/runtime-config.ts +1 -5
  169. package/src/safetensors.ts +9 -2
  170. package/src/sampler.ts +37 -25
  171. package/src/serve/audio-routes.ts +323 -0
  172. package/src/serve/chat-request.ts +2 -0
  173. package/src/serve/chat-stage.ts +19 -14
  174. package/src/serve/completion-executor.ts +9 -1
  175. package/src/serve/discovery-routes.ts +17 -1
  176. package/src/serve/generated-token-history.ts +57 -0
  177. package/src/serve/generation-gateway.ts +88 -38
  178. package/src/serve/model-binding.ts +7 -1
  179. package/src/serve/model-host.ts +3 -4
  180. package/src/serve/preparation.ts +3 -3
  181. package/src/serve/prompt-response-trace.ts +14 -0
  182. package/src/serve/request-plan.ts +6 -3
  183. package/src/serve/request-prep.ts +40 -25
  184. package/src/serve/text-completion-stage.ts +2 -2
  185. package/src/serve/transcription-server.ts +64 -0
  186. package/src/serve/transcription-service.ts +502 -0
  187. package/src/server.ts +185 -261
  188. package/src/spec/assistant-rows.ts +93 -0
  189. package/src/spec/assistant-source.ts +28 -18
  190. package/src/spec/cache-identity.ts +22 -0
  191. package/src/spec/deepspec-rows.ts +29 -0
  192. package/src/spec/deepspec-source.ts +12 -10
  193. package/src/spec/dflash-source.ts +28 -4
  194. package/src/spec/drafter.ts +153 -142
  195. package/src/spec/dspark/deepspec-module.ts +95 -120
  196. package/src/spec/dspark/module-dflash.ts +126 -170
  197. package/src/spec/glm52-mtp-graph.ts +29 -0
  198. package/src/spec/glm52-mtp-rows.ts +132 -0
  199. package/src/spec/glm52-mtp-source.ts +54 -136
  200. package/src/spec/glm52-mtp-state.ts +33 -0
  201. package/src/spec/ngram-source.ts +115 -21
  202. package/src/spec/projected-draft-rows.ts +73 -0
  203. package/src/spec/qwen-mtp-module.ts +171 -0
  204. package/src/spec/qwen-mtp-rows.ts +230 -0
  205. package/src/spec/qwen-mtp-source.ts +84 -226
  206. package/src/spec/qwen-mtp-state.ts +30 -0
  207. package/src/spec/serve-loop.ts +73 -69
  208. package/src/spec/source.ts +93 -15
  209. package/src/spec/two-model-rows.ts +62 -0
  210. package/src/spec/two-model.ts +45 -103
  211. package/src/ssd-cache.ts +107 -36
  212. package/src/ssd-durability.ts +29 -27
  213. package/src/storage/host-buffer.ts +24 -0
  214. package/src/storage/kv-writer.ts +83 -0
  215. package/src/storage/kv-writer.worker.js +264 -0
  216. package/src/storage/retention-policy.ts +37 -0
  217. package/src/tiered-prompt-cache.ts +45 -0
  218. package/src/vision/embedder.ts +8 -0
  219. package/src/vision/prompt.ts +101 -65
  220. package/src/vision/qwen3vl-prompt.ts +101 -64
  221. package/src/vision/qwen3vl-tower.ts +11 -0
  222. package/src/vision/siglip.ts +8 -0
  223. package/src/web/app.html +12 -0
  224. package/src/web/app.js +217 -57
  225. package/src/web/src/chat.ts +4 -1
  226. package/src/web/src/composer.ts +3 -0
  227. package/src/web/src/voice.ts +141 -0
  228. package/src/web/sw.js +1 -1
  229. package/src/backends/mlx/memory-guard.ts +0 -40
  230. package/src/spec/prefix-state.ts +0 -45
  231. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -1,4 +1,5 @@
1
- import { leaseCacheState } from "./backends/mlx/state-views";
1
+ import { type CheckpointAttachment, materializeCheckpoint, attachmentBytes, cloneAttachments, disposeAttachments } from "./backends/mlx/checkpoint-state";
2
+ import { leaseCacheState, minimumReusableOffset } from "./backends/mlx/state-views";
2
3
  import { withResource, cleanupFailure, disposeResources, ownResource } from "./engine/resources";
3
4
  // Byte-capped LRU prompt cache — the RAM tier of the Layer-0 KV store.
4
5
  //
@@ -10,24 +11,22 @@ import { withResource, cleanupFailure, disposeResources, ownResource } from "./e
10
11
  // const hit = cache.take(promptIds); // longest-prefix match
11
12
  // generate(model, promptIds, { cache: hit?.caches ?? fresh })
12
13
  // cache.put([...promptIds, ...generated], caches, ns, hit?.retain);
13
- // take() is NON-CONSUMING for trimmable caches: it serves zero-copy clones
14
- // and leaves the donor in place, so N agents can share one prefill.
15
- // Qwen hybrid entries containing recurrent SSM state transfer ownership on
16
- // an exact hit, avoiding an extra resident recurrent snapshot. put() supersedes
17
- // same-ns prefix-ancestors (when the new entry is trimmable) so a conversation
18
- // stays one entry, not one per turn.
14
+ // take() lends zero-copy views and leaves every RAM donor in place, including
15
+ // recurrent and method state. SSD persistence runs independently of RAM reuse.
16
+ // put() supersedes same-ns prefix-ancestors when the new entry is trimmable.
19
17
  //
20
18
  // TIERING (Layer 0, unified-engine plan): when a ColdTier is attached,
21
19
  // take() itself runs the two-tier dance — RAM peek vs cold find, restore
22
- // (zero-copy mmap) + trim when the cold tier holds a strictly longer
20
+ // (streamed copy) + trim when the cold tier holds a strictly longer
23
21
  // prefix — so EVERY consumer (the serial lane, the batch scheduler, future
24
22
  // prefix sharing) gets SSD restores through the same take()/put() it
25
23
  // already calls. Eviction spills to the tier (the #spill hook); idle
26
- // entries DEMOTE to it (demoteIdle — free the GPU memory, keep the prefix
27
- // reachable); onPut lets the server schedule its debounced write-behind
28
- // snapshot for both lanes.
24
+ // entries demote to it when policy permits. TieredPromptCache owns queued
25
+ // persistence and promotes restored prefixes into RAM.
29
26
 
30
27
  import type { Cache } from "./model/gemma4";
28
+ import type { PrefixCache, PrefixCacheHit } from "./contracts/prefix-cache";
29
+ import { LruRetention, type RetentionPolicy, type RetentionCandidate } from "./storage/retention-policy";
31
30
  import { cloneKvCaches } from "./kv-store";
32
31
 
33
32
  /** Reference-counted release: wraps an entry's `retain` (e.g. an mmap
@@ -65,13 +64,17 @@ export interface ColdTier {
65
64
  /** Longest stored usable prefix for prompt/ns — index-only, no I/O.
66
65
  * `handle` is the tier's opaque entry token, passed back to restore. */
67
66
  find(prompt: number[], ns: string): { prefixLen: number; handle: unknown } | null;
68
- /** Materialize a found entry as GPU-visible caches (zero-copy COW mmap;
69
- * pages fault in lazily). `retain` must run after the caches are
70
- * disposed (it unmaps the backing file). Null on any failure. */
71
- restore(handle: unknown): { tokens: number[]; caches: Cache[]; retain: () => void } | null;
67
+ /** Restore GPU-visible state. The default SSD backend copies into owned
68
+ * arrays. An optional backing release runs after consumers dispose their
69
+ * arrays. Null on any failure. */
70
+ restore(handle: unknown): { tokens: number[]; caches: Cache[]; attachments?: CheckpointAttachment[]; retain: () => void } | null;
71
+ findExact?(tokens: number[], ns: string): { prefixLen: number; handle: unknown } | null;
72
+ restoreAsync?(handle: unknown): Promise<ReturnType<ColdTier["restore"]>>;
73
+ /** Tensor objects have no target layers; restore with their own schema. */
74
+ restoreObjectAsync?(handle: unknown): Promise<ReturnType<ColdTier["restore"]>>;
72
75
  /** Borrow live caches and finish persistence before returning. A false
73
- * result prevents a required demotion or recurrent ownership transfer. */
74
- store(tokens: number[], caches: Cache[], ns: string): boolean | void;
76
+ * result prevents a required demotion. */
77
+ store(tokens: number[], caches: Cache[], ns: string, attachments?: CheckpointAttachment[]): boolean | void;
75
78
  }
76
79
 
77
80
  /** Spill sink for eviction/demotion (write-behind, 2026-07-06).
@@ -95,9 +98,7 @@ export interface SpillSink {
95
98
  spillSync?: (entry: PromptCacheEntry) => void;
96
99
  }
97
100
 
98
- export interface PromptCacheEntry {
99
- tokens: number[];
100
- caches: Cache[];
101
+ export interface PromptCacheEntry extends PrefixCacheHit<Cache[], CheckpointAttachment[]> {
101
102
  /** Namespace key — adapter spec for LoRA requests ("" = base model).
102
103
  * KV computed under one adapter must never seed another's prefill. */
103
104
  ns: string;
@@ -124,7 +125,7 @@ export function commonPrefixLength(a: number[], b: number[]): number {
124
125
  return i;
125
126
  }
126
127
 
127
- interface EntryRecord {
128
+ interface EntryRecord extends RetentionCandidate {
128
129
  entry: PromptCacheEntry;
129
130
  bytes: number;
130
131
  lastUsed: number;
@@ -132,12 +133,98 @@ interface EntryRecord {
132
133
  /** Ref-counted retain: the donor holds one share (entry.retain); every
133
134
  * clone handed out by take() holds another. */
134
135
  share: { acquire(): () => void };
136
+ resident: boolean;
137
+ sessions: number;
135
138
  }
136
139
 
137
- export class PromptCache {
140
+ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]> {
141
+ /** Exact tensor objects share this store's byte budget, eviction and writer.
142
+ * Empty token history denotes no conversation; the private namespace is the
143
+ * complete object identity. No fabricated language tokens enter the index. */
144
+ readonly #objects: import("./contracts/object-cache").ObjectCache<CheckpointAttachment[]> = {
145
+ take: key => this.#takeObject(`object:${key}`),
146
+ put: (key, value) => this.put([], [], `object:${key}`, undefined, value),
147
+ };
148
+
149
+ get objects(): import("./contracts/object-cache").ObjectCache<CheckpointAttachment[]> | undefined {
150
+ return this.maxBytes > 0 || this.#cold ? this.#objects : undefined;
151
+ }
152
+
153
+ async #takeObject(ns: string): Promise<{ value: CheckpointAttachment[]; dispose(): void } | null> {
154
+ let entry = this.findExact([], ns);
155
+ if (entry) this.objectHits++;
156
+ else this.objectMisses++;
157
+ if (!entry) {
158
+ const cold = this.#cold;
159
+ const hit = cold?.findExact?.([], ns);
160
+ if (!hit || !cold?.restoreObjectAsync) return null;
161
+ const epoch = this.#epoch;
162
+ const loaded = await cold.restoreObjectAsync(hit.handle);
163
+ if (!loaded) return null;
164
+ this.objectRestores++;
165
+ if (epoch !== this.#epoch) { this.#disposeEntry({ ...loaded, ns }, false); return null; }
166
+ // Another request may have published this immutable object during IO.
167
+ entry = this.findExact([], ns);
168
+ if (entry) this.#disposeEntry({ ...loaded, ns }, false);
169
+ else {
170
+ this.#publishingPrefetch = true;
171
+ try { this.put([], loaded.caches, ns, loaded.retain, loaded.attachments); }
172
+ catch (error) { this.#disposeEntry({ ...loaded, ns }, false); throw error; }
173
+ finally { this.#publishingPrefetch = false; }
174
+ entry = this.findExact([], ns);
175
+ }
176
+ }
177
+ if (!entry) return null;
178
+ const borrowed = this.#borrow(this.#records.get(entry)!, 0, ns);
179
+ try { this.evictToBudget(); this.reclaim(); }
180
+ catch (error) { this.#disposeEntry(borrowed, false); throw error; }
181
+ let released = false;
182
+ return { value: borrowed.attachments ?? [], dispose: () => {
183
+ if (released) return; released = true;
184
+ this.#disposeEntry(borrowed, false);
185
+ } };
186
+ }
187
+
138
188
  readonly maxBytes: number;
139
189
  #entries: EntryRecord[] = [];
140
190
  #clock = 0;
191
+ #sessions = new Map<string, Map<string, EntryRecord>>();
192
+ #records = new WeakMap<PromptCacheEntry, EntryRecord>();
193
+ sessionHits = 0;
194
+ sessionMisses = 0;
195
+ prefixScans = 0;
196
+ objectHits = 0;
197
+ objectMisses = 0;
198
+ objectRestores = 0;
199
+
200
+ closeSession(sessionId: string): void {
201
+ for (const record of this.#sessions.get(sessionId)?.values() ?? []) record.sessions--;
202
+ this.#sessions.delete(sessionId);
203
+ }
204
+
205
+ #associate(sessionId: string | undefined, ns: string, record: EntryRecord): void {
206
+ if (!sessionId) return;
207
+ let entries = this.#sessions.get(sessionId);
208
+ if (!entries) this.#sessions.set(sessionId, entries = new Map());
209
+ const old = entries.get(ns);
210
+ if (old === record) return;
211
+ if (old) old.sessions--;
212
+ entries.set(ns, record); record.sessions++;
213
+ }
214
+
215
+ #sessionRecord(prompt: number[], ns: string, sessionId?: string): EntryRecord | undefined {
216
+ const record = sessionId ? this.#sessions.get(sessionId)?.get(ns) : undefined;
217
+ // A session labels affinity. Full-history callers may edit or branch;
218
+ // incompatible history uses ordinary lookup, never a request refusal.
219
+ return record && record.entry.tokens.length < prompt.length &&
220
+ commonPrefixLength(record.entry.tokens, prompt) === record.entry.tokens.length ? record : undefined;
221
+ }
222
+
223
+ #coldHit(prompt: number[], ns: string, sessionId?: string) {
224
+ const known = this.#sessionRecord(prompt, ns, sessionId);
225
+ return (known && this.#cold?.findExact?.(known.entry.tokens, ns)) || this.#cold?.find(prompt, ns) || null;
226
+ }
227
+
141
228
  hits = 0;
142
229
  misses = 0;
143
230
  demotions = 0;
@@ -148,9 +235,98 @@ export class PromptCache {
148
235
  readonly #spillSync: ((entry: PromptCacheEntry) => void) | null;
149
236
  /** Optional cold tier: take() tiers over it, demoteIdle() spills into it. */
150
237
  readonly #cold: ColdTier | null;
151
- /** Fired after every successful put() — the server hangs its debounced
152
- * write-behind SSD snapshot here so BOTH lanes' entries persist. */
238
+ /** Observer after publication, independent of persistence policy. */
153
239
  onPut: ((tokens: number[], ns: string) => void) | null = null;
240
+ /** The backend measures pressure; this store chooses which optional
241
+ * snapshots to release. No storage I/O runs at a reclaim boundary. */
242
+ pressure: { overBudget(): boolean; releasePending?(): void } | null = null;
243
+ /** Tier policy: a pending write is not a lower-tier copy. This controls
244
+ * eviction only; persistence completion does not itself remove an entry. */
245
+ canEvict: ((entry: PromptCacheEntry) => boolean) | null = null;
246
+ protected promoteRestores = false;
247
+ retention: RetentionPolicy = new LruRetention();
248
+ #prefetches = new Map<unknown, Promise<PromptCacheEntry | null>>();
249
+ #pins = new Map<PromptCacheEntry, number>();
250
+ #publishingPrefetch = false;
251
+ #epoch = 0;
252
+
253
+ /** Start cold reads during request preparation, coalescing identical IO.
254
+ * Active request interest keeps a just-restored donor resident until the
255
+ * caller has consumed it. Cancellation releases interest through the lease. */
256
+ async prefetch(prompt: number[], ns = "", sessionId?: string): Promise<() => void> {
257
+ const known = this.#sessionRecord(prompt, ns, sessionId);
258
+ if (known?.resident) return this.#pin(known.entry);
259
+ const cold = this.#cold;
260
+ const hit = cold?.restoreAsync ? this.#coldHit(prompt, ns, sessionId) : null;
261
+ if (!cold?.restoreAsync || !hit || (!known && hit.prefixLen <= this.peekPrefixLen(prompt, ns))) return () => {};
262
+ let pending = this.#prefetches.get(hit.handle);
263
+ if (!pending) {
264
+ const epoch = this.#epoch;
265
+ pending = (async () => {
266
+ const loaded = await cold.restoreAsync!(hit.handle);
267
+ if (!loaded) return null;
268
+ if (epoch !== this.#epoch) { this.#disposeEntry({ ...loaded, ns }, false); return null; }
269
+ this.#publishingPrefetch = true;
270
+ try { this.put(loaded.tokens, loaded.caches, ns, loaded.retain, loaded.attachments); }
271
+ catch (error) { this.#disposeEntry({ ...loaded, ns }, false); throw error; }
272
+ finally { this.#publishingPrefetch = false; }
273
+ return this.findExact(loaded.tokens, ns);
274
+ })();
275
+ this.#prefetches.set(hit.handle, pending);
276
+ }
277
+ let entry: PromptCacheEntry | null;
278
+ try { entry = await pending; }
279
+ finally { if (this.#prefetches.get(hit.handle) === pending) this.#prefetches.delete(hit.handle); }
280
+ if (!entry) return () => {};
281
+ this.#associate(sessionId, ns, this.#records.get(entry)!);
282
+ return this.#pin(entry);
283
+ }
284
+
285
+ #pin(entry: PromptCacheEntry): () => void {
286
+ this.#pins.set(entry, (this.#pins.get(entry) ?? 0) + 1);
287
+ let released = false;
288
+ return () => {
289
+ if (released) return; released = true;
290
+ const count = this.#pins.get(entry!) ?? 1;
291
+ if (count === 1) this.#pins.delete(entry!); else this.#pins.set(entry!, count - 1);
292
+ this.evictToBudget(); this.reclaim();
293
+ };
294
+ }
295
+
296
+ #victim(): EntryRecord | undefined {
297
+ const candidates = this.#entries.filter(record => !this.#pins.has(record.entry));
298
+ const unrelated = candidates.filter(record => record.sessions === 0);
299
+ return candidates.length ? this.retention.victim(unrelated.length ? unrelated : candidates) : undefined;
300
+ }
301
+
302
+ evictToBudget(): void {
303
+ if (this.#publishingPrefetch) return;
304
+ while (this.totalBytes > this.maxBytes) {
305
+ if (!this.#entries.length) break;
306
+ const oldest = this.#victim();
307
+ if (!oldest) break;
308
+ if (this.canEvict && !this.canEvict(oldest.entry)) break;
309
+ this.retention.evicted(oldest);
310
+ this.#entries = this.#entries.filter(r => r !== oldest);
311
+ try { this.#disposeEntry(oldest.entry, !this.canEvict); }
312
+ catch (error) { console.warn(`[prompt-cache] entry cleanup failed: ${error}`); }
313
+ }
314
+ }
315
+
316
+ reclaim(): void {
317
+ const pressure = this.pressure;
318
+ if (!pressure?.overBudget()) return;
319
+ pressure.releasePending?.();
320
+ while (this.#entries.length && pressure.overBudget()) {
321
+ const oldest = this.#victim();
322
+ if (!oldest) break;
323
+ if (this.canEvict && !this.canEvict(oldest.entry)) break;
324
+ this.retention.evicted(oldest);
325
+ this.#entries = this.#entries.filter(r => r !== oldest);
326
+ this.#disposeEntry(oldest.entry, false);
327
+ this.demotions++;
328
+ }
329
+ }
154
330
 
155
331
  /** Zero-copy view cloner — injectable so model-free tests can stub it. */
156
332
  readonly #clone: (caches: Cache[]) => Cache[];
@@ -161,6 +337,7 @@ export class PromptCache {
161
337
  spill: SpillSink | ((entry: PromptCacheEntry) => void) | null = null,
162
338
  cold: ColdTier | null = null,
163
339
  clone: (caches: Cache[]) => Cache[] = cloneKvCaches,
340
+ private readonly materialize: (caches: Cache[], attachments?: CheckpointAttachment[]) => void = materializeCheckpoint,
164
341
  ) {
165
342
  this.maxBytes = maxBytes;
166
343
  this.#spillOwned = typeof spill === "function" ? null : spill?.spillOwned ?? null;
@@ -170,6 +347,8 @@ export class PromptCache {
170
347
  }
171
348
 
172
349
  #disposeEntry(entry: PromptCacheEntry, spillFirst: boolean): void {
350
+ const record = this.#records.get(entry);
351
+ if (record) record.resident = false;
173
352
  if (spillFirst) {
174
353
  if (this.#spillOwned) {
175
354
  // NON-BLOCKING spill: clone BEFORE disposing (clones are zero-copy
@@ -185,23 +364,26 @@ export class PromptCache {
185
364
  // no backing mmap for retain to unmap, so nothing the clones
186
365
  // could alias goes away.
187
366
  let clones: Cache[] | null = null;
367
+ let attachments: CheckpointAttachment[] | undefined;
188
368
  try {
189
369
  clones = this.#clone(entry.caches);
190
- this.#spillOwned({ tokens: [...entry.tokens], caches: clones, ns: entry.ns });
370
+ attachments = cloneAttachments(entry.attachments);
371
+ this.#spillOwned({ tokens: [...entry.tokens], caches: clones, ns: entry.ns, attachments });
191
372
  } catch {
192
373
  // Failure degrades to no-spill; eviction/demotion never unwinds.
193
374
  if (clones) for (const c of clones) try { c.dispose(); } catch {}
375
+ try { disposeAttachments(attachments); } catch {}
194
376
  }
195
377
  } else {
196
378
  const spill =
197
379
  this.#spillSync ??
198
380
  (this.#cold
199
- ? (e: PromptCacheEntry) => this.#cold!.store(e.tokens, e.caches, e.ns)
381
+ ? (e: PromptCacheEntry) => this.#cold!.store(e.tokens, e.caches, e.ns, e.attachments)
200
382
  : null);
201
383
  if (spill) try { spill(entry); } catch {}
202
384
  }
203
385
  }
204
- disposeResources(entry.caches);
386
+ disposeResources([...entry.caches, { dispose: () => disposeAttachments(entry.attachments) }]);
205
387
  entry.retain?.(); // backing release requires successful cache disposal
206
388
  }
207
389
 
@@ -220,16 +402,17 @@ export class PromptCache {
220
402
  relievePressure(overBudget: () => boolean): number {
221
403
  let count = 0;
222
404
  while (this.#entries.length && overBudget()) {
223
- let oldest = 0;
224
- for (let i = 1; i < this.#entries.length; i++)
225
- if (this.#entries[i]!.lastUsed < this.#entries[oldest]!.lastUsed) oldest = i;
226
- const { entry } = this.#entries[oldest]!;
405
+ const victim = this.#victim();
406
+ if (!victim) break;
407
+ const oldest = this.#entries.indexOf(victim);
408
+ const { entry } = victim;
227
409
  // Keep the entry resident if persistence throws. A failed SSD write
228
410
  // must not be presented as a successful demotion.
229
411
  if (this.#cold) {
230
- if (this.#cold.store(entry.tokens, entry.caches, entry.ns) === false) break;
412
+ if (this.#cold.store(entry.tokens, entry.caches, entry.ns, entry.attachments) === false) break;
231
413
  }
232
414
  else this.#spillSync?.(entry);
415
+ this.retention.evicted(victim);
233
416
  this.#entries.splice(oldest, 1);
234
417
  this.#disposeEntry(entry, false);
235
418
  this.demotions++;
@@ -239,9 +422,9 @@ export class PromptCache {
239
422
  }
240
423
 
241
424
  /** Find the entry with the longest usable common prefix of `prompt`
242
- * across BOTH tiers. Trimmable RAM entries are served non-consumingly as
243
- * zero-copy clones; exact hits on Qwen hybrid entries containing recurrent
244
- * SSM state transfer the donor itself. The caller owns the returned caches —
425
+ * across BOTH tiers. Every RAM entry lends zero-copy views and retains its
426
+ * donor, including exact-boundary recurrent state and method attachments.
427
+ * The caller owns the returned views —
245
428
  * dispose them or put() an extended entry, honoring `retain` either way.
246
429
  *
247
430
  * Usable prefix = common prefix capped at prompt.length - 1 (at least
@@ -250,21 +433,39 @@ export class PromptCache {
250
433
  * wrapped — those entries only match in full.
251
434
  *
252
435
  * Tier order: the cold tier wins only with a STRICTLY longer usable
253
- * prefix (its restore costs a mmap + lazy fault-in; RAM is free). A
436
+ * prefix (restore reads storage and materializes native arrays). A
254
437
  * cold entry with an untrimmable divergent tail (ring post-wrap, SSM)
255
438
  * is dropped and the RAM candidate (or a fresh prefill) serves.
256
439
  * hits/misses count RAM candidacy only (cold restores are counted by
257
440
  * the tier itself), preserving the /stats meaning. */
258
- take(prompt: number[], ns = ""): PromptCacheEntry | null {
441
+ take(prompt: number[], ns = "", sessionId?: string): PromptCacheEntry | null {
442
+ const hit = this.#take(prompt, ns, sessionId);
443
+ // Keep the chosen state's owned views before releasing optional donors.
444
+ // This also covers a cold restore materializing its bytes at admission.
445
+ try { this.reclaim(); return hit; }
446
+ catch (error) {
447
+ if (hit) this.#disposeEntry(hit, false);
448
+ throw error;
449
+ }
450
+ }
451
+
452
+ #take(prompt: number[], ns: string, sessionId?: string): PromptCacheEntry | null {
453
+ const known = this.#sessionRecord(prompt, ns, sessionId);
454
+ if (known?.resident) {
455
+ this.sessionHits++; this.hits++;
456
+ return this.#borrow(known, known.entry.tokens.length, ns);
457
+ }
458
+ if (sessionId) this.sessionMisses++;
459
+ this.prefixScans++;
259
460
  let bestIdx = -1;
260
461
  let bestLen = 0;
261
462
  for (let i = 0; i < this.#entries.length; i++) {
262
463
  const e = this.#entries[i]!.entry;
263
464
  if (e.ns !== ns) continue;
264
465
  const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
265
- if (p <= bestLen) continue;
466
+ if (p <= bestLen || p < minimumReusableOffset(e.caches)) continue;
266
467
  const trimNeeded = e.tokens.length - p;
267
- if (trimNeeded > 0 && !e.caches.every((c) => c.isTrimmable())) continue;
468
+ if (trimNeeded > 0 && (!!e.attachments?.length || !e.caches.every((c) => c.isTrimmable()))) continue;
268
469
  bestLen = p;
269
470
  bestIdx = i;
270
471
  }
@@ -275,7 +476,7 @@ export class PromptCache {
275
476
  // candidate stays put UNTOUCHED (the old serial-lane flow trimmed it
276
477
  // before comparing — a needless entry degradation, gone now).
277
478
  if (this.#cold) {
278
- const hit = this.#cold.find(prompt, ns);
479
+ const hit = this.#coldHit(prompt, ns, sessionId);
279
480
  if (hit && hit.prefixLen > bestLen) {
280
481
  const loaded = this.#cold.restore(hit.handle);
281
482
  if (loaded) {
@@ -283,7 +484,8 @@ export class PromptCache {
283
484
  this.#disposeEntry({ ...entry, ns }, false));
284
485
  const result = withResource(owner, (entry) => {
285
486
  const trimNeeded = entry.tokens.length - hit.prefixLen;
286
- if (trimNeeded > 0 && !entry.caches.every((cache) => cache.isTrimmable())) {
487
+ if (hit.prefixLen < minimumReusableOffset(entry.caches)) return null;
488
+ if (trimNeeded > 0 && (!!entry.attachments?.length || !entry.caches.every((cache) => cache.isTrimmable()))) {
287
489
  console.warn(
288
490
  `[prompt-cache] cold entry unusable: ${entry.tokens.length} tokens, ` +
289
491
  `needs trim ${trimNeeded} but untrimmable (wrapped ring/SSM) — re-prefilling`,
@@ -294,7 +496,27 @@ export class PromptCache {
294
496
  const tokens = entry.tokens.slice(0, hit.prefixLen);
295
497
  return { ...owner.transfer(), tokens, ns };
296
498
  });
297
- if (result) return result;
499
+ if (result) {
500
+ if (this.promoteRestores) {
501
+ // Cache the restored prefix immediately. A second request can
502
+ // reuse RAM even before this borrower finishes its generation.
503
+ const share = makeSharedRetain(result.retain);
504
+ result.retain = share.acquire();
505
+ const retained = share.acquire();
506
+ let clones: Cache[] = [];
507
+ let attachments: CheckpointAttachment[] | undefined;
508
+ try {
509
+ clones = this.#clone(result.caches);
510
+ attachments = cloneAttachments(result.attachments);
511
+ this.put([...result.tokens], clones, ns, retained, attachments, sessionId);
512
+ } catch (error) {
513
+ cleanupFailure(error, () => disposeResources([...clones,
514
+ { dispose: () => disposeAttachments(attachments) },
515
+ { dispose: retained }, { dispose: () => this.#disposeEntry(result, false) }]));
516
+ }
517
+ }
518
+ return result;
519
+ }
298
520
  }
299
521
  }
300
522
  }
@@ -310,30 +532,30 @@ export class PromptCache {
310
532
  // consume-and-trim semantics CANNIBALIZED donors: agent B borrowing
311
533
  // agent A's 2k system prompt destroyed A's 10k entry to do it.
312
534
  const rec = this.#entries[bestIdx]!;
535
+ this.#associate(sessionId, ns, rec);
536
+ return this.#borrow(rec, bestLen, ns);
537
+ }
538
+
539
+ #borrow(rec: EntryRecord, bestLen: number, ns: string): PromptCacheEntry {
313
540
  rec.lastUsed = ++this.#clock;
314
541
  rec.lastUsedMs = Date.now();
315
- // Qwen recurrent caches are untrimmable. Transfer the exact-boundary
316
- // entry instead of retaining a second recurrent snapshot in RAM.
317
- // This reduced memory pressure in the recorded multi-turn reproduction.
318
- // Save the boundary before consumption: the extended recurrent state
319
- // cannot be trimmed back, and a delayed writer can no longer find it
320
- // in RAM after ownership moves. An already durable boundary is cheap.
321
- if (rec.entry.caches.some((c) => c.signature() === "ssm")) {
322
- if (this.#cold?.store(rec.entry.tokens, rec.entry.caches, ns) === false)
323
- throw new Error("prompt-cache: failed to persist recurrent boundary before transfer");
324
- this.#entries.splice(bestIdx, 1);
325
- return rec.entry;
326
- }
542
+ rec.uses++; rec.cost = bestLen; this.retention.accessed(rec);
543
+ // Lend immutable views for every backend state. RAM retention and SSD
544
+ // durability remain cache responsibilities while the caller extends its
545
+ // own views. This keeps synchronous persistence out of a RAM hit.
327
546
  const clones = this.#clone(rec.entry.caches);
547
+ let attachments: CheckpointAttachment[] | undefined;
328
548
  const trimNeeded = rec.entry.tokens.length - bestLen;
329
549
  try {
550
+ attachments = cloneAttachments(rec.entry.attachments);
330
551
  if (trimNeeded > 0) for (const c of clones) c.trim(trimNeeded);
331
552
  } catch (error) {
332
- cleanupFailure(error, () => disposeResources(clones));
553
+ cleanupFailure(error, () => disposeResources([...clones, { dispose: () => disposeAttachments(attachments) }]));
333
554
  }
334
555
  return {
335
556
  tokens: rec.entry.tokens.slice(0, bestLen),
336
557
  caches: clones,
558
+ attachments,
337
559
  ns,
338
560
  retain: rec.share.acquire(),
339
561
  };
@@ -346,17 +568,15 @@ export class PromptCache {
346
568
  for (const { entry: e } of this.#entries) {
347
569
  if (e.ns !== ns) continue;
348
570
  const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
349
- if (p <= best) continue;
571
+ if (p <= best || p < minimumReusableOffset(e.caches)) continue;
350
572
  const trimNeeded = e.tokens.length - p; // same usability rule as take()
351
- if (trimNeeded > 0 && !e.caches.every((c) => c.isTrimmable())) continue;
573
+ if (trimNeeded > 0 && (!!e.attachments?.length || !e.caches.every((c) => c.isTrimmable()))) continue;
352
574
  best = p;
353
575
  }
354
576
  return best;
355
577
  }
356
578
 
357
- /** Read-only exact-token lookup (the write-behind snapshot path: the
358
- * entry stays owned by the cache; the caller only reads array state,
359
- * under the gateway lock so no generation is mutating it). */
579
+ /** Read-only lookup of an immutable, cache-owned checkpoint. */
360
580
  findExact(tokens: number[], ns = ""): PromptCacheEntry | null {
361
581
  for (const { entry: e } of this.#entries) {
362
582
  if (e.ns !== ns || e.tokens.length !== tokens.length) continue;
@@ -368,10 +588,12 @@ export class PromptCache {
368
588
  /** Insert (or reinsert) an entry; evicts LRU entries over the byte cap
369
589
  * (spilling each to the cold tier first, when one is attached). If the
370
590
  * entry itself exceeds the cap it is spilled + disposed, not stored. */
371
- put(tokens: number[], caches: Cache[], ns = "", retain?: () => void): void {
591
+ put(tokens: number[], caches: Cache[], ns = "", retain?: () => void, attachments?: CheckpointAttachment[], sessionId?: string): void {
372
592
  // All cache-provided validation runs before ownership is adopted.
373
- const bytes = cacheBytes(caches);
374
- const trimmable = caches.every((cache) => cache.isTrimmable());
593
+ const bytes = cacheBytes(caches) + attachmentBytes(attachments);
594
+ const trimmable = !attachments?.length && caches.every((cache) => cache.isTrimmable());
595
+ // Storage takes resolved snapshots, independently of their producer.
596
+ this.materialize(caches, attachments);
375
597
  const discard = (entry: PromptCacheEntry, spill: boolean) => {
376
598
  try { this.#disposeEntry(entry, spill); }
377
599
  catch (error) {
@@ -380,8 +602,8 @@ export class PromptCache {
380
602
  console.warn(`[prompt-cache] entry cleanup failed: ${error}`);
381
603
  }
382
604
  };
383
- const entry: PromptCacheEntry = { tokens, caches, ns, retain };
384
- if (bytes > this.maxBytes) {
605
+ const entry: PromptCacheEntry = { tokens, caches, ns, retain, attachments };
606
+ if (bytes > this.maxBytes && !this.canEvict) {
385
607
  discard(entry, true);
386
608
  return;
387
609
  }
@@ -389,8 +611,11 @@ export class PromptCache {
389
611
  // shares; the entry's own retain is the donor's share.
390
612
  const share = makeSharedRetain(retain);
391
613
  entry.retain = share.acquire();
392
- const rec: EntryRecord = { entry, bytes, lastUsed: ++this.#clock, lastUsedMs: Date.now(), share };
614
+ const rec: EntryRecord = { entry, bytes, cost: tokens.length, uses: 1, priority: 0, lastUsed: ++this.#clock, lastUsedMs: Date.now(), share, resident: true, sessions: 0 };
615
+ this.retention.accessed(rec);
393
616
  this.#entries.push(rec);
617
+ this.#records.set(entry, rec);
618
+ this.#associate(sessionId, ns, rec);
394
619
  // Exact duplicates (equal tokens) are redundant REGARDLESS of
395
620
  // trimmability — the new entry serves exactly the matches the old one
396
621
  // did (mlx-lm's trie replaces in place). Without this, every gemma ctx
@@ -400,6 +625,12 @@ export class PromptCache {
400
625
  if (old === rec || old.entry.ns !== ns) continue;
401
626
  if (old.entry.tokens.length !== tokens.length) continue;
402
627
  if (commonPrefixLength(old.entry.tokens, tokens) !== tokens.length) continue;
628
+ // Equivalent publication replaces the cache object, not the checkpoint
629
+ // identity. Every session sharing it follows the replacement.
630
+ if (old.sessions) for (const sessions of this.#sessions.values()) {
631
+ if (sessions.get(ns) !== old) continue;
632
+ sessions.set(ns, rec); old.sessions--; rec.sessions++;
633
+ }
403
634
  this.#entries = this.#entries.filter((r) => r !== old);
404
635
  discard(old.entry, false);
405
636
  }
@@ -415,39 +646,26 @@ export class PromptCache {
415
646
  if (trimmable) {
416
647
  for (const old of [...this.#entries]) {
417
648
  if (old === rec || old.entry.ns !== ns) continue;
418
- if (old.entry.tokens.length >= tokens.length) continue;
649
+ if (old.entry.tokens.length >= tokens.length || old.entry.tokens.length < minimumReusableOffset(caches)) continue;
419
650
  if (commonPrefixLength(old.entry.tokens, tokens) !== old.entry.tokens.length) continue;
420
651
  this.#entries = this.#entries.filter((r) => r !== old);
421
652
  discard(old.entry, false);
422
653
  }
423
654
  }
424
- while (this.totalBytes > this.maxBytes && this.#entries.length > 1) {
425
- let lruIdx = 0;
426
- for (let i = 1; i < this.#entries.length; i++)
427
- if (this.#entries[i]!.lastUsed < this.#entries[lruIdx]!.lastUsed) lruIdx = i;
428
- const [evicted] = this.#entries.splice(lruIdx, 1);
429
- discard(evicted!.entry, true);
430
- }
655
+ this.evictToBudget();
431
656
  try { this.onPut?.(tokens, ns); } catch {}
432
657
  }
433
658
 
434
- /** Layer-0 idle demotion: spill every entry unused for `idleMs` to the
435
- * cold tier and FREE its GPU memory. The prefix stays reachable — the
436
- * next take() restores it via zero-copy mmap (~0.25 s for a 13.7k-token
437
- * entry, vs a 12 s re-prefill) — but between bursts the RAM tier drains
438
- * toward empty, returning unified memory to the system. Caller must
439
- * hold the generation lock (entries' arrays are disposed here) — with
440
- * a spillOwned sink the lock covers only the zero-copy clone; the
441
- * write runs off-lock and the buffers free when it settles. No-op
442
- * without a cold tier — demotion without a place to demote TO would
443
- * just be data loss. Returns entries demoted. */
659
+ /** Evict entries older than idleMs. Tiered residency policy first ensures
660
+ * SSD coverage. No-op without a cold tier. Returns entries removed from RAM. */
444
661
  demoteIdle(idleMs: number, now = Date.now()): number {
445
662
  if (!this.#cold) return 0;
446
663
  let n = 0;
447
664
  for (const rec of [...this.#entries]) {
448
- if (now - rec.lastUsedMs < idleMs) continue;
665
+ if (now - rec.lastUsedMs < idleMs || this.#pins.has(rec.entry)) continue;
666
+ if (this.canEvict && !this.canEvict(rec.entry)) continue;
449
667
  this.#entries = this.#entries.filter((r) => r !== rec);
450
- this.#disposeEntry(rec.entry, true); // spill-first, then dispose
668
+ this.#disposeEntry(rec.entry, !this.canEvict); // persisted entries need no duplicate write
451
669
  this.demotions++;
452
670
  n++;
453
671
  }
@@ -455,6 +673,8 @@ export class PromptCache {
455
673
  }
456
674
 
457
675
  clear(): void {
676
+ this.#sessions.clear();
677
+ this.#epoch++;
458
678
  const entries = this.#entries;
459
679
  this.#entries = [];
460
680
  disposeResources(entries.map(({ entry }) => ({ dispose: () => this.#disposeEntry(entry, false) })));
@@ -52,11 +52,7 @@ export function runtimeValue(name: RuntimeKey): string | undefined {
52
52
  return runtimeConfig().value(name);
53
53
  }
54
54
 
55
- /** On/off env flag: an explicit "1"/"0" always wins; otherwise `defaultOn`.
56
- * (Formerly src/flags.ts `flagOn` — same resolver, one module.) */
57
- export function flagOn(name: RuntimeKey, defaultOn: boolean): boolean {
58
- return runtimeFlag(name, defaultOn);
59
- }
55
+ /** An explicit "1"/"0" wins; other values retain the caller's default. */
60
56
  export function runtimeFlag(name: RuntimeKey, defaultOn: boolean): boolean {
61
57
  return runtimeConfig().flag(name, defaultOn);
62
58
  }
@@ -127,8 +127,15 @@ export class ShardedSafetensors {
127
127
  self.tensorToFile.set(tensor, sf);
128
128
  }
129
129
  } else {
130
- const sf = SafetensorsFile.open(`${modelDir}/model.safetensors`);
131
- self.files.set("model.safetensors", sf);
130
+ // Single-file artifacts: HF's model.safetensors, or mlx-whisper's
131
+ // weights.safetensors (mlx-community/whisper-*).
132
+ const file = (await Bun.file(`${modelDir}/model.safetensors`).exists())
133
+ ? "model.safetensors"
134
+ : (await Bun.file(`${modelDir}/weights.safetensors`).exists())
135
+ ? "weights.safetensors"
136
+ : "model.safetensors";
137
+ const sf = SafetensorsFile.open(`${modelDir}/${file}`);
138
+ self.files.set(file, sf);
132
139
  for (const name of sf.tensors.keys()) self.tensorToFile.set(name, sf);
133
140
  }
134
141
  return self;