@qvac/harness 0.2.4 → 0.2.6

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 (35) hide show
  1. package/.claude/skills/asana/SKILL.md +86 -0
  2. package/.claude/skills/gh-stack/SKILL.md +181 -0
  3. package/.claude/skills/gh-stack/references/commands.md +177 -0
  4. package/.claude/skills/gh-stack/references/stack-design.md +97 -0
  5. package/.claude/skills/gh-stack/references/troubleshooting.md +159 -0
  6. package/.claude/skills/js-house-style/SKILL.md +650 -0
  7. package/.claude/skills/kvcache/SKILL.md +171 -0
  8. package/.claude/skills/pr-create/SKILL.md +137 -0
  9. package/.claude/skills/pr-review/SKILL.md +148 -0
  10. package/CLAUDE.md +36 -0
  11. package/dist/harness.bundle +23 -14
  12. package/dist-lib/index.d.ts +6 -1
  13. package/dist-lib/index.js +27 -9
  14. package/dist-lib/lib/hardware/capability-probe.d.ts +1 -1
  15. package/dist-lib/lib/hardware/capability-probe.js +1 -1
  16. package/dist-lib/lib/hardware/gpu-probe-entry.d.ts +1 -0
  17. package/dist-lib/lib/hardware/gpu-probe-entry.js +2 -0
  18. package/dist-lib/lib/hardware/gpu-probe.d.ts +8 -0
  19. package/dist-lib/lib/hardware/gpu-probe.js +54 -0
  20. package/dist-lib/lib/hardware/gpu-stats.bare.d.ts +1 -1
  21. package/dist-lib/lib/hardware/gpu-stats.bare.js +1 -24
  22. package/dist-lib/lib/hardware/gpu-stats.native.cjs +27 -0
  23. package/dist-lib/lib/hardware/gpu-stats.native.d.cts +1 -0
  24. package/dist-lib/lib/models/model-pool.d.ts +1 -1
  25. package/dist-lib/lib/models/model-pool.js +3 -2
  26. package/dist-lib/lib/spawn.d.ts +3 -0
  27. package/dist-lib/lib/spawn.js +5 -3
  28. package/dist-lib/lib/tools/exec/adapters/python/loader.d.ts +9 -3
  29. package/dist-lib/lib/tools/exec/adapters/python/loader.js +9 -6
  30. package/dist-lib/schema/generate.d.ts +6 -2
  31. package/dist-lib/schema/generate.js +10 -5
  32. package/docs/kvcache-reference.md +655 -0
  33. package/package.json +1 -1
  34. package/worker/harness-sidecar.mjs +8 -3
  35. package/worker/stow-entry.mjs +3 -19
@@ -0,0 +1,655 @@
1
+ # KV cache — reference
2
+
3
+ How the QVAC SDK's prompt cache actually works, and the rules for using it from
4
+ core-v2. Mechanics only.
5
+
6
+ > **"core-v2" is this stack**: `@qvac/harness` (completion, tools, the model
7
+ > pool — this repo) plus `@qvac/core` (chat, storage, compaction). The name
8
+ > distinguishes it from the retired v1 assistant that still sits in
9
+ > `tetherto/qvac-app`. Paths below are repo-relative when they are harness
10
+ > files, and named by package when they are not. A handful of
11
+ > `qvac-assistant/*` specifiers are the desktop app's own aliases and live in
12
+ > `tetherto/qvac-app`.
13
+
14
+ The sliding window and dynamic tools this doc once covered are retired on every
15
+ layer of the stack; what remains of them is one section,
16
+ [Retired knobs](#retired-knobs). The remaining work items this implies are the
17
+ "KV cache follow-ups" checklist in `assistant/MIGRATION.md`, which is still in
18
+ `tetherto/qvac-app`.
19
+
20
+ ## Versions
21
+
22
+ Behaviour here is version-specific and has changed more than once. Check first:
23
+
24
+ ```bash
25
+ grep '"@qvac/sdk"' package.json
26
+ grep '"version"' node_modules/@qvac/inference/package.json node_modules/@qvac/llm-llamacpp/package.json
27
+ ```
28
+
29
+ core-v2 ships **`@qvac/sdk` 0.19.0** → **`@qvac/inference` 0.19.0** →
30
+ **`@qvac/llm-llamacpp` 0.49.1** (`@qvac/harness` declares `^0.19.0` for
31
+ both packages; inference pulls `^0.49.1`). If the lockfile resolves more than
32
+ one copy, read the one `@qvac/harness` gets, not whichever is hoisted.
33
+
34
+ Since 0.19 `@qvac/sdk` is a thin re-export over `@qvac/inference`, which owns
35
+ the engine, the plugins and the cache session. The harness runs in a Bare
36
+ sidecar with the engine in-process, so its `#sdk` import seam resolves to
37
+ `@qvac/inference` there (`@qvac/sdk` only under Node) — the cache code that
38
+ executes is `@qvac/inference/dist/plugins/builtin/llamacpp-completion/ops/`.
39
+
40
+ What 0.19 changed for everything below ([qvac#4163](https://github.com/tetherto/qvac/pull/4163)):
41
+ `n_discarded` is gone from the `loadModel` schema and rejected at validation;
42
+ the `toolBlockEvictable` gate that re-sent the tool block under sliding is
43
+ deleted; a thrown `ContextOverflowError` no longer rolls the cache back; and the
44
+ error carries `requiredTokens`/`cachedTokens`/`ctxSize`, parsed from every
45
+ wording the addon emits. Addon 0.48 removed sliding outright (and
46
+ `contextSlides`), stops a generation that fills the window with
47
+ `stopReason=contextOverflow`, and rewrites reasoning compaction as rewind and
48
+ re-decode. Earlier revisions of this doc described 0.18.2 (rollback on overflow,
49
+ sliding still possible) and 0.17.x (tool block in the prime); both are history.
50
+ Read source in this order:
51
+
52
+ 1. `node_modules/@qvac/inference/dist/**` — compiled but readable, and exactly
53
+ what runs. `@qvac/sdk/dist` holds only the re-export and the RPC client.
54
+ 2. A checkout of the `qvac` monorepo (`packages/inference`,
55
+ `packages/llm-llamacpp`) — **fetch first and read `tether/main`.** A stale
56
+ checkout produced two wrong conclusions in this doc's history. The addon C++
57
+ is `packages/llm-llamacpp/addon/src/model-interface/`.
58
+ 3. Never a standalone `qvac-lib-infer-llamacpp-llm` checkout — the addon source
59
+ of record is the monorepo; standalone clones drift stale.
60
+
61
+ The live C++ class is **`MtmdLlmContext`**, not `TextLlmContext`: chat models load
62
+ with a projection model, so the multimodal context is what executes. The two are
63
+ parallel implementations.
64
+
65
+ ## The three layers
66
+
67
+ Three state sets, keyed differently, never reconciled with each other:
68
+
69
+ | Layer | Owns | Keyed by |
70
+ | ------- | -------------------------------------------------- | --------------- |
71
+ | core-v2 | the message **list** | chat |
72
+ | SDK | a message **count** (`savedCount`) | cache file path |
73
+ | addon | token **positions** (`nPast`) + the on-disk `.bin` | cache key |
74
+
75
+ `savedCount` means _"already sent"_, never _"still resident"_: nothing below the
76
+ SDK reports what it evicted. That is what made both retired knobs fatal, and it
77
+ is the test any new eviction idea has to pass.
78
+
79
+ ## The knobs
80
+
81
+ | Knob | Where | core-v2 call site |
82
+ | ----------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
83
+ | `history` | `completion()` | full history, no pre-trim |
84
+ | `kvCache: 'chat-<id>'` (a repair generation nests under it: `chat-<id>/r<n>`) | `completion()` | [assistant.ts](https://github.com/tetherto/qvac-core/blob/main/lib/assistant.ts) `_execute` → [completion.ts](../lib/capabilities/completion.ts) `completion` |
85
+
86
+ Nothing else. The model loads with `tools: true` and the cache-relevant config
87
+ (`ctx_size`, `cache-type-k/v`); every other cache behaviour is the SDK's own.
88
+
89
+ ## Retired knobs
90
+
91
+ core-v2 stopped passing both on 2026-08-20
92
+ ([2026-08-20-kvcache-static-placement](./decisions/2026-08-20-kvcache-static-placement.md)),
93
+ and upstream has since deleted them. They stay here as bug shapes, not options.
94
+
95
+ - **`n_discarded` (sliding window).** Was `floor(ctxSize / 10)` at `loadModel()`.
96
+ SDK 0.19 dropped it from the config schema, so `loadModel` rejects the key;
97
+ addon 0.48 removed sliding entirely
98
+ ([qvac#3938](https://github.com/tetherto/qvac/pull/3938)). Two present-day
99
+ defects retired it before upstream did. A slide was **invisible to the SDK**:
100
+ the session recorded a turn's full message count on success and next sent
101
+ `history.slice(savedCount)`, so a span the addon discarded in C++ was gone from
102
+ the live cache and the saved `.bin` while the app still counted it as sent —
103
+ the model silently lost the middle of the chat, with no error and no TTFT
104
+ tell. And while sliding was possible the SDK set
105
+ `toolBlockEvictable = n_discarded > 0`, which made the whole tool block travel
106
+ on every turn (~331 tokens for a 4-tool agent) instead of being cached once.
107
+ Prefill also slid exactly once and gave up, so long prompts overflowed anyway.
108
+ A full window is now a normal end state: overflow at prefill, `stopReason:
109
+ length` mid-generation, recovered by the harness ladder and by the fold.
110
+ - **`toolsMode: 'dynamic'` (dynamic tools).** SDK 0.18 deleted the key
111
+ ([qvac#3380](https://github.com/tetherto/qvac/pull/3380); the parser rejects
112
+ it, so passing it fails every load) and the addon dropped `tools_compact`
113
+ after 0.39.4 ([qvac#3373](https://github.com/tetherto/qvac/pull/3373)). It
114
+ was a silent no-op on every model we ship: the addon gated `tools_compact` on
115
+ `general.architecture == "qwen3"` exactly, our catalog is `qwen35`/`qwen36`,
116
+ so it logged `tools_compact is not supported … ignoring` and ran without
117
+ anchoring or trimming — while the SDK still sliced as-if-dynamic from the
118
+ requested flag, sending `[assistant, user]` plus the tool block and ignoring
119
+ `savedCount`. The previous answer was duplicated into the KV every turn, the
120
+ block accumulated, and on a cold cache only the last exchange was sent, so the
121
+ model confabulated instead of overflowing. Where the addon half did run it
122
+ trimmed the tool block, every tool result and the assistant's answer after a
123
+ turn, which is what the SDK's re-send assumed. Static placement is the only
124
+ mode now: `history.slice(savedCount)`, full history on a cold cache at TTFT
125
+ cost only — and that is what makes the overflow ladder effective, since
126
+ dropping oldest turns changes what the slice sends.
127
+
128
+ Each removal changed `modelConfig`, so each re-keyed `modelId` and cold-primed
129
+ every chat once. Static tool calling is unaffected by either removal.
130
+
131
+ ## Cache identity
132
+
133
+ ```
134
+ {kvCacheDir}/{cacheKey}/{modelId}/{configHash}.bin # ~/.qvac/kv-cache/...
135
+ ```
136
+
137
+ - `cacheKey` — ours: `chat-<chatId>`.
138
+ - `modelId` — see below.
139
+ - `configHash` — `sha256(canonical({ systemPrompt, tools }))[:16]`, where
140
+ `tools` is the **complete tool definitions** the call carries (names,
141
+ descriptions, parameter schemas; object keys sorted, array order kept), or
142
+ `[]` when the call has none. **A changing system prompt changes the file**,
143
+ and so does any edit to any tool — one cold prime per change. An `@skill`
144
+ mention changes it for that turn only.
145
+
146
+ Two `.bin` files under one chat key means `configHash` moved.
147
+
148
+ ### What `modelId` actually is
149
+
150
+ The SDK's `modelId` is **not** a model name — it is identity-by-config, computed
151
+ at `loadModel` (`@qvac/inference`, `dist/handlers/load-model/handler.js`,
152
+ `canonicalConfigString` + `generateShortHash`):
153
+
154
+ ```
155
+ modelId = sha256("{modelType}:{modelSrc}:{canonicalConfigString(modelConfig)}").slice(0, 16)
156
+ ```
157
+
158
+ `canonicalConfigString` recursively sorts keys, so key _order_ never matters —
159
+ but **every key-value pair in `modelConfig` does**. Two loads of the same weights
160
+ with any differing config field are two different `modelId`s: separate pool
161
+ entries in memory, separate `{modelId}/` cache directories on disk.
162
+
163
+ Properties that follow:
164
+
165
+ - **Deterministic.** Same type + src + config → same id, across restarts. That is
166
+ what lets `chat-<id>/{modelId}/` cache files survive an app restart, and it is
167
+ why `loadModel` on an already-loaded id is a no-op ("already loaded").
168
+ - **The SDK cannot re-configure a loaded model.** Changing ctx or KV quant means
169
+ loading a _different_ `modelId`; core-v2's model pool keys on exactly this.
170
+
171
+ ### What happens when you change …
172
+
173
+ | You change | `modelId` | `configHash` | Cache effect |
174
+ | -------------------------------------------------------------------------------------------------------- | ----------- | ------------ | ---------------------------------------------------------------------------------------- |
175
+ | system prompt (in history) | same | **changes** | new `.bin` in the same dir; cold prime; old file orphaned |
176
+ | tool set — add, drop, or edit any tool's name, description or parameters | same | **changes** | new `.bin`; cold prime |
177
+ | **any** `modelConfig` field (ctx_size, `cache-type-k/v`, device, gpu_layers, stop sequences, verbosity…) | **changes** | same | new `{modelId}/` directory — **every chat on this model cold-primes**; old dirs orphaned |
178
+ | model weights (`modelSrc`) | **changes** | — | same as above |
179
+ | chat | — | — | different `{cacheKey}/` directory (by design) |
180
+
181
+ Two traps this table encodes:
182
+
183
+ - **Observing the system changes it.** core-v2 puts `verbosity: llmVerbosity()`
184
+ into `modelConfig` ([model-pool.ts:330](../lib/models/model-pool.ts)),
185
+ read from `QVAC_LLM_VERBOSITY`. Turning addon debug logging on flips a config
186
+ field → new `modelId` → every chat cold-primes under a fresh directory. Debug
187
+ the cache with harness logs (`QVAC_HARNESS_LOG`) or `xxd` on the `.bin`, not by
188
+ raising addon verbosity, or the cold behaviour you observe is one you caused.
189
+ Any env-dependent config field (`openclCacheDir`, `main-gpu`) has the same
190
+ property.
191
+ - **Orphaned files accumulate — and only the app can delete them.** Nothing
192
+ cleans custom-key cache directories (the retention sweep is auto-key only), so
193
+ every config or system-prompt change strands the previous `.bin`. The SDK
194
+ cannot safely auto-delete: identity-by-config cuts both ways, so a "stale"
195
+ file **revives** the moment the same `(key, modelId, configHash)` recurs —
196
+ `beginTurn` probes the disk path and reuses it (flip ctx back, revert the
197
+ prompt, unset `QVAC_LLM_VERBOSITY`, and the old cache is live again). Whether
198
+ that rare revival is worth the disk is a product call, which is why retention
199
+ for custom keys is deliberately the caller's job (`deleteCache({ kvCacheKey,
200
+ modelId? })`). core-v2 implements **delete on re-key** (2026-08-20,
201
+ `@qvac/core`'s `lib/kv-cache-reclaim.ts`): every device with a harness watches
202
+ agents and deletes the whole `chat-<id>` key for an agent's chats when a
203
+ re-keying field changes, deletes a chat's key when the chat disappears, and
204
+ deletes it again after a fold (below) — four moments in all, each of which
205
+ reclaims any repair generations with it, because they are children of that key —
206
+ the cold prime is already inevitable, so it costs nothing beyond the
207
+ revert-reuse case. Residual: changes replicated while the assistant was
208
+ closed reclaim nothing (no diff to observe). Scale that made this
209
+ non-optional: observed 21–26 MB per file at ctx 2048, and the state file
210
+ scales with the _allocated_ context — at the 51,200 desktop default,
211
+ plausibly hundreds of MB per chat (unmeasured).
212
+ - **Apps can delete caches too, device-locally.** The engine API exposes
213
+ `clearKvCache()` (every cache on this device) and `deleteChatKvCache({ id })`
214
+ (2026-09-10; the chat's key with its repair children). Both act only on the
215
+ device whose Core is called — nothing is replicated, and the chat's warm
216
+ cache may sit on the provider device. The desktop app's
217
+ `qvac-assistant/kv-cache` wraps both behind the `supportsKvCacheDeletion` gate.
218
+
219
+ One more footgun: the SDK also accepts `system_prompt` **inside `modelConfig`**
220
+ as a fallback for the prime when the history carries no system message. Putting
221
+ it there would drag the system prompt into `modelId` — a prompt edit would then
222
+ cold-prime _every chat_ instead of one. core-v2 correctly keeps it in history;
223
+ keep it that way.
224
+
225
+ ## Building the history
226
+
227
+ The `history` argument is an ordered array of `{ role, content }` (the field is
228
+ `content`, not `message`; user turns may also carry `attachments`). Canonical
229
+ shape, mid tool-chain:
230
+
231
+ ```jsonc
232
+ [
233
+ { "role": "system", "content": "<harness buildSystemPrompt output>" },
234
+ { "role": "user", "content": "question" },
235
+ { "role": "assistant", "content": "answer" },
236
+ { "role": "user", "content": "next question" },
237
+ { "role": "assistant", "content": "<tool_call>…web_search…</tool_call>" },
238
+ { "role": "tool", "content": "<tool result>" },
239
+ { "role": "assistant", "content": "answer grounded in the result" }
240
+ ]
241
+ ```
242
+
243
+ Structural rules:
244
+
245
+ 1. **Exactly one `system` message, first.** The SDK primes from it and filters it
246
+ out of cold sends; a second one mid-history is never valid.
247
+ 2. **A `tool` message directly follows the assistant message that called it.**
248
+ The chat template assumes it.
249
+ 3. **The last message is `user`** (fresh turn) **or `tool`** (chain
250
+ continuation).
251
+ 4. **Tool definitions are never history.** They ride the separate `tools`
252
+ argument.
253
+ 5. **Pass the whole history, every turn.** The SDK computes the delta. Never
254
+ pre-trim — the only sanctioned shrinking is the overflow ladder, which edits
255
+ the _outgoing candidate_, not the stored history.
256
+
257
+ Note the two shapes core-v2 actually has: **within a run**, the round loop
258
+ appends `assistant(tool-call markup) → tool(result)` pairs per round. **Across
259
+ turns**, history is rebuilt from storage and collapses each run to
260
+ `[user, final assistant answer]` — thinking chunks never join assistant content,
261
+ and tool results are not replayed (the one exception is a `skill` load, replayed
262
+ so the next turn keeps the granted tools). `sanitizeTurn` then strips control
263
+ tokens from every turn and residual tool-call markup from assistant turns.
264
+
265
+ ### Editing history: what resets the cache, and what silently doesn't
266
+
267
+ **Editing history content never resets the cache.** For our custom `chat-<id>`
268
+ key the cache identity is `(cacheKey, modelId, configHash)` and `configHash`
269
+ hashes only the system prompt and the tool definitions — history is not part
270
+ of it. The only edits that force a fresh cache file are a **system prompt
271
+ change**, a tool-set change, or any model-load-config change
272
+ (new `modelId`). (Auto-key mode hashes prior history, so there any edit is a
273
+ cache miss — core-v2 does not use it.)
274
+
275
+ That is the trap, not a safety: warm turns only ever send the tail, so an edit
276
+ to anything **before** the tail — removing a turn, stripping a thinking block or
277
+ tool output from an old assistant message, regenerating an earlier answer —
278
+
279
+ - is **silently ignored while the cache is warm**: the model keeps conditioning
280
+ on the original text in the KV — which is why core-v2 deletes the chat cache
281
+ before executing a superseding (retry/regenerate) run (2026-08-20,
282
+ `@qvac/core`'s `lib/kv-cache-reclaim.ts`); any new divergence writer must do the
283
+ same;
284
+ - **suddenly takes effect on the next cold prime** (restart, cancel-wipe, other
285
+ device), so the model's effective context changes across a restart;
286
+ - and **additionally corrupts the slice boundary**:
287
+ `savedCount` is a message _count_, so inserting or removing anything before
288
+ the tail shifts which messages the count refers to. The SDK detects only the
289
+ empty-slice case.
290
+
291
+ Edits that are safe, because they touch only what is about to be sent:
292
+
293
+ - the **last user message** — `annotateLastUserTime` does exactly this, and its
294
+ own comment states the rule: _"earlier turns must stay byte-identical so the
295
+ KV-cache prefix reuses"_;
296
+ - **tool results in the current chain** — they are the outgoing slice; the
297
+ overflow ladder's compaction relies on this.
298
+
299
+ **Compaction is the second divergence writer, and it is implemented.** A fold
300
+ rewrites history before the tail on purpose: `historyFromChunks` replaces every
301
+ turn through the boundary with one summary turn. So it does exactly what this
302
+ section warns about, and pays for it the same way — `compactChat` deletes the
303
+ chat key once the `compaction` chunk lands. The chunk is replicated, so a fold
304
+ written on another device reaches this one as data with no local delete behind
305
+ it; `_reconcileCompaction` closes that at the start of the next run, once per
306
+ device per fold. The folded chunks stay in the store and keep rendering: only the
307
+ prompt shrinks.
308
+
309
+ The general rule: **normalize deterministically, from turn 1, identically every
310
+ turn.** A transform applied late or conditionally (stripping thinking only
311
+ sometimes, truncating tool outputs after the fact) creates app-vs-KV drift that
312
+ is invisible warm and surfaces cold. core-v2's storage shape already conforms —
313
+ thinking and tool traffic never enter cross-turn history, so there is nothing to
314
+ strip later. If a true edit feature (edit/delete a past message) needs the model
315
+ to actually honour the change, it must invalidate the chat cache the way retry
316
+ does (`deleteKvCache('chat-<id>')` on the executing device before the next
317
+ completion) and pay the cold-start cost — it is not a content tweak.
318
+
319
+ ## What actually gets sent
320
+
321
+ Every turn the SDK either finds the cache or **primes** it — a separate
322
+ `model.run(..., { prefill: true })` carrying the **system prompt alone** (since
323
+ 0.17.1, qvac `598f1551b`, QVAC-23251; 0.17.0 primed the tool block too). Tools
324
+ stay out of the prime because a message list with no user turn is not a
325
+ renderable conversation for every chat template — Qwen3.5 raises
326
+ `No user query found in messages.` — and a template failure would degrade the
327
+ whole prefix rather than just the block.
328
+
329
+ Then it sends a _slice_, not the history (`prepareMessagesForCache`,
330
+ `decideCachedHistorySlice`):
331
+
332
+ | Condition | Sent |
333
+ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------- |
334
+ | `savedCount` in `(0, history.length]`, slice non-empty | `history.slice(savedCount)` |
335
+ | otherwise — cold cache, or a stale count left by a cancelled turn | full history minus system; the stale count is dropped (`dropStaleSavedCount`) |
336
+
337
+ The **tool block** rides in front of the slice, never inside history. It is
338
+ written into the cache by the first turn that actually renders it — the SDK
339
+ requires a user message in that payload before believing it landed, because a
340
+ Qwen template anchors the block on the last user query and the addon answers a
341
+ missing one by re-rendering the turn with tools stripped — and skipped on every
342
+ later turn once `toolBlockCached` is recorded at commit. A stale-count fallback
343
+ re-sends it with the whole conversation. The block is never trimmed back out of
344
+ the cache; that is why `configHash` includes the tool definitions, so a changed
345
+ set lands on a fresh file rather than a warm prefix holding the old block.
346
+
347
+ Until 0.19 a second gate sat in front of the skip: `toolBlockEvictable =
348
+ n_discarded > 0`, because the sliding window's discard region opened exactly
349
+ where the block sits. While core-v2 passed `n_discarded` (until 2026-08-20) the
350
+ block travelled on every turn, ~331 tokens for a 4-tool agent; qvac#4163 deleted
351
+ the gate with the knob, and a config that still carried the key would not force
352
+ a resend.
353
+
354
+ `tools: []` (or none) means no block and a different `configHash` — a cold
355
+ prime, not a strategy switch.
356
+
357
+ ## Addon semantics
358
+
359
+ Nothing evicts. Since addon 0.48 the KV holds exactly what was decoded into it
360
+ (minus compacted reasoning, below); a full window is terminal for the turn and
361
+ the core-v2 **compact** flow (see _Compaction_) is what replaces eviction.
362
+
363
+ **A prompt that does not fit is refused at prefill.** Two guards, both `>=`:
364
+ the slice alone against `ctx_size`, and the cached total plus the slice against
365
+ `ctx_size`. Either raises `ContextOverflow` with the cache untouched — there is
366
+ no discard step in between. The two guards, and the two context classes, format
367
+ their numbers differently; `parseContextOverflowMessage` normalizes every form
368
+ into the error's sizes (see _Reading the numbers_).
369
+
370
+ **A thrown overflow keeps the cache** (0.19, qvac#4163). The completion handler
371
+ marks a prefill overflow and every addon pre-mutation refusal (scheduler cap,
372
+ bad `json_schema`, missing media) as non-destructive: the deferred unwind runs
373
+ `releaseTurn`, which leaves the committed `.bin` and its `savedCount` alone, and
374
+ only rolls back a cache this same turn had freshly primed. Before 0.19 the unwind
375
+ was always `rollback`, so a legitimate tool-result overflow unlinked the chat's
376
+ whole file — the defect this doc used to carry as its top upstream report.
377
+
378
+ **A generation that reaches the ceiling stops, with what it produced** (addon
379
+ 0.48: `stopReason=contextOverflow`, where earlier builds reported the
380
+ per-sequence limit). The SDK maps it to the public `stopReason: 'length'` and
381
+ does **not** commit the turn — `shouldCommitCachedTurn` refuses a
382
+ context-boundary stop the way it refuses a cancel or a zero-token reply — so the
383
+ deferred `rollback` unlinks the `.bin` and the next turn cold-primes from
384
+ history, the truncated answer included. Nothing throws, which is why
385
+ compaction's end-of-run trigger exists at all.
386
+
387
+ **Reasoning is compacted out of the KV by the addon.**
388
+ `generationParams.remove_thinking_from_context` defaults to `true` (addon
389
+ 0.33.0); a closed thinking block is removed after the turn — since 0.48 by
390
+ rewinding to the block boundary and re-decoding what follows, with no `seq_add`
391
+ shifting. `cacheTokens` after a turn therefore excludes thinking, and a
392
+ compaction failure surfaces as an error rather than a cache that still holds the
393
+ trace. This is why core-v2 never has to strip thinking from history itself.
394
+
395
+ **Cache file.** Four ints of metadata, written on every committed turn:
396
+ `nPast`, `cacheTokens`, and two retired slots that carried the sliding window's
397
+ first-message counters. 0.48+ fills those by mirroring the live values —
398
+ `{nPast, nPast, cacheTokens, cacheTokens}` — so a still-sliding build that reads
399
+ the file refuses to slide instead of evicting from position 0.
400
+
401
+ **Reset happens only on key CHANGE.** Same key → no reload, no reset. Switching
402
+ keys saves the old cache first. Since addon 0.35.1 a same-key turn does check
403
+ whether the `.bin` still exists and discards its in-memory state if not.
404
+
405
+ **Disk growth is unbounded for our keys.** SDK 0.17.0 added a retention sweep
406
+ (size + idle budgets, mobile/desktop split, plus empty-directory pruning), but it
407
+ only considers entries with an `.auto-cache-<16hex>` marker — i.e. **auto keys
408
+ only**. A `chat-<chatId>` cache is never swept or size-capped, and every turn
409
+ rewrites the whole `.bin`. The upside: the sweep cannot evict a chat cache, so it
410
+ is not a cold-prime trigger.
411
+
412
+ ## Compaction
413
+
414
+ The app-side answer to a full window, and the only sanctioned one:
415
+ `@qvac/core`'s `lib/compaction.ts` plus `compactChat` in its `lib/assistant.ts`.
416
+ It folds a chat's older turns into a single summary turn, persisted as a
417
+ replicated `compaction` chunk carrying `summary` and `throughId`, so every device
418
+ rebuilds the same prompt from chat data rather than from cache state. Decision
419
+ record: [2026-08-21-compaction-is-chat-data](https://github.com/tetherto/qvac-app/blob/main/docs/decisions/2026-08-21-compaction-is-chat-data.md),
420
+ still in `tetherto/qvac-app`.
421
+
422
+ **What it evicts is the prompt, not the KV.** The folded chunks stay in the store
423
+ and keep rendering; the fold then deletes the chat key so the next completion
424
+ primes from the compacted history.
425
+
426
+ **Only the last turn stays verbatim** (`COMPACTION_KEEP_TURNS`, 1), and it has to:
427
+ fold the last user message away and the model has nothing to answer. Keeping two
428
+ was measured as the reason a fold could free nothing — the second kept turn drags
429
+ its assistant answer along, which is usually the largest thing in the chat. At ctx
430
+ 4096 that left a fold at ~3,890 of 4,096 and the next answer truncated at 90
431
+ generated tokens.
432
+
433
+ **A fold that would grow the prompt is refused** (`summary-not-smaller`). The
434
+ summary turn carries a 132-character prefix, so summarizing a couple of one-word
435
+ turns is always a loss; `foldReducesPrompt` compares it against the turns it
436
+ replaces before anything is appended or any cache is dropped. A comparison on
437
+ characters, never a token budget.
438
+
439
+ **A summarizer that overflows folds a narrower range.** The fold's own prompt is
440
+ the range it folds, so a history that no longer fits overflows the fold meant to
441
+ rescue it — and the ladder cannot trim it, because it is one user turn.
442
+ `compactChat` halves the range and retries instead of giving up, which keeps a
443
+ chat opened on a smaller window (a desktop chat at 51k on a phone at 12k) from
444
+ failing every message. A boundary no later than the previous fold's is refused
445
+ (`already-compacted`), so narrowing can never un-collapse an earlier fold.
446
+
447
+ **The boundary never splits a kept turn from its uploads.** An upload chunk is
448
+ appended before the user message it belongs to, so `compactionBoundary` walks
449
+ back over the uploads bound to the first kept message before it picks the point.
450
+
451
+ **A fold announces only once it is going to summarize.** The `compacting` phase is
452
+ written from inside `compactChat`, past the cheap refusals, so a run that gets
453
+ `already-compacted` never flashes the label.
454
+
455
+ **A fold summarizes in the run's own window.** The harness folds `ctxSize` into
456
+ the model key, so the summarizer gets the agent row with the app-level context
457
+ size applied — otherwise a fold evicts the resident model, reloads it at the
458
+ device default, and the run reloads it again.
459
+
460
+ **A fold survives a superseding run.** The reactive path folds and re-asks the
461
+ question with `parentId`, which opens a dead range over the sequence numbers
462
+ between the two run-requests — and that is exactly where the fold writes its
463
+ chunk. A compaction chunk is therefore exempt from the branch filters in
464
+ `_execute`: dropping it would un-fold the chat and the retry would re-send
465
+ everything.
466
+
467
+ **Floor.** `COMPACTION_MIN_CTX_SIZE` is 4,096, exported renderer-safe from the app's
468
+ `qvac-assistant/constants`. Below it the summary cannot pay for the ~420-token
469
+ prefix (system prompt + tool block, measured on a 4-tool agent) it has to share
470
+ the window with, so those chats stay on the overflow ladder. The context picker
471
+ still offers 1k and 2k, so a client must read the constant before telling a user
472
+ their conversation will be summarized.
473
+
474
+ **Triggers**, all on the SDK's own counts, never an estimate:
475
+
476
+ | When | Reads |
477
+ | ------------------- | ------------------------------------------------------------------------------------------------- |
478
+ | before a run | the previous round's `cacheTokens` vs its `ctxSize` |
479
+ | at the end of a run | this run's own numbers, because a turn can fill the window by itself |
480
+ | after an overflow | the harness ladder is exhausted; the fold lands and the question is re-asked as a superseding run |
481
+
482
+ `ctxSize` is per-device — mobile loads every model at 12k, desktop at its
483
+ per-model default — so only a turn _this_ device ran is a reading for it. A chat
484
+ that moved here from a phone carries a window this host does not have.
485
+
486
+ **Why the margin is 95%, and 90% at or below 4,096.** Holding back a fixed share
487
+ costs most where the window is largest, so the margin stays thin. What it has to
488
+ buy is a turn that lands _between_ the trigger and the ceiling — that is the turn
489
+ whose successor folds before generating, visibly and with a real post-fold
490
+ reading. At 4,096 a 5% margin is 204 tokens, narrower than an ordinary turn:
491
+ measured on a 4k chat, turns arrived at 58, 107, 147, 325, 416 and 1,267 tokens,
492
+ so the chat kept crossing the whole band inside one turn, truncating that answer
493
+ against the ceiling (`stopReason: length`) and leaving only a silent fold behind
494
+ it. A tenth of 4,096 is 409 tokens, which most of those turns fit inside.
495
+
496
+ **A fold leaves the chat with no occupancy reading for one turn**, and that is
497
+ deliberate. The number the folding run measured describes history the fold has
498
+ just replaced, so publishing it renders the chat as still full — that is what
499
+ locked the composer before this landed — and `_lastRunMetrics` would read it back
500
+ and fold again on the next run, since a new user turn moves the boundary and
501
+ `already-compacted` no longer refuses. The post-fold number does not exist until
502
+ the next prime: occupancy is whatever the SDK reports after it builds the prompt,
503
+ and deriving it here would be the chars-per-token estimate the rules forbid. So
504
+ `withoutOccupancy` strips `cacheTokens`/`promptTokens` from the terminal status of
505
+ a run that folded, and the next completed turn restores a real reading. The client
506
+ currently renders that absence as 0.
507
+
508
+ **A turn that fills the window ends cleanly.** The SDK reports `stopReason:
509
+ length` and the run completes — nothing throws — which is why the end-of-run
510
+ check exists at all: the reactive trigger never sees that turn. The SDK does not
511
+ commit that turn either (its `.bin` is unlinked, see _Addon semantics_), so the
512
+ fold's key delete costs nothing extra there: the next completion was going to
513
+ cold-prime regardless.
514
+
515
+ **Every fold announces itself.** The run status carries a `compacting` phase,
516
+ app-runtime passes it through to the live status, and desktop and mobile name it
517
+ "Compacting conversation...". That took one narrowing on the client: a
518
+ non-terminal run-status normally means the run restarted, so a client discards
519
+ whatever streamed — which is why announcing the end-of-run fold used to erase the
520
+ answer on screen. `chunk-to-turn` now exempts a status carrying that phase, since
521
+ a fold is not a new attempt. What stays conditional is _clearing_ the phase: the
522
+ clear rewrites the chunk into a plain non-terminal status, so it only runs for the
523
+ before-run fold, whose run carries on. Where a terminal status follows — the end
524
+ of a turn, a failed overflow — that status closes the phase.
525
+
526
+ **So the fold is shown from its chunk, not from the phase.** The `compaction`
527
+ chunk is already replicated and already parsed, so no new event was needed — only
528
+ rendering. `chunk-to-turn` keeps it as a turn of its own kind (that is what puts
529
+ the marker in the right place), the message list excludes it, and
530
+ `anchorCompactionMarkers` puts it on `thread.compactionMarkers` in the reducer,
531
+ anchored to the user message it precedes — never to a reply, which attempt
532
+ grouping collapses to its selected attempt — so neither client re-derives the
533
+ position and a retry cannot orphan the row. Desktop and mobile render it as a quiet centered row —
534
+ "Compacted session" — in the same shape as the day divider. Unlike the phase, it
535
+ survives: it is there when the user scrolls back.
536
+
537
+ ## Reading the numbers
538
+
539
+ | Field | Means |
540
+ | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
541
+ | `stats.promptTokens` | tokens in **this turn's slice** |
542
+ | `stats.generatedTokens` | tokens produced |
543
+ | `stats.cacheTokens` | **physical KV-cell usage** |
544
+ | `ContextOverflowError.requiredTokens` | what the failing guard needed, in `ctxSize` units — the slice alone, or cached + slice; can **equal** `ctxSize`, the guards fire on `>=` |
545
+ | `ContextOverflowError.cachedTokens` | the resident total on the warm-cache guard; unset on the slice-alone guard |
546
+ | `ContextOverflowError.promptTokens` | the slice, **only** when the guard counted tokens (TextLlm); unset when it reported KV cells/positions (Mtmd) |
547
+ | `ContextOverflowError.ctxSize` | the window — or the per-sequence cap, on a scheduler refusal |
548
+
549
+ All four are parsed out of the addon's message (`parseContextOverflowMessage`,
550
+ 0.19). Since qvac#4163 every wording the addon emits parses; before that Mtmd's
551
+ forms matched nothing and `promptTokens` alternately meant the slice or a cached
552
+ total. Mtmd is the class production executes — chat models load with a
553
+ projection model — so expect `requiredTokens` and `ctxSize`, `cachedTokens` on a
554
+ warm overflow, and **no `promptTokens`**. Where a guard reports both positions
555
+ and KV cells the larger is taken. core-v2 reads none of them:
556
+ `isContextOverflowError` matches the error's `name` (`CONTEXT_OVERFLOW`) and the
557
+ ladder retries on shape alone.
558
+
559
+ Two traps:
560
+
561
+ - `cacheTokens` is a _different quantity_ from `nPast` (logical positional span).
562
+ They are separate metadata fields because M-RoPE media can occupy more cells
563
+ than its positional span. **Do not do arithmetic mixing them** — in particular,
564
+ `cacheTokens - promptTokens` is not the primed prefix size.
565
+ - `promptTokens` on the error is usually absent and, when present, counts one
566
+ guard's view of the slice; on the stats it is always the slice the addon
567
+ processed. Do not compare the two.
568
+
569
+ ## Rules for using it
570
+
571
+ 1. **Pass the full history.** The SDK computes the delta.
572
+ 2. **No client-side token math.** No chars-per-token estimates, no reserved
573
+ response margin, no pre-emptive trimming, no computed budgets. core-v2 has none
574
+ and must keep none.
575
+ 3. **React, never predict.** The only supported "will this fit" answer is to send
576
+ it and handle the typed `ContextOverflowError`. Compaction's proactive trigger is
577
+ not an exception: it acts on the SDK's own `cacheTokens` from a round that already
578
+ completed, never on an estimate of the outgoing prompt.
579
+ 4. **Use the SDK's real counts** for diagnosis, never estimates.
580
+ 5. **Keep the system prompt stable** for a chat — every change costs a cold prime.
581
+ 6. **Keep the tool set stable** — `tools` joins `configHash`, so adding,
582
+ dropping, or editing a tool costs a cold prime.
583
+ 7. **Only two legitimate overflows** in steady state: the current prompt alone
584
+ exceeds ctx, or a tool result alone exceeds ctx. A third legitimate case: a
585
+ device that never ran this chat receives the whole history (the cache is
586
+ device-local). A fourth: a window below the 4,096 fold floor, which never
587
+ compacts and degrades through the ladder instead. Anything else is a bug.
588
+
589
+ ## Debugging
590
+
591
+ Enable harness logs — `QVAC_HARNESS_LOG=1`, or `--log-verbose`. What ships today:
592
+
593
+ ```
594
+ round start {"round":0,"turns":16,"tools":1}
595
+ round stats {"round":0,"wallMs":2188,"timeToFirstToken":450,"cacheTokens":835,
596
+ "promptTokens":458,"generatedTokens":84,"backendDevice":"gpu"}
597
+ final {"stopReason":...,"toolCalls":0,"truncated":false}
598
+ add_text: <|im_start|>system ... ← addon output, NOT a harness line (below)
599
+ ```
600
+
601
+ - `round start` — `turns` is the history length, `tools` the count passed as the
602
+ separate `tools` argument. Tool definitions never appear in history.
603
+ - `round stats` carries the SDK's real token counts. `cacheTokens` climbing turn
604
+ over turn = warm reuse; dropping back toward the system-prompt size = a re-prime.
605
+ - **Two `add_text` blocks = a cold prime** (system prompt, then the slice); one
606
+ block = warm. But `add_text` is **addon-level** output (it comes from below
607
+ the addon, in llama.cpp), visible only at elevated `QVAC_LLM_VERBOSITY` — the
608
+ addon maps `0→ERROR, 1→WARNING, 2→INFO, 3→DEBUG` (`LoggingMacros.cpp`), so
609
+ use `3` to be sure — and that env feeds `modelConfig.verbosity`, so enabling
610
+ it **re-keys `modelId` and cold-primes every chat** (the trap above). Use it only when verbosity is already on;
611
+ otherwise the safe cold-prime signals are `round stats` (`cacheTokens`
612
+ dropping back toward prompt size) and `xxd` on the `.bin`.
613
+ - A failed turn logs `completion failed {...}`
614
+ ([index.ts:1281](../index.ts)) and **no**
615
+ `round stats`, because
616
+ the stats only exist once the SDK settles the run. An absent `cacheTokens` line
617
+ means the turn failed, not that the cache was bypassed.
618
+ - No addon line reports eviction any more: `discarded N tokens after the first
619
+ message`, `[ContextSlider] context is full but cannot discard tokens` and
620
+ `runtimeStats().contextSlides` all left with sliding in addon 0.48. A log
621
+ that shows them is an old build. `normalizeCompletionStats` forwards
622
+ TTFT/TPS/ppTPS/cacheTokens/promptTokens/generatedTokens/avgConcurrentSeq/
623
+ backendDevice, nothing about the KV's shape.
624
+
625
+ Nothing currently reports which context-recovery rung ran. If you need that,
626
+ log per candidate from
627
+ `runRecoveredRoundWith` in `capabilities/completion.ts`: the rung index,
628
+ `droppedTurns` (stage 1), a compaction counter (stage 2), and a roles-only shape
629
+ of the candidate history plus the tool count. Roles only — never message content.
630
+
631
+ **Read the cache file directly** — the header carries the metadata, no
632
+ instrumentation needed:
633
+
634
+ ```bash
635
+ xxd -l 32 ~/.qvac/kv-cache/chat-<chatId>/*/*.bin
636
+ # after an accepted diagram repair the live file is one level deeper:
637
+ xxd -l 32 ~/.qvac/kv-cache/chat-<chatId>/r*/*/*.bin
638
+ ```
639
+
640
+ ```
641
+ 7173 6767 0200 0000 0400 0000 7c00 0000 7c00 0000 ...
642
+ ^ 'ggsq' ^ ver 2 ^ 4 fields ^ nPast ^ nPast again (retired slot)
643
+ ```
644
+
645
+ Little-endian `uint32` after the count: `nPast`, `nPast`, `cacheTokens`,
646
+ `cacheTokens` on addon 0.48+ — slots 1 and 3 are the retired first-message
647
+ counters, mirrored from the live values (a file from an older build carries
648
+ `firstMsgTokens` / `firstMsgCacheTokens` there instead). `nPast` climbing turn
649
+ over turn is warm reuse; back to prompt size is a re-prime. An empty
650
+ `chat-<id>/<modelId>/` directory means a rollback unlinked the file — after a
651
+ cancelled turn, a zero-token reply, or a generation that hit the ceiling.
652
+
653
+ To A/B the cache, drop `cacheKey` where `_execute` builds the completion
654
+ request in [assistant.ts](https://github.com/tetherto/qvac-core/blob/main/lib/assistant.ts).
655
+ There is no env kill-switch in core-v2.