@qvac/harness 0.8.6 → 0.8.8

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 (61) hide show
  1. package/CHANGELOG.md +1298 -507
  2. package/dist-lib/index.d.ts +11 -0
  3. package/dist-lib/index.js +102 -25
  4. package/dist-lib/lib/capabilities/completion.d.ts +5 -4
  5. package/dist-lib/lib/capabilities/completion.js +132 -64
  6. package/dist-lib/lib/capabilities/tool-selection.d.ts +1 -3
  7. package/dist-lib/lib/capabilities/tool-selection.js +2 -4
  8. package/dist-lib/lib/capabilities/tool-turns.d.ts +0 -1
  9. package/dist-lib/lib/capabilities/tool-turns.js +0 -6
  10. package/dist-lib/lib/connect.js +3 -0
  11. package/dist-lib/lib/contract-types.d.ts +2 -0
  12. package/dist-lib/lib/helpers/tool-call-markup.d.ts +1 -0
  13. package/dist-lib/lib/helpers/tool-call-markup.js +3 -0
  14. package/dist-lib/lib/models/context-choice.d.ts +15 -0
  15. package/dist-lib/lib/models/context-choice.js +55 -0
  16. package/dist-lib/lib/models/model-pool.d.ts +3 -1
  17. package/dist-lib/lib/models/model-pool.js +8 -11
  18. package/dist-lib/lib/models/sdk-faults.d.ts +1 -0
  19. package/dist-lib/lib/models/sdk-faults.js +6 -0
  20. package/dist-lib/lib/office/engine/pptx/picture-area.d.ts +23 -0
  21. package/dist-lib/lib/office/engine/pptx/picture-area.js +84 -0
  22. package/dist-lib/lib/office/engine/pptx/presentation.d.ts +2 -0
  23. package/dist-lib/lib/office/engine/pptx/presentation.js +55 -17
  24. package/dist-lib/lib/office/engine/pptx/types.d.ts +1 -0
  25. package/dist-lib/lib/office/engine/xlsx/workbook.d.ts +1 -0
  26. package/dist-lib/lib/office/engine/xlsx/workbook.js +34 -3
  27. package/dist-lib/lib/serve.js +6 -1
  28. package/dist-lib/lib/spawn.js +4 -10
  29. package/dist-lib/lib/tools/exec.d.ts +4 -1
  30. package/dist-lib/lib/tools/exec.js +55 -11
  31. package/dist-lib/lib/tools/file-glob.js +1 -0
  32. package/dist-lib/lib/tools/grep.js +1 -0
  33. package/dist-lib/lib/tools/office/ops.js +1 -1
  34. package/dist-lib/lib/tools/office.js +29 -6
  35. package/dist-lib/lib/tools/read-files.js +1 -0
  36. package/dist-lib/lib/tools/skill.js +6 -4
  37. package/dist-lib/lib/tools/skills/catalog.d.ts +2 -0
  38. package/dist-lib/lib/tools/skills/catalog.js +4 -0
  39. package/dist-lib/lib/tools/skills/cli-schema-check.d.ts +1 -0
  40. package/dist-lib/lib/tools/skills/cli-schema-check.js +3 -0
  41. package/dist-lib/lib/tools/skills/cli-schema-load.js +10 -0
  42. package/dist-lib/lib/tools/skills/cli-schema-types.d.ts +2 -0
  43. package/dist-lib/lib/tools/skills/cli-schema.js +6 -3
  44. package/dist-lib/lib/tools/skills/frontmatter-parser.d.ts +2 -0
  45. package/dist-lib/lib/tools/skills/frontmatter-parser.js +4 -0
  46. package/dist-lib/lib/tools/skills/manifest-parse.d.ts +1 -0
  47. package/dist-lib/lib/tools/skills/manifest-parse.js +12 -0
  48. package/dist-lib/lib/tools/skills/mention-name.d.ts +6 -0
  49. package/dist-lib/lib/tools/skills/mention-name.js +6 -0
  50. package/dist-lib/lib/tools/skills/mention.d.ts +2 -3
  51. package/dist-lib/lib/tools/skills/mention.js +5 -6
  52. package/dist-lib/lib/tools/weather-lookup.js +1 -0
  53. package/dist-lib/lib/tools/web-fetch.js +1 -0
  54. package/dist-lib/lib/tools/web-search.js +1 -0
  55. package/dist-lib/schema/harness.js +4 -2
  56. package/dist-lib/spec/hrpc/messages.js +18 -8
  57. package/dist-lib/spec/hyperschema/index.js +18 -8
  58. package/dist-lib/spec/hyperschema/schema.json +11 -1
  59. package/dist-lib/spec/hyperschema/types.d.ts +3 -1
  60. package/package.json +5 -7
  61. package/worker/harness-sidecar.mjs +2 -1
package/CHANGELOG.md CHANGED
@@ -8,96 +8,346 @@ While the major version is `0`, a breaking change bumps the minor version.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.8.8] - 2026-10-15
12
+
13
+ ### Added
14
+
15
+ - A skill's manifest may declare `display_name`, the `@` handle users see and
16
+ type, as one word (letters, digits, `-` or `_`); a `display_name` that is
17
+ not one word is ignored. The Word and Excel skills are shown as Documents
18
+ and Spreadsheets, and their folder names are trademarks, so a host needs a
19
+ handle other than the name. Once the word skill's manifest declares
20
+ `display_name: Documents`, `@documents` resolves it, and `@word` still does,
21
+ so an app that sends the folder name keeps working. The catalog entry and
22
+ the `skills` wire carry `displayName`, a customized copy of a skill keeps
23
+ the shipped one, and `skillMentionHandle` gives a host the same rule the
24
+ harness matches on. The folder name stays the skill's key everywhere else.
25
+ (#469)
26
+
27
+ ### Fixed
28
+
29
+ - A replayed tool call keeps its markup when history is rendered from scratch.
30
+ Every assistant turn lost its tool-call markup on the way in, so a retry,
31
+ compaction, a dropped chat cache, an overflow candidate or another device
32
+ showed the model an empty assistant turn followed by a result answering
33
+ nothing. Only an assistant turn that no tool result follows is stripped now,
34
+ which still keeps markup leaked into a final answer out of the prompt. (#402)
35
+ - A web search the harness runs for "search the web" now ends on the same
36
+ continuation nudge as a search the model makes. Without it, the round after
37
+ the search often failed on Qwen3.5-9B with thinking on instead of answering.
38
+ (#486)
39
+ - An identical call to a read tool that already succeeded this turn is
40
+ answered with a note pointing at the earlier result instead of running
41
+ again, and counts as a strike. A small model could repeat the same call
42
+ hundreds of times in one round until the context filled. Any other tool
43
+ that runs clears the memo, and a round is cut to its first 16 calls. (#377)
44
+ - Asking the `skill` tool for a file a loaded skill does not have returns
45
+ guidance listing its real files instead of an error, so the app no longer
46
+ shows a failed tool call for it. A file asked of an unknown skill returns
47
+ `skill not found`. (#383)
48
+
49
+ ## [0.8.7] - 2026-10-12
50
+
51
+ ### Added
52
+
53
+ - A skill's manifest may declare `env`, the variables its `exec` commands run
54
+ with. The child process env is an allowlist, so this is the only way a
55
+ skill's binary sees a setting of its own. The value `${session}` expands to
56
+ the chat the run belongs to, which lets a long-lived tool such as a browser
57
+ daemon keep one instance per chat; a run with no session drops that variable.
58
+ An overlay skill keeps the base skill's `env`, so an edit never widens what
59
+ a command can reach. `completion()` takes an optional `session`, in process
60
+ and over hrpc, and falls back to the first segment of `cacheKey`. (#341)
61
+
62
+ ```yaml
63
+ metadata:
64
+ env:
65
+ AGENT_BROWSER_SESSION: '${session}'
66
+ AGENT_BROWSER_HEADED: '1'
67
+ ```
68
+
69
+ - A verb in a skill's `cli.schema.json` may carry `x-max-output-chars`, the
70
+ most output `exec` returns for that command. The ceiling wins over the
71
+ model's own `maxOutputChars`, the truncation note says which limit applied,
72
+ and a schema whose cap is not a positive integer fails to load. Page
73
+ snapshots and other verbose read verbs stay inside the context this way.
74
+ (#342)
75
+
76
+ - The `browser` skill ships with `@qvac/skills` 0.1.14: an agent drives a
77
+ Chromium through the `agent-browser` CLI, scoped to `exec(agent-browser)`,
78
+ with one browser session per chat. (#405)
79
+
80
+ - A chat model's context climbs as far as the device and the SDK allow when
81
+ neither the agent nor the host sets one: 262,144, then 131,072, then 65,536,
82
+ before the default. The device class caps the climb, the trained window from
83
+ the weight's GGUF header bounds it, and each rung must pass `assessModelFit`
84
+ with its own config. The choice is kept per pool key for the life of the
85
+ process. See the
86
+ [decision record](https://github.com/tetherto/qvac-harness/blob/v0.8.7/docs/decisions/2026-10-08-the-device-class-sets-how-far-the-context-climbs.md).
87
+ (#382)
88
+
89
+ ### Changed
90
+
91
+ - **Breaking for hosts:** a skill named with `@` or in prose now reaches the
92
+ model as a `skill` call, its result and the continuation nudge right after the
93
+ request, exactly the round a load the model makes itself leaves, instead of
94
+ being written into the user's message. The user's turn is sent as typed and
95
+ the run stays on the chat's KV cache key, so its rounds and the turn after it
96
+ read the cached prefix; the child key 0.8.6 ran these turns under (#305) and
97
+ its delete are gone. The host must replay each run's turns as it sent them
98
+ (`@qvac/core` does from 0.7.0, tetherto/qvac-core#338): a host that collapses
99
+ a run to its request and answer leaves the cache's message count stale, and
100
+ the next turn's whole history is appended to the cache again. Because the body
101
+ is replayed as sent, an edit to the skill reaches such a chat on its next
102
+ mention of the skill, not on the next turn.
103
+
104
+ - `bare-stow` 0.1.7, with the patches it made stale dropped. (#396)
105
+ - `lunte` 1.8.6 and `lunte-plugin-qvac` 0.2.0. (#397)
106
+
107
+ ### Fixed
108
+
109
+ - **Breaking:** a skill switched off on the agent can no longer be used. Naming
110
+ it with `@` or in prose loads nothing and unlocks nothing, and a chat that
111
+ loaded it earlier loses it as soon as it is switched off (QVAC-25357).
112
+ `skillIsActive` reads the agent's `enabledSkills` alone, and
113
+ `SkillActivation` drops its `mention` and `loadedSkills` fields. A host that
114
+ relied on a mention to grant a skill must enable it on the agent first. (#338)
115
+ - A round that says nothing and calls nothing is re-asked on a history that
116
+ keeps that round's turn. The re-ask used to resend the history without it,
117
+ which the SDK, having cached the turn, read as stale: it sent the whole
118
+ history again and the addon appended it to the KV cache. (#403)
119
+ - A model loaded by hand when nothing else is in memory is no longer refused,
120
+ even when its estimate is above the memory cap. Beside another model a
121
+ manual load is still refused, so it never piles a second model into memory.
122
+ (#394)
123
+
124
+ - When llama.cpp overruns its grammar mid-round and a complete
125
+ `<function=…></function>` call has already streamed, the round ends on that
126
+ text and markup recovery runs the call, instead of the turn failing with
127
+ "Unexpected empty grammar stack". The held-back prose is flushed before any
128
+ rethrow, and the inference inspector reports the recovered round as
129
+ `completed` with the grammar error as its `reason`. (#370)
130
+
131
+ - `@mention`s, the implicit skill preload and the explicit web-search check
132
+ read the turn as the user typed it (`CompletionContext.said`, carried in the
133
+ start frame's existing payload), so a host that rewrites the stored turn no
134
+ longer hides a mention. Hosts that do not send `said` behave as before.
135
+ (#372)
136
+
137
+ - Office files keep their pictures off the text: in a deck a picture fills an
138
+ empty placeholder, sits below the text when there is room, and otherwise
139
+ takes the right half; an op aimed at a slide a later `addSlide` creates is
140
+ refused rather than moved. In a workbook `addImage` without a `cell` lands
141
+ beside the data, as `addChart` does, and a second picture or chart stacks
142
+ below the first. A nested op is named as its own entry in a tool error, and
143
+ the tool result says when an attached image went unused. (#406)
144
+
145
+ ### Removed
146
+
147
+ - **Breaking:** `skillPreloadMessage` (`lib/tools/skills/mention.ts`) and
148
+ `withoutSkillLoadTurns` (`lib/capabilities/tool-turns.ts`). Nothing writes a
149
+ skill body into the user's message any more, so no earlier load turn has to
150
+ be dropped to keep the two from contradicting each other.
151
+ - `CompletionSdk.deleteCache`. Nothing calls it now that the child key (#305)
152
+ and the chat-cache drop after a model-loaded skill (#373, reverted in #400)
153
+ are gone.
154
+
11
155
  ## [0.8.6] - 2026-10-08
12
156
 
13
157
  ### Added
14
158
 
15
- - `setModelMemoryCap(percent)` lets loaded models fill that share of the
16
- device's memory budget, so chat models a request loaded stay loaded beside
17
- each other while they fit, and a chat switching between them loads neither
18
- again. A load that would not fit unloads idle models one at a time: image and
19
- music models first, then embedding, speech and transcription models, then
20
- chat models a request loaded, then those loaded by hand, least recently used
21
- first. Each load is sized by `assessModelFit`, and `list()` rows of loaded
22
- models carry the estimate as `memoryBytes`; `memoryUsage()` answers their
23
- total. A manual `loadModel` of any kind never unloads another model. `0`, a mobile device, and a host
24
- that never sets a cap keep one chat model a request loaded, as before
25
- (QVAC-25735). (#358)
159
+ - `setModelMemoryCap(percent)` keeps several models loaded side by side. Loaded
160
+ models may fill that share of the device's memory budget, so chat models a
161
+ request loaded stay loaded while they fit, and a chat switching between them
162
+ loads neither again.
163
+
164
+ A load that would not fit unloads idle models one at a time, least recently
165
+ used first within each group: image and music models first, then embedding,
166
+ speech and transcription models, then chat models a request loaded, then
167
+ those loaded by hand. A manual `loadModel` of any kind never unloads another
168
+ model.
169
+
170
+ Each load is sized by `assessModelFit`. `list()` rows of loaded models carry
171
+ the estimate as `memoryBytes`, and `memoryUsage()` answers their total. `0`,
172
+ a mobile device, and a host that never sets a cap keep one chat model a
173
+ request loaded, as before (QVAC-25735). See
174
+ [Keeping several models in memory](https://github.com/tetherto/qvac-harness/blob/v0.8.6/README.md#keeping-several-models-in-memory)
175
+ and the
176
+ [decision record](https://github.com/tetherto/qvac-harness/blob/v0.8.6/docs/decisions/2026-10-07-loaded-models-share-a-memory-budget.md).
177
+
178
+ ```js
179
+ await harness.setModelMemoryCap(85)
180
+
181
+ const { usedBytes } = await harness.memoryUsage()
182
+ const loaded = (await harness.list({ cachedOnly: true })).filter((m) => m.isLoaded)
183
+ // each loaded row carries memoryBytes, the estimate its load was sized by
184
+ ```
185
+
186
+ (#358)
187
+
26
188
  - `setDownloadsAllowed({ allowed })`, in process and over hrpc, lets a host hold
27
- model downloads. While downloads are not allowed, a load whose model is not on
28
- the device emits a `model` event with `status: 'held'` and the model's
29
- download name and `addon`, then waits. It goes ahead once a `download()` puts
30
- the model on the device or downloads are allowed again; aborting the caller's
31
- signal ends it with `AbortedError`, and a wait that ends without loading
32
- reports `error` with `message: 'aborted'`. This covers the image, music and translation
189
+ model downloads, for example while a phone is on cellular, so a request
190
+ cannot pull several GB without asking. Downloads are allowed by default, and
191
+ `download()` itself is never held: it is how the host fetches a held model
192
+ once the user approves.
193
+
194
+ While downloads are not allowed, a load whose model is not on the device
195
+ emits a `model` event with `status: 'held'`, the model's download name and
196
+ its `addon`, then waits. It goes ahead once a `download()` puts the model on
197
+ the device or downloads are allowed again. Aborting the caller's signal ends
198
+ it with `AbortedError`, and a wait that ends without loading reports `error`
199
+ with `message: 'aborted'`. This covers the image, music and translation
33
200
  runtimes and every pooled load (chat, embedding, speech, transcription).
34
- `download()` is never held, and downloads are allowed by default. Over hrpc,
35
- every model event frame now carries an `addon` field, which is `null` unless
36
- the event is `held` or the `error` that ends a held wait. (#340)
201
+
202
+ Over hrpc, every model event frame now carries an `addon` field, which is
203
+ `null` unless the event is `held` or the `error` that ends a held wait. See
204
+ [Holding model downloads](https://github.com/tetherto/qvac-harness/blob/v0.8.6/README.md#holding-model-downloads)
205
+ and the
206
+ [decision record](https://github.com/tetherto/qvac-harness/blob/v0.8.6/docs/decisions/2026-10-07-a-load-waits-for-a-download-the-host-holds.md).
207
+
208
+ ```js
209
+ await harness.setDownloadsAllowed({ allowed: false })
210
+
211
+ harness.on('model', (state) => {
212
+ if (state.status === 'held') askToDownload(state.model, state.addon)
213
+ })
214
+
215
+ // later, once the user agrees
216
+ await harness.setDownloadsAllowed({ allowed: true })
217
+ ```
218
+
219
+ (#340)
220
+
37
221
  - Ternary Bonsai 2 27B joins the curated catalog in two builds,
38
222
  `Ternary-Bonsai-2-27B-PQ2_0` (7.2 GB) and `Ternary-Bonsai-2-27B-PTQ1_0`
39
223
  (5.9 GB), each with its vision projection. Both sit in the `high` tier, by
40
- their memory footprint rather than their parameter count. As a Qwen3.8-27B
41
- fine-tune whose name the SDK's dialect router does not recognise, it parses
42
- tool calls as `qwen35`; a catalog entry can now name its tool dialect. (#303)
224
+ their memory footprint rather than their parameter count.
225
+
226
+ It is a Qwen3.8-27B fine-tune whose name the SDK's dialect router does not
227
+ recognise, so it parses tool calls as `qwen35`; a catalog entry can now name
228
+ its tool dialect.
229
+
230
+ ```js
231
+ import { BONSAI_2_27B_PTQ1 } from '@qvac/harness/model-catalog'
232
+
233
+ await harness.loadModel({ model: BONSAI_2_27B_PTQ1, agent: { ctxSize: 8192 } })
234
+ ```
235
+
236
+ (#303)
43
237
 
44
238
  ### Changed
45
239
 
46
240
  - `@qvac/sdk` and `@qvac/inference` 0.21.0, with `@qvac/llm-llamacpp` 0.55.0.
47
- `@qvac/model-fit` leaves the native graph, and `@qvac/fabric` now ships its
48
- prebuilds as per-platform packages (`@qvac/fabric-<os>-<cpu>`), so a host
49
- that packages the addons exempts those names from any install age gate.
50
- The fit check passes the model's source to the SDK's new `loadModel`-shaped
241
+ A host that packages the addons exempts `@qvac/fabric-<os>-<cpu>` from any
242
+ install age gate: `@qvac/fabric` now ships its prebuilds as those
243
+ per-platform packages. `@qvac/model-fit` leaves the native graph. The fit
244
+ check passes the model's source to the SDK's new `loadModel`-shaped
51
245
  `assessModelFit` input. (#302)
52
- - Transcription streams long audio to the model in 300 s segments instead of
53
- decoding the whole clip first, so peak memory no longer grows with the clip's
54
- length (a 3.8 h clip peaked at about 0.9 GB above baseline, down from 1.8
55
- GB). Clips under 60 s are transcribed as before. (#291)
246
+ - Transcribing long audio no longer needs memory in proportion to its length.
247
+ It streams the audio to the model in 300 s segments instead of decoding the
248
+ whole clip first: a 3.8 h clip peaked at about 0.9 GB above baseline, down
249
+ from 1.8 GB. Clips under 60 s are transcribed as before. (#291)
56
250
 
57
251
  ### Fixed
58
252
 
59
- - A run that preloads a skill (`@skill`, or a skill named in prose) caches its
60
- prompt under a child of the chat's key for the length of the run, instead of
61
- prefilling the whole prompt on every round of a tool loop. The child key is
62
- deleted when the run ends. (#305)
253
+ - A run that preloads a skill (`@skill`, or a skill named in prose) no longer
254
+ prefills the whole prompt on every round of a tool loop. It caches its prompt
255
+ under a child of the chat's key for the length of the run, and the child key
256
+ is deleted when the run ends. (#305)
63
257
 
64
258
  ## [0.8.5] - 2026-10-06
65
259
 
66
260
  ### Added
67
261
 
68
- - Skill-gated `pptx`, `docx` and `xlsx` tools on the office engine. A call takes
69
- an `attachmentId` (or `create` options), `ops` that name engine methods with
70
- named arguments, and an `output` file name; with an `attachmentId` alone it
71
- returns the file's contents. The first failing op saves nothing and returns
72
- the engine's error. An edit is never saved under the attached file's own
73
- name. Saving the same file again in one turn answers with the one already
74
- attached, and a changed file under that name is refused. The one-line answer
75
- comes back as `reply`. These are the tools the presentations, word and excel
76
- skills in `@qvac/skills` 0.1.12 call. (#286)
262
+ - The model can now read, edit and create PowerPoint, Word and Excel files
263
+ without Python, on Bare and Node: skill-gated `pptx`, `docx` and `xlsx`
264
+ tools on the office engine. These are the tools the presentations, word and
265
+ excel skills in `@qvac/skills` 0.1.12 call.
266
+
267
+ A call takes an `attachmentId` (or `create` options), `ops` that name engine
268
+ methods with named arguments, and an `output` file name; with an
269
+ `attachmentId` alone it returns the file's contents. The first failing op
270
+ saves nothing and returns the engine's error.
271
+
272
+ An edit is never saved under the attached file's own name. Saving the same
273
+ file again in one turn answers with the one already attached, and a changed
274
+ file under that name is refused. The one-line answer comes back as `reply`.
275
+ See the
276
+ [office engine](https://github.com/tetherto/qvac-harness/blob/v0.8.5/lib/office/engine/README.md)
277
+ for the methods an op can name.
278
+
279
+ ```js
280
+ // the arguments a model passes to the pptx tool
281
+ {
282
+ create: { title: 'Q3 review' },
283
+ ops: [{ op: 'addSlide', title: 'Revenue', body: 'Up 12% on Q2' }],
284
+ output: 'q3-review.pptx'
285
+ }
286
+ ```
287
+
288
+ (#286)
77
289
 
78
290
  ### Changed
79
291
 
80
292
  - **Breaking:** `@qvac/skills` is now 0.1.12, whose office and pdf skills run on
81
293
  the `pptx`, `docx`, `xlsx` and `pdf` tools instead of Python. A host that
82
294
  passes its own `skillsDir` needs 0.1.12 as well: older office skills import
83
- Python libraries this release no longer ships. (#286)
84
- - `extractOfficeText` and `extractDocxText` read .docx, .xlsx and .pptx in
85
- process on the engine instead of starting Pyodide, and read at most 1M cells
295
+ Python libraries this release no longer ships. A host on the bundled skills
296
+ has nothing to do.
297
+
298
+ ```sh
299
+ # a host whose own skillsDir comes from @qvac/skills
300
+ # before
301
+ npm install @qvac/skills@0.1.11
302
+ # after
303
+ npm install @qvac/skills@0.1.12
304
+ ```
305
+
306
+ (#286)
307
+
308
+ - `extractOfficeText` and `extractDocxText` no longer start Pyodide: they read
309
+ .docx, .xlsx and .pptx in process on the engine, and read at most 1M cells
86
310
  across a workbook. Extracted workbook booleans read `true`/`false` instead of
87
311
  `True`/`False`. `closeOfficeWorker` and `closeDocxWorker` remain as no-ops.
88
312
  (#286)
89
313
  - `exec` refuses Python that imports `pptx`, `docx` or `openpyxl`, or declares
90
- their packages, before it runs, and names the tool (or the skill that grants
91
- it) to use instead. (#287)
314
+ their packages, before it runs. The refusal names the tool (or the skill
315
+ that grants it) to use instead, so the model moves to the office tools
316
+ rather than trying to install the package. (#287)
92
317
  - **Breaking:** Python run through `exec` asks for approval like any other
93
- write, including a run that only builds a new PDF. (#289)
318
+ write, including a run that only builds a new PDF. Those runs used to
319
+ skip `approve`; now the user is asked. The `pdf` tool, which builds PDFs now,
320
+ counts as a read and does not ask.
321
+
322
+ ```js
323
+ // before: exec running Python that only built a new PDF skipped approve
324
+ // after: it reaches approve as a write, like any other Python run
325
+ const context = {
326
+ model,
327
+ agent,
328
+ messages,
329
+ approve: async ({ name, effect }) => (effect === 'write' ? askUser(name) : true)
330
+ }
331
+ ```
332
+
333
+ (#289)
94
334
 
95
335
  ### Removed
96
336
 
97
337
  - **Breaking:** the vendored Python wheels for python-pptx, python-docx,
98
338
  openpyxl, fpdf2 and pypdf, and their dependencies. Only micropip is still
99
339
  seeded; any other package installs from PyPI, which sandbox mode refuses.
100
- Use the office and `pdf` tools. (#286, #289)
340
+ Use the office and `pdf` tools.
341
+
342
+ ```js
343
+ // before: exec built a PDF offline with the vendored fpdf2
344
+ { language: 'python', command: 'from fpdf import FPDF\n...', outputs: ['report.pdf'] }
345
+ // after: the pdf tool builds it
346
+ { action: 'create', markdown: '# Report\n...', output: 'report.pdf' }
347
+ ```
348
+
349
+ (#286, #289)
350
+
101
351
  - The `exec` guidance written for python-pptx, python-docx and fpdf2 code:
102
352
  their error hints and source checks, and the shrink-to-fit pass on saved
103
353
  decks. (#288, #289)
@@ -106,42 +356,64 @@ While the major version is `0`, a breaking change bumps the minor version.
106
356
 
107
357
  ### Added
108
358
 
109
- - A skill-gated `pdf` tool on `bare-pdfium` 1.6.0. With an `attachmentId` and
110
- no `action` it reads a PDF: the page count, each page's text and the form
111
- fields. With an `action` it saves a new file named `output` and attaches it:
112
- `create` from Markdown (chat images placed with `![caption](attachmentId)`),
113
- `merge`, `split`, `rotate`, `watermark`, `fill`, `encrypt`, `decrypt` and
114
- `text`. Errors name what exists, a locked PDF takes its password on any
115
- action, and the one-line answer comes back as `reply`. On Node it reports
116
- that the device cannot open PDFs. The pdf skill in the next `@qvac/skills`
117
- release uses it instead of Python. (#256)
118
- - An office engine in `lib/office/engine/` that reads, edits and creates `.pptx`,
119
- `.docx` and `.xlsx` files in TypeScript on Bare and Node, so office editing no
120
- longer needs Python and can run on mobile. It keeps untouched parts byte for
121
- byte, fails with a `DocsError` whose code names the problem, and moves Excel
122
- references when rows and columns change. It adds `@xmldom/xmldom`, makes
123
- `bare-zlib` a direct dependency and maps `#zlib` to `bare-zlib` or `zlib`.
359
+ - The model can read, create and change PDFs without Python: a skill-gated
360
+ `pdf` tool on `bare-pdfium` 1.6.0. The pdf skill in the next `@qvac/skills`
361
+ release uses it instead of Python. On Node it reports that the device cannot
362
+ open PDFs.
363
+
364
+ With an `attachmentId` and no `action` it reads a PDF: the page count, each
365
+ page's text and the form fields. With an `action` it saves a new file named
366
+ `output` and attaches it: `create` from Markdown (chat images placed with
367
+ `![caption](attachmentId)`), `merge`, `split`, `rotate`, `watermark`, `fill`,
368
+ `encrypt`, `decrypt` and `text`.
369
+
370
+ Errors name what exists, a locked PDF takes its password on any action, and
371
+ the one-line answer comes back as `reply`.
372
+
373
+ ```js
374
+ // the arguments a model passes to the pdf tool
375
+ { attachmentId, action: 'rotate', degrees: 90, pages: [2], output: 'scan-rotated.pdf' }
376
+ ```
377
+
378
+ (#256)
379
+
380
+ - An office engine in `lib/office/engine/` reads, edits and creates `.pptx`,
381
+ `.docx` and `.xlsx` files in TypeScript on Bare and Node, so office editing
382
+ no longer needs Python and can run on mobile. It is internal in this release:
383
+ the tools that drive it come in 0.8.5.
384
+
385
+ It keeps untouched parts byte for byte, fails with a `DocsError` whose code
386
+ names the problem, and moves Excel references when rows and columns change.
387
+ It adds `@xmldom/xmldom`, makes `bare-zlib` a direct dependency and maps
388
+ `#zlib` to `bare-zlib` or `zlib`. See the
389
+ [engine's README](https://github.com/tetherto/qvac-harness/blob/v0.8.0/lib/office/engine/README.md)
390
+ and the
391
+ [decision record](https://github.com/tetherto/qvac-harness/blob/v0.8.0/docs/decisions/2026-09-28-office-files-are-edited-by-the-harness-engine.md).
392
+ (#150)
124
393
 
125
394
  ### Fixed
126
395
 
127
- - Qwen3.5-family models: a tool call that ran goes back into the chat history
128
- in the Qwen3.5 template's own format (`<tool_call>` wrapper, one line per tag,
129
- string values as written) instead of a compact `<function=…>` line. A model
130
- several calls into a turn copied the compact form, the tool grammar rejected
131
- it, and the run ended in "This didn't finish". Models with another tool format
132
- (e.g. MedPsy) still get a format that is not their own, until the SDK accepts
133
- structured tool calls in history. (QVAC-23092, #146)
134
- - A skill loaded with a `file` it does not have, before its instructions were
135
- loaded, now returns those instructions with a line naming the files it has,
136
- so the skill counts as loaded and its skill-gated tools appear. Before, a
137
- one-file skill answered that the model already had its instructions, and its
138
- tools stayed hidden. (#257)
139
- - After a tool attaches a file, the loop tells the model the file is shown and
140
- to answer, allows one more tool round, then writes the answer in a tool-less
141
- round. Small models no longer keep calling tools on their own output until
142
- the round cap. (#258)
143
- - `exec`: a `$` before a digit, a space or punctuation in a shell command is
144
- escaped instead of rejected, so text like `$1,299` runs on the first try. Real
396
+ - Qwen3.5-family models no longer stall several tool calls into a turn. A tool
397
+ call that ran goes back into the chat history in the Qwen3.5 template's own
398
+ format (`<tool_call>` wrapper, one line per tag, string values as written)
399
+ instead of a compact `<function=…>` line.
400
+
401
+ A model several calls into a turn copied the compact form, the tool grammar
402
+ rejected it, and the run ended in "This didn't finish". Models with another
403
+ tool format (e.g. MedPsy) still get a format that is not their own, until
404
+ the SDK accepts structured tool calls in history. (QVAC-23092, #146)
405
+
406
+ - A skill's gated tools now appear when the model first asks the skill for a
407
+ file it does not have. Loaded with such a `file` before its instructions were
408
+ loaded, the skill returns those instructions with a line naming the files it
409
+ has, so it counts as loaded. Before, a one-file skill answered that the model
410
+ already had its instructions, and its tools stayed hidden. (#257)
411
+ - Small models no longer keep calling tools on their own output until the round
412
+ cap. After a tool attaches a file, the loop tells the model the file is shown
413
+ and to answer, allows one more tool round, then writes the answer in a
414
+ tool-less round. (#258)
415
+ - `exec`: text like `$1,299` in a shell command runs on the first try. A `$`
416
+ before a digit, a space or punctuation is escaped instead of rejected. Real
145
417
  shell expansion is still refused, now with a hint to single-quote the text.
146
418
  (#208)
147
419
 
@@ -149,223 +421,419 @@ While the major version is `0`, a breaking change bumps the minor version.
149
421
 
150
422
  ### Added
151
423
 
152
- - A tool result may carry `answer: true` beside its `content` to end the turn
153
- with that content as the reply, without another round through the model.
154
- The `tool-result` event still fires. `weather_lookup` uses it for an
155
- ambiguous or unknown place: the question that lists the namesakes, or the
156
- not-found line, is the reply.
424
+ - A tool can now end the turn with its own answer. A tool result may carry
425
+ `answer: true` beside a non-empty `content`, and that content becomes the
426
+ reply without another round through the model. The `tool-result` event still
427
+ fires.
428
+
429
+ `weather_lookup` uses it for an ambiguous or unknown place: the question that
430
+ lists the namesakes, or the not-found line, is the reply.
431
+
432
+ ```js
433
+ const harness = new Harness({
434
+ tools: [
435
+ {
436
+ name: 'store_hours',
437
+ description: 'Opening hours of the store',
438
+ parameters: { type: 'object', properties: {} },
439
+ toolset: null,
440
+ effect: () => 'read',
441
+ execute: async () => ({ content: 'Open 9 to 5, Monday to Saturday.', answer: true })
442
+ }
443
+ ]
444
+ })
445
+ ```
446
+
447
+ (#153)
157
448
 
158
449
  ### Fixed
159
450
 
160
- - `weather_lookup` finds the place before it asks for the weather. It looks the
161
- city up on Open-Meteo's geocoder, `geocoding-api.open-meteo.com`, a new
162
- outbound host, and asks wttr.in for the forecast at the coordinates of the
163
- place it picked, which leads the report. A bare "Nassau" now reports Nassau,
164
- Bahamas. It used to ask wttr.in by name, whose own geocoder put it in
165
- Westbury, New York. A name that places of a similar size share, such as
166
- Springfield, gets a question listing up to four of them instead of a
167
- forecast: any namesake with a tenth of the largest one's population counts.
168
- A country or state in the request picks among them. When the geocoder is
169
- blocked, slow or failing, or knows no such place, the lookup asks wttr.in by
170
- name, as before. The resource label names both hosts.
451
+ - `weather_lookup` reports the place that was asked about: a bare "Nassau" now
452
+ reports Nassau, Bahamas. It used to ask wttr.in by name, whose own geocoder
453
+ put it in Westbury, New York.
454
+
455
+ It now finds the place before it asks for the weather. It looks the city up
456
+ on Open-Meteo's geocoder, `geocoding-api.open-meteo.com`, a new outbound
457
+ host, and asks wttr.in for the forecast at the coordinates of the place it
458
+ picked, which leads the report. The resource label names both hosts.
459
+
460
+ A name that places of a similar size share, such as Springfield, gets a
461
+ question listing up to four of them instead of a forecast: any namesake with
462
+ a tenth of the largest one's population counts. A country or state in the
463
+ request picks among them. When the geocoder is blocked, slow or failing, or
464
+ knows no such place, the lookup asks wttr.in by name, as before. See the
465
+ [decision record](https://github.com/tetherto/qvac-harness/blob/v0.7.3/docs/decisions/2026-09-16-a-lookup-reports-the-place-it-resolved.md).
466
+ (#153)
171
467
 
172
468
  ## [0.7.2] - 2026-10-01
173
469
 
174
470
  ### Added
175
471
 
176
- - `web_fetch` attaches a plain-text file (.txt, .csv, .json…) to the chat
177
- besides returning its text, so the user can open or save it and `exec` can
178
- stage it to read past the returned text. The result carries its
179
- `attachmentId`, `fileName` and `byteLength`. A body cut at the fetch cap is
180
- not attached, and a host that cannot store files gets the text alone, as
181
- with page images. (#147, #148)
472
+ - A plain-text file `web_fetch` fetched (.txt, .csv, .json…) is now attached
473
+ to the chat besides its text being returned, so the user can open or save
474
+ it and `exec` can stage it to read past the returned text. The result
475
+ carries its `attachmentId`, `fileName` and `byteLength`.
476
+
477
+ A body cut at the fetch cap is not attached, and a host that cannot store
478
+ files gets the text alone, as with page images.
479
+
480
+ ```js
481
+ for await (const event of harness.completion(context)) {
482
+ if (event.type === 'tool-result' && event.name === 'web_fetch') {
483
+ const { attachmentId, fileName, byteLength } = event.result
484
+ // attachmentId is undefined when nothing was attached
485
+ }
486
+ }
487
+ ```
488
+
489
+ (#147, #148)
182
490
 
183
491
  ### Changed
184
492
 
185
493
  - Speech sounds cleaner on desktops. Devices on the `mid` profile and up now
186
494
  speak with Supertonic 3 FP16 (`Supertonic3-FP16`, 207 MB) instead of Q8_0
187
495
  (127 MB), at 16 inference steps instead of 5, which removes the artifacts
188
- the 5-step output had. Synthesis takes about 2.7× as long, still around 7×
189
- faster than real time on an M-series Mac. Phones (`low`) and `tiny` devices
190
- keep Q4_0 at 5 steps. A host that prefetches `infraModelsFor` downloads the
191
- FP16 model once; `Supertonic3-Q8_0` stays in the catalog, and now also runs
192
- at 16 steps when a host asks for it by name. (#149)
496
+ the 5-step output had.
497
+
498
+ Synthesis takes about 2.7× as long, still around 7× faster than real time on
499
+ an M-series Mac. Phones (`low`) and `tiny` devices keep Q4_0 at 5 steps.
500
+
501
+ A host that prefetches `infraModelsFor` downloads the FP16 model once;
502
+ `Supertonic3-Q8_0` stays in the catalog, and now also runs at 16 steps when a
503
+ host asks for it by name.
504
+
505
+ ```js
506
+ import { defaultTtsModelName } from '@qvac/harness/model-defaults'
507
+
508
+ defaultTtsModelName('mid') // 'Supertonic3-FP16', was 'Supertonic3-Q8_0'
509
+ ```
510
+
511
+ (#149)
193
512
 
194
513
  ## [0.7.1] - 2026-09-30
195
514
 
196
515
  ### Added
197
516
 
198
- - `list()` rows carry `isLoading`: true from the moment a load of that model
199
- starts, whoever asked for it, until it is ready or the load fails. It is read
200
- off the pool, so a host shows a model as loading without counting loads
201
- itself. A model loading again under other settings while a copy of it is
202
- resident reads `isLoaded` and `isLoading` both true. Chat models read it
203
- today: image, music and translation models load for each generation, and
204
- speech, transcription and embedding pools key by source, so those rows read
205
- `false` for now. An optional field on the wire; a row from an older harness
206
- reads `false`. (#143)
517
+ - A host can show a model as loading without counting loads itself. `list()`
518
+ rows carry `isLoading`: true from the moment a load of that model starts,
519
+ whoever asked for it (a request, `warmModel` or `loadModel`), until it is
520
+ ready or the load fails. It is read off the pool.
521
+
522
+ A model loading again under other settings while a copy of it is resident
523
+ reads `isLoaded` and `isLoading` both true. Chat models read it today. Image,
524
+ music and translation models load for each generation, and speech,
525
+ transcription and embedding pools key by source, so those rows read `false`
526
+ for now. It is an optional field on the wire; a row from an older harness
527
+ reads `false`. See
528
+ [Load and unload a model](https://github.com/tetherto/qvac-harness/blob/v0.7.1/README.md#load-and-unload-a-model).
207
529
 
208
530
  ```js
209
531
  const loading = (await harness.list({ cachedOnly: true })).filter((row) => row.isLoading)
210
532
  ```
211
533
 
534
+ (#143)
535
+
212
536
  ### Changed
213
537
 
214
- - The `model` event carries `catalogChanged` at every edge a `list()` re-read
215
- would show: the `loading` that starts a load and the `ready` or `error` that
216
- ends it, beside `unloaded`. Progress ticks still do not. A host that re-reads
217
- the list on `catalogChanged` sees a model turn loading, then loaded, with no
218
- bookkeeping of its own. The served event stream never drops a frame that
219
- carries it, so two `ready` frames of one model under two keys both cross. (#143)
538
+ - A host that re-reads `list()` on `catalogChanged` sees a model turn loading,
539
+ then loaded, with no bookkeeping of its own. The `model` event carries
540
+ `catalogChanged` at every edge a re-read would show: the `loading` that
541
+ starts a load and the `ready` or `error` that ends it, beside `unloaded`.
542
+ Progress ticks still do not.
543
+
544
+ The served event stream never drops a frame that carries it, so two `ready`
545
+ frames of one model under two keys both cross.
546
+
547
+ ```js
548
+ harness.on('model', async (state) => {
549
+ if (state.catalogChanged) render(await harness.list({ cachedOnly: true }))
550
+ })
551
+ ```
552
+
553
+ (#143)
554
+
220
555
  - `ModelInfo` requires `isLoading`, and `listModels`, `customModelRows` and
221
556
  `mergeCustomRows` return `CatalogRow`, the row before the pools are asked. A
222
- typed fake `list()` adds the field. (#143)
557
+ typed fake `list()` adds the field.
558
+
559
+ ```js
560
+ // before
561
+ list: async () => [{ ...row, isLoaded: false }]
562
+
563
+ // after
564
+ list: async () => [{ ...row, isLoaded: false, isLoading: false }]
565
+ ```
566
+
567
+ (#143)
568
+
223
569
  - A custom model's row reads `isLoaded` only once its load has landed; it used
224
570
  to read `true` from the first attempt. (#143)
225
571
 
226
572
  ### Fixed
227
573
 
228
- - A load that ends without the model in memory emits `model` with `error` and
229
- the reason: it failed, or a suspend or close cancelled it. It used to end in
230
- silence, so a host could not tell a failed load from one still running. The
231
- `error` also refreshes the cached `isCached`, since a load may have downloaded
232
- the weights before it failed. (#143)
574
+ - A host can tell a failed load from one still running. A load that ends
575
+ without the model in memory emits `model` with `error` and the reason in
576
+ `message`: it failed, or a suspend or close cancelled it. It used to end in
577
+ silence. The `error` also refreshes the cached `isCached`, since a load may
578
+ have downloaded the weights before it failed. (#143)
233
579
 
234
580
  ## [0.7.0] - 2026-09-29
235
581
 
236
582
  ### Added
237
583
 
238
- - `list()` rows carry `taskType`, what the model is for: `llm`, `embeddings`,
239
- `speech-to-text`, `text-to-speech`, `image-generation`, `audio-generation`,
240
- `translation`, `text-recognition` or `other`. It is decided from the addon in
241
- one place, `lib/models/model-task.ts`, on each side of the wire rather than
242
- sent over it. `ModelTaskType` is exported, and grows the four new values a
243
- preview never returns. (#140)
584
+ - A host can tell what each model is for without keeping its own table.
585
+ `list()` rows carry `taskType`: `llm`, `embeddings`, `speech-to-text`,
586
+ `text-to-speech`, `image-generation`, `audio-generation`, `translation`,
587
+ `text-recognition` or `other`.
588
+
589
+ It is decided from the addon in one place, `lib/models/model-task.ts`, on
590
+ each side of the wire rather than sent over it. `ModelTaskType` is exported,
591
+ and grows the four new values a preview never returns.
592
+
593
+ ```js
594
+ const rows = await harness.list({ cachedOnly: true })
595
+ const transcribers = rows.filter((row) => row.taskType === 'speech-to-text')
596
+ ```
597
+
598
+ (#140)
244
599
 
245
- - `modelsForAgent(agent, host)` lists every model an agent runs on a device,
600
+ - `modelsForAgent(agent, host)` answers which models an agent runs on a device,
246
601
  each with its `use`: the chat model, clamped by `runnableModelName`, then the
247
602
  image and music models its skills need. A host deciding what to download, or
248
603
  which agents a model delete affects, asks this instead of working it out.
249
- `runnableModelName(requested, host)` and `isMobilePlatform(platform)` move here
250
- from `@qvac/core`: on a phone a pick above the device's profile runs as its
251
- recommended model. Whether the host is a phone is the required `mobile` on
252
- `HostCapability`, which `capability()` now reports, so its answer passes
253
- straight in; a host built by hand has to say. Both functions are on the
254
- browser-safe `@qvac/harness/model-catalog` entry too. (#141)
604
+
605
+ `runnableModelName(requested, host)` and `isMobilePlatform(platform)` move
606
+ here from `@qvac/core`: on a phone, a pick above the device's profile runs as
607
+ its recommended model. Whether the host is a phone is the required `mobile`
608
+ on `HostCapability`, which `capability()` now reports, so its answer passes
609
+ straight in; a host built by hand has to say. `runnableModelName` and
610
+ `isMobilePlatform` are on the browser-safe `@qvac/harness/model-catalog`
611
+ entry too.
255
612
 
256
613
  ```js
257
- const chat = modelsForAgent(agent, await harness.capability()).find((m) => m.use === 'chat')
614
+ import { modelsForAgent, QWEN_35_9B } from '@qvac/harness'
615
+
616
+ const agent = {
617
+ modelName: QWEN_35_9B,
618
+ installedSkills: ['image-generation'],
619
+ skillsScope: 'all'
620
+ }
621
+ const models = modelsForAgent(agent, await harness.capability())
622
+ // [{ name, addon: 'llm', use: 'chat' }, { name, addon, use: 'image' }]
623
+ ```
624
+
625
+ ```js
626
+ import { runnableModelName, QWEN_35_4B, QWEN_35_9B } from '@qvac/harness/model-catalog'
627
+
628
+ const host = { capabilityProfile: 'mid', recommendedModelName: QWEN_35_4B, mobile: true }
629
+ runnableModelName(QWEN_35_9B, host) // QWEN_35_4B
630
+ ```
631
+
632
+ (#141)
633
+
634
+ - `capability()` answers `mobile`, whether the harness runs on a phone, read
635
+ off the platform it probes. It rides `capability-response` as an optional
636
+ field; a response from an older harness reads as `false`.
637
+
638
+ ```js
639
+ const { mobile } = await harness.capability()
258
640
  ```
259
641
 
260
- - `capability()` answers `mobile`, whether the harness runs on a phone, read off
261
- the platform it probes. It rides `capability-response` as an optional field;
262
- a response from an older harness reads as `false`. (#141)
642
+ (#141)
643
+
644
+ - A host no longer needs to know what `modelChoice` means in an agent's
645
+ `generate_music` tool settings. `musicModelChoiceFrom(settings)` reads the
646
+ music model pick out of them, and `isMusicModelChoice` is exported beside it.
647
+
648
+ ```js
649
+ import { musicModelChoiceFrom } from '@qvac/harness/music-catalog'
650
+
651
+ musicModelChoiceFrom({ modelChoice: 'turbo-hq' }) // 'turbo-hq'
652
+ musicModelChoiceFrom({}) // undefined
653
+ ```
263
654
 
264
- - `musicModelChoiceFrom(settings)` reads the music model pick out of an agent's
265
- `generate_music` tool settings, so a host no longer knows what `modelChoice`
266
- means there. `isMusicModelChoice` is exported beside it. (#141)
655
+ (#141)
267
656
 
268
657
  ### Changed
269
658
 
270
- - **Breaking:** the package no longer publishes a prebuilt sidecar:
659
+ - **Breaking:** the package no longer publishes a prebuilt sidecar.
271
660
  `dist/harness.bundle`, `dist/harness.cjs`, `dist/prebuilds-manifest.json` and
272
661
  the `./dist/harness.cjs` export are gone. A host stows
273
662
  `worker/harness-sidecar.mjs` itself, as desktop and mobile already do, or
274
- spawns `worker/stow-entry.mjs` straight from its install. (#127)
275
- - **Breaking:** a stowed harness runs python in a worker rather than in-process.
276
- A host that stows the harness stops deferring `pyodide`, keeps
277
- `QVAC_PYODIDE_CACHE_DIR` writable, no longer stages a separate worker bundle,
278
- and ships what stow offloads beside the bundle, which now includes pyodide's
279
- wasm, stdlib and lock file and the vendored wheels. A target that embeds
280
- assets instead of offloading them cannot run python, since `fs` cannot read
281
- inside a bundle. (#127)
282
- - A bundle of the package root carries neither pyodide nor the vendored wheels.
283
- Only an entry that runs python, the exec tool or `@qvac/harness/docx` and
284
- `/office`, pulls them in. (#127)
663
+ spawns `worker/stow-entry.mjs` straight from its install. See
664
+ [The published package](https://github.com/tetherto/qvac-harness/blob/v0.7.0/README.md#the-published-package)
665
+ and [Spawning](https://github.com/tetherto/qvac-harness/blob/v0.7.0/README.md#spawning).
666
+
667
+ ```js
668
+ // before
669
+ const harness = require('@qvac/harness/dist/harness.cjs')
670
+ const { ipc, sidecar, exit } = await harness.start(args)
671
+
672
+ // after
673
+ import { spawnHarness } from '@qvac/harness'
674
+
675
+ const harness = spawnHarness({
676
+ entry: require.resolve('@qvac/harness/worker/stow-entry.mjs'),
677
+ sdkStorage
678
+ })
679
+ ```
680
+
681
+ (#127)
682
+
683
+ - **Breaking:** a stowed harness runs python in a worker rather than
684
+ in-process. A host that stows the harness:
685
+
686
+ - stops deferring `pyodide`
687
+ - keeps `QVAC_PYODIDE_CACHE_DIR` writable
688
+ - no longer stages a separate worker bundle
689
+ - ships what stow offloads beside the bundle, which now includes pyodide's
690
+ wasm, stdlib and lock file and the vendored wheels
691
+
692
+ A target that embeds assets instead of offloading them cannot run python,
693
+ since `fs` cannot read inside a bundle. See
694
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.7.0/docs/decisions/2026-09-25-the-python-worker-runs-on-bare-worker.md).
695
+ (#127)
696
+
697
+ - A bundle of the package root carries neither pyodide nor the vendored
698
+ wheels. Only an entry that runs python pulls them in: the exec tool, or
699
+ `@qvac/harness/docx` and `/office`. (#127)
285
700
  - `SandboxHook` no longer checks `packages`. The exec tool refuses an install
286
701
  the vendored wheels do not hold in full in sandbox mode, whether declared in
287
702
  `packages` or on a pip line, with the same message as before. (#127)
288
703
  - The vendored wheels are the `#pyodide-wheels` directory asset on Bare;
289
704
  `QVAC_PYTHON_WHEELS_DIR` still overrides it. (#127)
290
705
  - The python worker and the in-process deadline are spawned through
291
- `worker_threads`, which the package maps to `bare-worker`;
706
+ `worker_threads`, which the package maps to `bare-worker`.
292
707
  `#pyodide-worker-spawn` is gone. (#127)
293
- - **Breaking:** `engines.bare` is `>=1.34.0`, so Bare refuses to load the
294
- package on older runtimes. `bare-runtime` is pinned to 1.34.0 and
295
- `bare-sidecar` to 0.5.7 (Bare 1.34.0) across the tree, `bare-supervisor`'s copy
296
- included, with `bare-thread` at least 1.2.7 and `bare-module` 7.1.0 for
297
- `bare-worker`'s copy. (#127)
708
+ - **Breaking:** Bare refuses to load the package on runtimes older than 1.34.0:
709
+ `engines.bare` is `>=1.34.0`. `bare-runtime` is pinned to 1.34.0 and
710
+ `bare-sidecar` to 0.5.7 (Bare 1.34.0) across the tree, `bare-supervisor`'s
711
+ copy included, with `bare-thread` at least 1.2.7 and `bare-module` 7.1.0 for
712
+ `bare-worker`'s copy. A host upgrades its Bare to 1.34.0 or later. (#127)
298
713
  - `bare-module` is no longer a dependency or an override, and
299
- `bare-module-traverse` is overridden to 2.5.8: earlier versions drop the
714
+ `bare-module-traverse` is overridden to `~2.5.8`: earlier versions drop the
300
715
  `asset` condition on `bare-sidecar`'s binary, and a stowed harness then fails
301
716
  to boot on Bare 1.33 and later. (#127)
302
717
  - `skillModelsForAgent` rows carry `use`, `image` or `music`, so a caller can
303
- tell which skill a model is for without looking the name up in the catalogs. (#141)
304
- - `CapabilityInfo` has a required `mobile`, so a `HarnessContract` whose
305
- `capability()` builds its own answer, a test stub included, returns it too. (#141)
718
+ tell which skill a model is for without looking the name up in the catalogs.
719
+ (#141)
720
+ - `CapabilityInfo` has a required `mobile`. A `HarnessContract` whose
721
+ `capability()` builds its own answer, a test stub included, returns it too.
722
+
723
+ ```js
724
+ // before
725
+ capability: async () => ({ capabilityProfile: 'mid', recommendedModelName: QWEN_35_4B })
726
+
727
+ // after
728
+ capability: async () => ({
729
+ capabilityProfile: 'mid',
730
+ recommendedModelName: QWEN_35_4B,
731
+ mobile: false
732
+ })
733
+ ```
734
+
735
+ (#141)
306
736
 
307
737
  ### Removed
308
738
 
309
- - **Breaking:** `bareRuntime` is no longer exported from `./lib/tools/exec.ts`.
310
- It is at `./lib/tools/exec/bare-runtime.ts`, so the package root no longer
311
- loads the exec tool to reach it. (#127)
312
- - **Breaking:** `QVAC_PYODIDE_TEST_LOADER`: tests point
313
- `QVAC_PYODIDE_WORKER_ENTRY` at an entry that serves their fake interpreter. (#127)
739
+ - **Breaking:** `bareRuntime` is no longer exported from `./lib/tools/exec.ts`,
740
+ so the package root no longer loads the exec tool to reach it. It is at
741
+ `./lib/tools/exec/bare-runtime.ts`.
742
+
743
+ ```js
744
+ // before
745
+ import { bareRuntime } from '@qvac/harness/lib/tools/exec.ts'
746
+
747
+ // after
748
+ import { bareRuntime } from '@qvac/harness/lib/tools/exec/bare-runtime.ts'
749
+ ```
750
+
751
+ (#127)
752
+
753
+ - **Breaking:** `QVAC_PYODIDE_TEST_LOADER` is gone. A test points
754
+ `QVAC_PYODIDE_WORKER_ENTRY` at an entry that serves its fake interpreter.
755
+
756
+ ```sh
757
+ # before
758
+ QVAC_PYODIDE_TEST_LOADER=/path/to/fake-loader.js
759
+ # after
760
+ QVAC_PYODIDE_WORKER_ENTRY=/path/to/fake-worker-entry.js
761
+ ```
762
+
763
+ (#127)
314
764
 
315
765
  ### Fixed
316
766
 
317
- - Python runs start on Bare 1.34, whose threads cannot read a module off disk,
318
- including in a stowed harness. The worker is spawned through `worker_threads`,
319
- which the package maps to `bare-worker` on Bare, pyodide is handed its asm
320
- module as `createPyodideModule`, and the files pyodide reads are named as
321
- assets, so a bundler carries them beside the bundle. (#127)
767
+ - Python runs start on Bare 1.34, including in a stowed harness. Threads there
768
+ cannot read a module off disk, so the worker is spawned through
769
+ `worker_threads`, which the package maps to `bare-worker` on Bare. Pyodide is
770
+ handed its asm module as `createPyodideModule`, and the files pyodide reads
771
+ are named as assets, so a bundler carries them beside the bundle. (#127)
322
772
 
323
773
  ## [0.6.0] - 2026-09-28
324
774
 
325
775
  ### Added
326
776
 
327
- - `loadModel({ model, agent })` loads a downloaded chat, embedding, speech or
328
- transcription model ahead of use, under the key a request for it would load
329
- under, so the first request finds it loaded. `unloadModel({ model })` unloads
330
- every copy of a model once the requests holding it finish, and says whether it
331
- could. Image and music models load for each generation and are refused. A load
332
- or an unload counts as a call in flight for `setModelIdleTimeout`, and one that
333
- lands during an idle unload waits for it. Also on the wire as `load-model` and
334
- `unload-model`. (#133)
777
+ - A host can load a model before anyone asks for it, and unload it on demand.
778
+ `loadModel({ model, agent })` loads a downloaded chat, embedding, speech or
779
+ transcription model under the key a request for it would load under, so the
780
+ first request finds it loaded. Pass the agent a chat model will run for: its
781
+ context size and KV cache types are part of that key.
782
+
783
+ `unloadModel({ model })` unloads every copy of a model once the requests
784
+ holding it finish, and says whether it could. Image and music models load for
785
+ each generation and are refused.
786
+
787
+ A load or an unload counts as a call in flight for `setModelIdleTimeout`, and
788
+ one that lands during an idle unload waits for it. Both are on the wire as
789
+ `load-model` and `unload-model`. See
790
+ [Load and unload a model](https://github.com/tetherto/qvac-harness/blob/v0.6.0/README.md#load-and-unload-a-model).
335
791
 
336
792
  ```js
793
+ import { QWEN_35_4B } from '@qvac/harness'
794
+
337
795
  const { loaded } = await harness.loadModel({ model: QWEN_35_4B, agent: { ctxSize: 8192 } })
338
796
  const { unloaded } = await harness.unloadModel({ model: QWEN_35_4B })
339
797
  ```
340
798
 
341
- - A chat model loaded with `loadModel` is pinned: it does not count against
342
- `completionModelCap`, so several stay loaded at once and a request for
343
- another model leaves them in place. A manual load never unloads another model
344
- to fit; it returns `{ loaded: false, reason: 'memory' }` when the SDK's
345
- `assessModelFit` says it would not fit beside what is loaded. A request always
346
- gets its model: pinned models no reply is using give way oldest first when it would not fit,
347
- and a request or a manual load for a model under other settings replaces its idle copy. Image and
348
- music generation and an idle unload still clear every idle chat model. (#134)
799
+ (#133)
800
+
801
+ - Several chat models can stay loaded at once. A chat model loaded with
802
+ `loadModel` is pinned: it does not count against `completionModelCap`, and a
803
+ request for another model leaves it in place.
804
+
805
+ A manual load never unloads another model to fit. It returns
806
+ `{ loaded: false, reason: 'memory' }` when the SDK's `assessModelFit` says it
807
+ would not fit beside what is loaded, so the host unloads one first. A request
808
+ always gets its model: when it would not fit, pinned models no reply is using
809
+ give way oldest first. A request or a manual load for a model under other
810
+ settings replaces its idle copy. Image and music generation and an idle
811
+ unload still clear every idle chat model, pinned or not. See
812
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.6.0/docs/decisions/2026-09-25-a-model-loaded-ahead-of-use-lands-where-its-request-looks.md).
349
813
 
350
814
  ```js
815
+ import { QWEN_35_9B } from '@qvac/harness'
816
+
351
817
  const { loaded, reason } = await harness.loadModel({ model: QWEN_35_9B })
352
818
  if (reason === 'memory') showNotice('Unload a model first')
353
819
  ```
354
820
 
821
+ (#134)
822
+
355
823
  ### Changed
356
824
 
357
- - A model loading or unloading in any pool emits `model` with `catalogChanged`,
358
- not only a chat model, so `list()` reports a fresh `isLoaded` for embedding,
359
- speech and transcription models too. (#133)
360
- - Speech, transcription and embedding requests key their pool entry by the
361
- model's registry src however the caller names it, so a model named once by
362
- its catalog name and once by its src is one copy in memory. (#133)
825
+ - `list()` reports a fresh `isLoaded` for embedding, speech and transcription
826
+ models, not only chat models. A model loading or unloading in any pool now
827
+ emits `model` with `catalogChanged`. (#133)
828
+ - A speech, transcription or embedding model named once by its catalog name and
829
+ once by its src is one copy in memory. Those requests key their pool entry by
830
+ the model's registry src however the caller names it. (#133)
363
831
 
364
832
  ### Fixed
365
833
 
366
834
  - A chat model loads when the host passes no `llmDevice`. 0.5.0 sent `device`,
367
835
  `gpu_layers`, `main-gpu` and `openclCacheDir` to the SDK as `undefined`,
368
- which the SDK spreads over its defaults: with no device the load failed with
836
+ which the SDK spreads over its defaults. With no device the load failed with
369
837
  "must specify a device: 'gpu' or 'cpu'", and with a device but no
370
838
  `llmGpuLayers` the default of 99 GPU layers was lost. Unset fields are left
371
839
  out again. (#137)
@@ -374,25 +842,39 @@ While the major version is `0`, a breaking change bumps the minor version.
374
842
 
375
843
  ### Added
376
844
 
377
- - `setModelIdleTimeout(ms)` frees memory once the device has been idle that
378
- long: with no `completion`, `invoke`, `embed`, `transcribe`, `speak`,
845
+ - `setModelIdleTimeout(ms)` frees memory on a device that has sat idle that
846
+ long. With no `completion`, `invoke`, `embed`, `transcribe`, `speak`,
379
847
  `speakStream` or `warmModel` call in flight, every idle resident model is
380
- unloaded, including the image, music and translation runtimes. The next call
381
- loads what it needs again. `0`, the default, is Never, and arms no timer. A
382
- turn waiting on a tool or an approval between rounds counts as busy, so it
383
- keeps its model. Also on the wire as `set-model-idle-timeout`. (#131)
848
+ unloaded, including the image, music and translation runtimes, and the next
849
+ call loads what it needs again.
850
+
851
+ The clock is device-wide: any call resets it, whichever model it uses. `0`,
852
+ the default, is Never and arms no timer. A turn waiting on a tool or an
853
+ approval between rounds counts as busy, so it keeps its model. The call is on
854
+ an in-process `Harness`, on the `RemoteHarness` client, and on the wire as
855
+ `set-model-idle-timeout`. See
856
+ [Freeing memory when idle](https://github.com/tetherto/qvac-harness/blob/v0.5.0/README.md#freeing-memory-when-idle)
857
+ and
858
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.5.0/docs/decisions/2026-09-25-idle-unload-is-opt-in-and-device-wide.md).
384
859
 
385
860
  ```js
386
- await harness.setModelIdleTimeout(30 * 60_000)
861
+ await harness.setModelIdleTimeout(30 * 60_000) // 0 turns it off
387
862
  ```
388
863
 
864
+ (#131)
865
+
389
866
  - `setCustomModels` entries take an optional `label`, the name the user gave
390
- the model, and `list()` rows carry it as `label`. The tool-call format is
391
- matched against the label as well as the file name, so a custom model
392
- registered under an opaque id still gets its family's format. (#124)
867
+ the model, and `list()` rows carry it back as `label`.
868
+
869
+ The label also picks the tool-call format: the model family (Qwen, Gemma,
870
+ LFM and so on) is matched against the label as well as the file name, with
871
+ spaces read as hyphens, so a custom model registered under an opaque id
872
+ still gets its family's format. (#124)
393
873
 
394
874
  - `setCustomModels` entries take an optional `contextLength`, the model's
395
- trained window. (#124)
875
+ trained window, which caps the context it loads with by default (see
876
+ Changed). Pass the preview's `contextLength` straight through. See
877
+ [Register the set](https://github.com/tetherto/qvac-harness/blob/v0.5.0/README.md#register-the-set).
396
878
 
397
879
  ```js
398
880
  await harness.setCustomModels([
@@ -403,23 +885,39 @@ While the major version is `0`, a breaking change bumps the minor version.
403
885
  contextLength: preview.contextLength
404
886
  }
405
887
  ])
888
+
889
+ const [row] = (await harness.list({ type: 'llm' })).filter(
890
+ (model) => model.name === 'custom-7f3a'
891
+ )
892
+ row.label // 'Gemma 4 E4B'
406
893
  ```
407
894
 
895
+ (#124)
896
+
408
897
  ### Changed
409
898
 
410
899
  - A custom model loads with a context of the smaller of 51,200 tokens and its
411
- trained `contextLength`, instead of the default. An asked-for context size
900
+ trained `contextLength`, where it always took 51,200 before. A model trained
901
+ on 40,960 tokens now loads with 40,960. A context size the caller asks for
412
902
  still wins. (#124)
413
903
 
414
904
  ### Fixed
415
905
 
416
906
  - `previewModelSource` takes a direct link to one shard of a split `.gguf` as
417
- the whole set: `src` is the first part, `sizeBytes` adds up every part, and
418
- the label names the count, as in `model.gguf (3 parts)`. A missing part
419
- refuses with `not-found` or `unreachable`. (#124)
420
-
421
- - `isCached` reports a sharded custom download once every shard is on disk,
422
- and `deleteModel` removes all of its shards. (#124)
907
+ the whole set. `src` is the first part, which holds the metadata, and
908
+ `sizeBytes` adds up every part, so `isCached` can match the finished
909
+ download. Every part is checked with a `HEAD` request, and a missing part
910
+ refuses with `not-found` or `unreachable`, naming the part. See
911
+ [Preview a source before adding it](https://github.com/tetherto/qvac-harness/blob/v0.5.0/README.md#preview-a-source-before-adding-it).
912
+ (#124)
913
+
914
+ - `isCached` reports a sharded custom download once every shard is on disk in
915
+ the folder the SDK keeps them in; it always read false before. `deleteModel`
916
+ removes all of its shards, where it left them behind. See
917
+ [List them](https://github.com/tetherto/qvac-harness/blob/v0.5.0/README.md#list-them)
918
+ and
919
+ [Delete a download](https://github.com/tetherto/qvac-harness/blob/v0.5.0/README.md#delete-a-download).
920
+ (#124)
423
921
 
424
922
  ## [0.4.0] - 2026-09-24
425
923
 
@@ -428,27 +926,48 @@ While the major version is `0`, a breaking change bumps the minor version.
428
926
  - `previewModelSource({ src, mmprojSrc })` checks a Hugging Face link, a direct
429
927
  `.gguf` link or a local path before a host adds it as a custom model. It
430
928
  reads the Hugging Face metadata and the file's GGUF header, never the whole
431
- model. The answer is a file to register (`src`, `sizeBytes`,
432
- `contextLength`, `architecture`, `sizeLabel`, `taskType`, `license`, `fit`
433
- and `fitReasons`), a list of `files` to pick from for a repo that holds
434
- several, or `ok: false` with a `refusal` code and a `reason` to show. It is
435
- served on the `RemoteHarness` client and on an in-process `Harness`, and its
436
- types are exported from the package root. (#121)
929
+ model.
930
+
931
+ The answer is one of three:
932
+
933
+ - a file to register: `ok: true` with `src`, `sizeBytes`, `contextLength`,
934
+ `architecture`, `sizeLabel`, `taskType`, `license`, `fit` and
935
+ `fitReasons`;
936
+ - a choice: `ok: true` with a list of `files` and no `src`, for a repo that
937
+ holds several weights;
938
+ - a refusal: `ok: false` with a `refusal` code and a `reason` to show.
939
+
940
+ It is served on the `RemoteHarness` client and on an in-process `Harness`,
941
+ and its types (`ModelSourcePreview`, `ModelSourceFile`,
942
+ `ModelSourceRefusal`, `ModelFitVerdict`, `PreviewModelSourceContext`) are
943
+ exported from the package root. See
944
+ [Preview a source before adding it](https://github.com/tetherto/qvac-harness/blob/v0.4.0/README.md#preview-a-source-before-adding-it)
945
+ and
946
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.4.0/docs/decisions/2026-09-22-a-model-source-is-previewed-before-it-is-added.md).
437
947
 
438
948
  ```js
439
949
  const preview = await harness.previewModelSource({ src: link })
440
950
 
441
951
  if (!preview.ok) return showError(preview.reason)
442
- if (!preview.src) return pickOneOf(preview.files) // then preview files[i].src
443
- if (preview.taskType !== 'llm') return showError('Only an llm runs as a custom model')
952
+ if (!preview.src) return pickOneOf(preview.files) // preview the pick next
953
+ if (preview.taskType !== 'llm') {
954
+ return showError('Only an llm runs as a custom model')
955
+ }
444
956
  ```
445
957
 
446
- - `setCustomModels` entries take `taskType` and `expectedSize`, and `list()`
447
- returns the registered custom models after the catalog rows, with
448
- `registrySource: 'custom'`, `isLoaded`, and an `isCached` that is false
449
- until every file is on disk and, when `expectedSize` is set, adds up to it.
450
958
  (#121)
451
959
 
960
+ - `setCustomModels` entries take `taskType` and `expectedSize`, and `list()`
961
+ returns the registered custom models after the catalog rows, under the same
962
+ `type` and `cachedOnly` filters.
963
+
964
+ A custom row has `registrySource: 'custom'` and `isLoaded`. Its `isCached`
965
+ is false until every file is on disk and, when `expectedSize` is set, their
966
+ sizes add up to it, so a partial download is not cached. See
967
+ [Register the set](https://github.com/tetherto/qvac-harness/blob/v0.4.0/README.md#register-the-set)
968
+ and
969
+ [List them](https://github.com/tetherto/qvac-harness/blob/v0.4.0/README.md#list-them).
970
+
452
971
  ```js
453
972
  await harness.setCustomModels([
454
973
  {
@@ -464,56 +983,90 @@ While the major version is `0`, a breaking change bumps the minor version.
464
983
  const custom = models.filter((model) => model.registrySource === 'custom')
465
984
  ```
466
985
 
986
+ (#121)
987
+
467
988
  - A `modelsDir` Harness option says where downloads live, so `list()` and
468
989
  `deleteModel` can find a custom model's files. The spawned sidecar sets it
469
- from its SDK storage; an in-process `Harness` has to pass it. (#121)
990
+ from its SDK storage; an in-process `Harness` has to pass it. See
991
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.4.0/docs/decisions/2026-09-24-a-custom-download-is-found-at-the-sdk-cache-path.md).
470
992
 
471
993
  ```js
472
994
  const harness = new Harness({ modelsDir })
473
995
  ```
474
996
 
997
+ (#121)
998
+
475
999
  ### Changed
476
1000
 
477
- - `deleteModel(name)` removes the weight and projection a custom model
478
- downloaded, where it used to remove nothing. It never touches a local path
479
- the user pointed at, and the model stays registered, listed as not cached.
480
- (#121)
1001
+ - `deleteModel(name)` frees the disk a custom model's download took: it
1002
+ removes the weight and projection the model downloaded, where it used to
1003
+ remove nothing.
1004
+
1005
+ It never touches a local path the user pointed at, since those are the
1006
+ user's files. The model stays registered and lists as not cached, ready to
1007
+ download again. See
1008
+ [Delete a download](https://github.com/tetherto/qvac-harness/blob/v0.4.0/README.md#delete-a-download).
481
1009
 
482
1010
  ```js
483
1011
  const { removed, freedBytes } = await harness.deleteModel('nanbeige-3b')
484
1012
  ```
485
1013
 
1014
+ (#121)
1015
+
486
1016
  - A custom model name resolves only where an `llm` is loaded, never for
487
- another task, and a `setCustomModels` entry whose `taskType` is anything
488
- other than `llm` is not registered. An entry without `taskType` is an `llm`,
489
- as before. (#121)
1017
+ another task. A `setCustomModels` entry whose `taskType` is anything other
1018
+ than `llm` is not registered. An entry without `taskType` is an `llm`, as
1019
+ before, so a host that registers only text models changes nothing. (#121)
490
1020
 
491
1021
  ### Fixed
492
1022
 
493
- - A turn that preloads a skill with `@skill`, or names an active skill in its
494
- prompt, no longer caches the inlined SKILL.md body under the chat's KV key.
495
- Each such turn left about 7k tokens stranded in the cache, until a later turn
496
- overflowed the context window and failed. (#120)
1023
+ - A chat no longer fails with a context overflow after turns that load a
1024
+ skill up front. A turn that preloads a skill with `@skill`, or names an
1025
+ active skill in its prompt, used to cache the inlined SKILL.md body under
1026
+ the chat's KV key. Each such turn left about 7k tokens stranded in the
1027
+ cache, until a later turn overflowed the context window and failed. (#120)
497
1028
 
498
1029
  ## [0.3.3] - 2026-09-24
499
1030
 
500
1031
  ### Changed
501
1032
 
502
- - `@qvac/sdk` and `@qvac/inference` 0.20.0. Some addons now ship their
503
- prebuilds as per-platform packages (`@qvac/<addon>-<os>-<cpu>`, for
504
- `audiogen-ggml` 0.4 and `tts-ggml` 0.9), which the addon names in its
505
- `#host-addon` import map, and `@qvac/model-fit` and `@qvac/fabric` join the
506
- native graph as deferred addons. A host that packages the addons resolves
507
- prebuilds through that map, and exempts the new package names from any
508
- install age gate: bun skips a blocked optional dependency without a word,
509
- and the prebuild goes missing. (#85)
1033
+ - `@qvac/sdk` and `@qvac/inference` 0.20.0. A host that packages the addons
1034
+ has packaging work to do.
1035
+
1036
+ Some addons now ship their prebuilds as per-platform packages
1037
+ (`@qvac/<addon>-<os>-<cpu>`, for `asr-ggml` 0.5, `audiogen-ggml` 0.4 and
1038
+ `tts-ggml` 0.9), which the addon names in its `#host-addon` import map, and
1039
+ `@qvac/model-fit` and `@qvac/fabric` join the native graph as deferred
1040
+ addons. The harness no longer pins `@qvac/asr-ggml` and lets the SDK resolve
1041
+ it.
1042
+
1043
+ A host that packages the addons resolves prebuilds through that map, and
1044
+ exempts the new package names from any install age gate: bun skips a blocked
1045
+ optional dependency without a word, and the prebuild goes missing. See
1046
+ [The published package](https://github.com/tetherto/qvac-harness/blob/v0.3.3/README.md#the-published-package).
1047
+
1048
+ ```toml
1049
+ # bunfig.toml, for each host the app ships to
1050
+ [install]
1051
+ minimumReleaseAgeExcludes = [
1052
+ "@qvac/model-fit",
1053
+ "@qvac/asr-ggml-darwin-arm64",
1054
+ "@qvac/audiogen-ggml-darwin-arm64",
1055
+ "@qvac/tts-ggml-darwin-arm64"
1056
+ ]
1057
+ ```
1058
+
1059
+ (#85)
1060
+
510
1061
  - A user message with several images reaches the SDK as one turn again. The
511
1062
  per-image split added for QVAC-24156 is gone, since SDK 0.20 carries the
512
1063
  upstream fix (llama.cpp #27348) and equal-size still images no longer merge
513
- as video frames and abort the encode. This release must not be paired with
514
- SDK 0.19, where that crash comes back. `<key>-vision-history-v1` cache
515
- directories left by the split are no longer read, and clearing all caches
516
- removes them. (#116)
1064
+ as video frames and abort the encode.
1065
+
1066
+ This release must not be paired with SDK 0.19, where that crash comes back.
1067
+ `<key>-vision-history-v1` cache directories left by the split are no longer
1068
+ read, and clearing all caches removes them. (#116)
1069
+
517
1070
  - `@qvac/skills` 0.1.9. The diagrams skill carries every recipe inline, so a 2B
518
1071
  model no longer loops on loading a reference file it cannot name. The excel
519
1072
  skill routes a chat image to create rather than edit, the Google OAuth routes
@@ -531,16 +1084,21 @@ While the major version is `0`, a breaking change bumps the minor version.
531
1084
 
532
1085
  ### Changed
533
1086
 
534
- - A networked Python run's approval label leads with the hosts its source names
535
- in a URL, as `python · network: www.youtube.com …`, or says
1087
+ - A person approving a networked Python run now sees where it will connect,
1088
+ and any other host is asked about as the run reaches it.
1089
+
1090
+ The approval label leads with the hosts the source names in a URL, as
1091
+ `python · network: www.youtube.com …`, or says
536
1092
  `network: no host named in the code`. On an approved run those hosts, and
537
- their `www.` form, are reached without asking; a subdomain is not. Any other
538
- host, such as a CDN a library fans out to, a redirect or a URL built at
539
- runtime, pauses the run and is asked about through the completion's
1093
+ their `www.` form, are reached without asking; a subdomain is not.
1094
+
1095
+ Any other host, such as a CDN a library fans out to, a redirect or a URL
1096
+ built at runtime, pauses the run and is asked about through the completion's
540
1097
  `askUser` before its name is looked up. A host allowed once stays allowed
541
1098
  for that run, and silence past a minute counts as a no. A host that does not
542
1099
  wire `askUser` refuses every unnamed host, and a skip-all run asks nothing.
543
- (#109)
1100
+ See
1101
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.3.2/docs/decisions/2026-09-22-python-reaches-the-network-through-the-guarded-fetch.md).
544
1102
 
545
1103
  ```js
546
1104
  for await (const event of harness.completion({
@@ -555,52 +1113,85 @@ While the major version is `0`, a breaking change bumps the minor version.
555
1113
  }
556
1114
  ```
557
1115
 
1116
+ (#109)
1117
+
558
1118
  ### Fixed
559
1119
 
560
- - A timed-out Python run spun at full CPU until the process was killed when
561
- Pyodide had no worker thread, as for a stowed bundle whose host does not set
562
- `QVAC_PYODIDE_WORKER_ENTRY`. It is now interrupted at the deadline, rejects
563
- with `Command timed out after <ms>ms`, and the interpreter keeps its state. A
1120
+ - A timed-out Python run no longer spins at full CPU until the process is
1121
+ killed when Pyodide has no worker thread, as for a stowed bundle whose host
1122
+ does not set `QVAC_PYODIDE_WORKER_ENTRY`.
1123
+
1124
+ It is now interrupted at the deadline, rejects with
1125
+ `Command timed out after <ms>ms`, and the interpreter keeps its state. A
564
1126
  blocking call such as a long `time.sleep` still sees the interrupt only once
565
- it returns. (#104)
566
- - A timed-out Python run on the worker thread could miss its SIGINT, so the
567
- worker was killed after the grace window along with the interpreter's state.
568
- The interrupt is resent every 100 ms until the run settles. (#112)
569
- - A Python run that failed partway through a save attached the half-written
570
- file, and each failing retry attached another copy under the same name. A
571
- non-zero exit now attaches nothing and reports every declared output in
572
- `missingOutputs`. (#110)
1127
+ it returns. See
1128
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.3.2/docs/decisions/2026-08-11-pyodide-worker-thread.md).
1129
+ (#104)
1130
+
1131
+ - A timed-out Python run on the worker thread keeps its interpreter state. It
1132
+ could miss its SIGINT, so the worker was killed after the grace window along
1133
+ with the interpreter's state. The interrupt is now resent every 100 ms until
1134
+ the run settles. (#112)
1135
+
1136
+ - A Python run that fails no longer attaches a broken file. A run that failed
1137
+ partway through a save attached the half-written file, and each failing
1138
+ retry attached another copy under the same name. A non-zero exit now
1139
+ attaches nothing and reports every declared output in `missingOutputs`, so
1140
+ the retry writes it again. (#110)
573
1141
 
574
1142
  ### Security
575
1143
 
576
1144
  - Python in `exec` reached the network whenever the runtime allowed it,
577
1145
  including a call that auto-ran as a read with no approval prompt, so a
578
1146
  document build that loads an image from a URL could reach the web without
579
- anyone seeing it. The network now opens only to a call a person approved or
580
- that skip-all let through, and sandbox mode stays offline. 0.3.1 is affected.
1147
+ anyone seeing it.
1148
+
1149
+ The network now opens only to a call a person approved or that skip-all let
1150
+ through, and sandbox mode stays offline. 0.3.1 is affected; upgrade from it.
1151
+ See
1152
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.3.2/docs/decisions/2026-09-22-python-reaches-the-network-through-the-guarded-fetch.md).
581
1153
  (#108)
582
1154
 
583
1155
  ## [0.3.1] - 2026-09-23
584
1156
 
585
1157
  ### Added
586
1158
 
587
- - Python in `exec` reaches the network on Bare: `urllib`, `http.client` and
588
- what is built on them, yt-dlp included, work unchanged, and `requests` works
589
- through urllib3's emscripten backend. Every request goes through the guarded
590
- fetch the web tools use, so each hop is checked before it is sent,
591
- credentials do not follow a redirect to another origin, and a response is
592
- capped at 100 MB. Where the global `fetch` is not the guarded one, as on
593
- Node, Python stays offline, and sandbox mode keeps it offline everywhere. A
594
- Python run may now declare `.mp4`, `.webm`, `.m4a` and `.mp3` outputs, each
595
- checked by its file signature. (#94)
596
- - `SkillOAuthConfig.verify`, parsed from a skill manifest's
597
- `setup.routes[].oauth.verify` and carried over the wire, so a host can probe a
598
- fresh token before saving it. A probe is an https `url` on the same site as
599
- the route's `tokenUrl`, an optional `GET` or `POST` `method` and JSON `body`,
600
- and `reject` rules. A rule matches a listed `status`, or a 2xx JSON body whose
601
- `field` is not `notEqual`, and its `error` names the rejection. A probe that
602
- breaks these rules is dropped and the route kept. `SkillOAuthVerify` and
603
- `SkillOAuthVerifyRule` are exported. (#105)
1159
+ - Python in `exec` reaches the network on Bare. `urllib`, `http.client` and
1160
+ what is built on them, yt-dlp included, work unchanged, and `requests`
1161
+ works through urllib3's emscripten backend.
1162
+
1163
+ Every request goes through the guarded fetch the web tools use: each hop is
1164
+ checked before it is sent, credentials do not follow a redirect to another
1165
+ origin, and a response is capped at 100 MB. Where the global `fetch` is not
1166
+ the guarded one, as on Node, Python stays offline, and `sandboxMode` keeps
1167
+ it offline everywhere. A Python run may now declare `.mp4`, `.webm`, `.m4a`
1168
+ and `.mp3` outputs, each checked by its file signature. See
1169
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.3.1/docs/decisions/2026-09-22-python-reaches-the-network-through-the-guarded-fetch.md).
1170
+ (#94)
1171
+
1172
+ ```js
1173
+ // exec arguments a model can now send on Bare
1174
+ {
1175
+ language: 'python',
1176
+ command:
1177
+ "import urllib.request\n" +
1178
+ "urllib.request.urlretrieve('https://example.com/talk.mp3', 'talk.mp3')",
1179
+ outputs: ['talk.mp3']
1180
+ }
1181
+ ```
1182
+
1183
+ - A host can probe a fresh OAuth token before saving it.
1184
+ `SkillOAuthConfig.verify` is parsed from a skill manifest's
1185
+ `setup.routes[].oauth.verify` and carried over the wire;
1186
+ `SkillOAuthVerify` and `SkillOAuthVerifyRule` are exported. The harness
1187
+ only parses the probe; the host runs it.
1188
+
1189
+ A probe is an https `url` on the same site as the route's `tokenUrl`, an
1190
+ optional `GET` or `POST` `method` and JSON `body`, and `reject` rules. A
1191
+ rule matches a listed `status`, or a 2xx JSON body whose `field` is not
1192
+ `notEqual` (a rule with both needs both), and its `error` names the
1193
+ rejection. A probe that breaks these rules is dropped and the route kept.
1194
+ (#105)
604
1195
 
605
1196
  ```js
606
1197
  const skills = await harness.skills()
@@ -615,63 +1206,95 @@ While the major version is `0`, a breaking change bumps the minor version.
615
1206
  ```
616
1207
 
617
1208
  - A skill operation may name `response.decode` to turn a provider's JSON body
618
- into a compact shape before the model sees it, starting with
619
- `gmail-message`: the ids and labels, the From, To, Subject, Date, Message-ID
620
- and References headers, the snippet, and the body as text, capped at 4000
621
- characters. A `gmail-get` request builder joins it. A manifest naming both
622
- `pick` and `decode`, or a builder or decoder this harness does not have, is
623
- rejected. (#69)
1209
+ into a compact shape before the model sees it. The first decoder is
1210
+ `gmail-message`: the ids and labels, the From, To, Subject, Date,
1211
+ Message-ID and References headers, the snippet, and the body as text,
1212
+ capped at 4000 characters. A `gmail-get` request builder goes with it.
1213
+
1214
+ A manifest naming both `pick` and `decode`, or a builder or decoder this
1215
+ harness does not have, is rejected. See
1216
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.3.1/docs/decisions/2026-09-01-skill-operation-manifests.md).
1217
+ (#69)
1218
+
1219
+ ```js
1220
+ // an operation in a skill's operations.json (gmail, @qvac/skills 0.1.7)
1221
+ {
1222
+ tool: 'gmail_get',
1223
+ request: {
1224
+ method: 'GET',
1225
+ url: 'https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}',
1226
+ query: { metadataHeaders: 'Subject,From,To,Date,Message-ID,References' },
1227
+ builder: 'gmail-get'
1228
+ },
1229
+ response: { decode: 'gmail-message' }
1230
+ }
1231
+ ```
624
1232
 
625
1233
  ### Changed
626
1234
 
627
- - A compiled skill operation whose HTTP method is `GET`, `HEAD` or `OPTIONS` is
628
- a read: it auto-runs without an approval prompt, like the same request through
629
- `http_request`, and takes no `intent` argument. MCP operations and every other
630
- method are still writes that ask. (#69)
631
- - `@qvac/skills` 0.1.7, now pinned exactly: Gmail reads a message with
1235
+ - A compiled skill operation whose HTTP method is `GET`, `HEAD` or `OPTIONS`
1236
+ now runs without an approval prompt. It is a read, like the same request
1237
+ through `http_request`, and takes no `intent` argument. MCP operations and
1238
+ every other method are still writes that ask. (#69)
1239
+ - `@qvac/skills` 0.1.7, now pinned exactly. Gmail reads a message with
632
1240
  `gmail_get` instead of telling the model to decode base64, which needs this
633
1241
  release's decoder, and Asana rejects a non-MCP app at connect time through
634
1242
  a `verify` probe. (#105)
635
1243
 
636
1244
  ### Fixed
637
1245
 
638
- - A malformed `<tool_call>` left its JSON arguments in the reply. The whole span
639
- is now stripped, and an unclosed one is stripped to the end of the text.
640
- (#100)
1246
+ - A malformed `<tool_call>` left its JSON arguments in the reply. The whole
1247
+ span is now stripped, and an unclosed one is stripped to the end of the
1248
+ text. (#100)
641
1249
  - A misspelt, placeholder or repeated `skill` load returned an error the model
642
1250
  retried until the turn ran out. A placeholder or the skill's own name now
643
- reads the body, a near miss of one of its files loads that file, and a repeat
644
- is acknowledged without re-reading it. (#101)
1251
+ reads the body, a near miss of one of its files loads that file, and a
1252
+ repeat is acknowledged without re-reading it. (#101)
645
1253
  - When the model reached for `pip` or a video tool on the shell path, it was
646
1254
  only told what the path is not, and retried the same call. Each refusal now
647
1255
  names the call that works, such as `packages: ["requests"]` with the pin a
648
- vendored package installs offline under. A missing import names the package
649
- that provides it, and `python -c`, a heredoc or bare Python source runs as
650
- Python instead of being sent back for a `language` argument. (#71)
1256
+ vendored package installs offline under.
1257
+
1258
+ A missing import names the package that provides it, and `python -c`, a
1259
+ heredoc or bare Python source runs as Python instead of being sent back for
1260
+ a `language` argument. See
1261
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.3.1/docs/decisions/2026-09-21-exec-names-the-call-that-works.md).
1262
+ (#71)
651
1263
 
652
1264
  ## [0.3.0] - 2026-09-23
653
1265
 
654
1266
  ### Added
655
1267
 
656
- - A `translate` capability for `invoke`: it translates text without loading an
657
- LLM. The language pair picks the route before anything loads. English and
658
- one of nine European languages go through the TranslatePsy-EuroNano packs,
659
- the other 43 languages Bergamot serves go through their per-pair models
660
- (pivoting through English when neither side is English), and 16 languages
661
- Bergamot has no model for go through TranslatePsy-AfriSLM, named by ISO 639-1
662
- or FLORES-200 code. An omitted `from` is detected from the text, except for
663
- the AfriSLM languages, which need it named. The `tool-result` carries the
664
- text, the pair and the `engine` that answered. (#76)
1268
+ - A `translate` capability for `invoke`: text translation without loading an
1269
+ LLM. The language pair picks the route before anything loads:
1270
+
1271
+ - English and one of nine European languages go through the
1272
+ TranslatePsy-EuroNano packs.
1273
+ - The other 43 languages Bergamot serves go through their per-pair models,
1274
+ pivoting through English when neither side is English.
1275
+ - 16 languages Bergamot has no model for go through TranslatePsy-AfriSLM,
1276
+ named by ISO 639-1 or FLORES-200 code.
1277
+
1278
+ An omitted `from` is detected from the text, except for the AfriSLM
1279
+ languages, which need it named. The `tool-result` carries the text, the pair
1280
+ and the `engine` that answered (`nmt`, `slm`, or `none` when the text is
1281
+ already in the target language). A pair no route serves, or a source that
1282
+ cannot be detected, comes back as `result.error`. See
1283
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.3.0/docs/decisions/2026-09-21-translation-pivots-through-english.md).
1284
+ (#76)
665
1285
 
666
1286
  ```js
667
1287
  for await (const event of harness.invoke({
668
1288
  capability: 'translate',
669
1289
  args: { text: 'The weather is nice today.', to: 'de' }
670
1290
  })) {
671
- if (event.type === 'tool-result') show(event.result.text)
1291
+ if (event.type === 'tool-result') {
1292
+ if (event.result.error) showError(event.result.error)
1293
+ else show(event.result.text)
1294
+ }
672
1295
  if (event.type === 'error') showError(event.message)
673
1296
  }
674
- // result: { text, from, to: 'de', engine: 'nmt', pivoted: false }
1297
+ // result: { text, from: 'en', to: 'de', engine: 'nmt', pivoted: false }
675
1298
  ```
676
1299
 
677
1300
  - `translationPlanFor`, `NMT_TO_ENGLISH`, `NMT_FROM_ENGLISH`,
@@ -688,18 +1311,23 @@ While the major version is `0`, a breaking change bumps the minor version.
688
1311
  if ('error' in plan) showError(plan.error)
689
1312
  ```
690
1313
 
691
- - `enhance: 'never' | 'auto'` on `invoke`. With `'auto'`, an image prompt is
692
- rewritten by a model already resident on the device, and never triggers a
693
- load: with nothing resident, or a rewrite that fails, the run goes direct.
1314
+ - `enhance: 'never' | 'auto'` on `invoke`, for the `image` capability. With
1315
+ `'auto'`, the prompt is rewritten by a model already resident on the
1316
+ device, and never triggers a load: with nothing resident, or a rewrite that
1317
+ fails, the run goes direct. Omitting `enhance` behaves as `'never'`, which
1318
+ passes the arguments through untouched.
1319
+
694
1320
  `metrics` reports the `route` taken (`direct` or `enhanced`), the
695
- `routeModel` used and the tokens the rewrite spent. Omitting `enhance`
696
- behaves as `'never'`, which passes the arguments through untouched. (#77)
1321
+ `routeModel` used and the tokens the rewrite spent. See
1322
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.3.0/docs/decisions/2026-09-21-a-caller-states-constraints-not-mechanisms.md).
1323
+ (#77)
697
1324
 
698
1325
  ```js
699
1326
  for await (const event of harness.invoke({
700
1327
  capability: 'image',
701
1328
  args: { prompt: 'funny meme' },
702
- enhance: 'auto'
1329
+ enhance: 'auto',
1330
+ attach
703
1331
  })) {
704
1332
  if (event.type === 'metrics') log(event.route, event.routeModel)
705
1333
  }
@@ -707,33 +1335,56 @@ While the major version is `0`, a breaking change bumps the minor version.
707
1335
 
708
1336
  ### Changed
709
1337
 
710
- - The bundle defers `@qvac/translation-nmtcpp` like the other inference
711
- addons, so `prebuilds-manifest.json` now names its prebuilds and a host that
712
- assembles them beside the bundle has to include it. It is already a
713
- dependency of `@qvac/sdk`. (#76)
1338
+ - A host that assembles the native prebuilds beside the bundle now has to
1339
+ include `@qvac/translation-nmtcpp`. The bundle defers it like the other
1340
+ inference addons, so `prebuilds-manifest.json` names its prebuilds. It is
1341
+ already a dependency of `@qvac/sdk`, so it is in the host's
1342
+ `node_modules`. See
1343
+ [The published package](https://github.com/tetherto/qvac-harness/blob/v0.3.0/README.md#the-published-package).
1344
+ (#76)
714
1345
 
715
1346
  ## [0.2.12] - 2026-09-22
716
1347
 
1348
+ The first published release with the 0.2.11 changes (`invoke`,
1349
+ `detectLanguage`, `SkillOAuthConfig`), plus the fix below.
1350
+
717
1351
  ### Fixed
718
1352
 
719
- - Sandbox mode refused every `exec` call that installs packages, which broke
720
- the pdf, presentations, word and excel skills. A request whose packages are
721
- all vendored now installs from the shipped wheels; one that would fall back
722
- to PyPI is still refused. (#91)
1353
+ - The pdf, presentations, word and excel skills work again under
1354
+ `sandboxMode`. Sandbox mode refused every `exec` call that installs
1355
+ packages, including installs that never leave the device.
1356
+
1357
+ A request whose packages are all vendored, each pinned to the version the
1358
+ shipped wheels carry, now installs from those wheels. One unvendored
1359
+ package, an unpinned version, or a host that ships no wheels would fall
1360
+ back to PyPI, and is still refused. See
1361
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.2.12/docs/decisions/2026-09-21-sandbox-mode-no-network.md#consequences).
1362
+ (#91)
723
1363
 
724
1364
  ## [0.2.11] - 2026-09-22
725
1365
 
726
1366
  Tagged but never published to npm; these changes first shipped in 0.2.12.
1367
+ Install 0.2.12 or later to get them.
727
1368
 
728
1369
  ### Added
729
1370
 
730
1371
  - `invoke({ capability, args })` runs one capability on its own, with no
731
1372
  model, no dispatch and no approval callback: the caller already decided.
732
- `image` is the first capability. It is a frame on the existing `completion`
733
- stream, so it takes the same `attach`, `progress` and abort signal and emits
734
- the same `tool-call`, `tool-result`, `metrics`, `error` and `aborted` events.
735
- `metrics` reports `totalTokens: 0`. It is on `Harness` and `RemoteHarness`
736
- alike, and optional on `HarnessContract`. (#74)
1373
+ `image` is the first capability, and `CAPABILITIES` and `isCapability` list
1374
+ and check the names.
1375
+
1376
+ It is a frame on the existing `completion` stream, so it takes the same
1377
+ `attach`, `progress` and abort signal, and emits the same `tool-call`,
1378
+ `tool-result`, `metrics`, `error` and `aborted` events. `metrics` reports
1379
+ `totalTokens: 0`. An optional `agent` supplies the image defaults (model,
1380
+ steps, size). A failure inside the tool, such as a missing `attach`, comes
1381
+ back as `result.error` on the `tool-result`; the `error` event is for a
1382
+ capability the device lacks or a tool that threw.
1383
+
1384
+ It is on `Harness` and `RemoteHarness` alike, and optional on
1385
+ `HarnessContract`. See
1386
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.2.11/docs/decisions/2026-09-21-a-run-that-loads-no-model.md).
1387
+ (#74)
737
1388
 
738
1389
  ```js
739
1390
  for await (const event of harness.invoke({
@@ -743,14 +1394,20 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
743
1394
  progress,
744
1395
  signal
745
1396
  })) {
746
- if (event.type === 'tool-result') showImage(event.result)
1397
+ if (event.type === 'tool-result') {
1398
+ // { attachmentId, fileName, byteLength, width, height } or { error }
1399
+ if (event.result.error) showError(event.result.error)
1400
+ else showImage(event.result.attachmentId)
1401
+ }
747
1402
  if (event.type === 'error') showError(event.message)
748
1403
  }
749
1404
  ```
750
1405
 
751
- - `detectLanguage({ text, topK })`, served over hrpc as `detect-language` and
752
- on `RemoteHarness`: language detection with no model to load. Without
753
- `topK`, or with 1, it answers with the single best match and no
1406
+ - `detectLanguage({ text, topK })`: language detection with no model to load.
1407
+ It is on `Harness`, served over hrpc as `detect-language`, and on
1408
+ `RemoteHarness`.
1409
+
1410
+ Without `topK`, or with 1, it answers with the single best match and no
754
1411
  probability. Above that it returns ranked candidates with their
755
1412
  probabilities, best first, and may return fewer than asked. Text it cannot
756
1413
  place answers `und`. (#75)
@@ -763,15 +1420,31 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
763
1420
  // [{ code, language, probability }, ...]
764
1421
  ```
765
1422
 
766
- - `SkillOAuthConfig` on `SkillSetupRoute.oauth`, parsed from a skill
767
- manifest's `setup.routes[].oauth` and carried over the wire, so a host can
768
- run a skill's OAuth setup from its manifest instead of its own provider
769
- table. A route missing `stateKey`, `authUrl`, `tokenUrl` or `tokenAuth`
770
- carries no `oauth`. (#88)
1423
+ - A host can run a skill's OAuth setup from its manifest instead of its own
1424
+ provider table. `SkillSetupRoute.oauth` carries a `SkillOAuthConfig` parsed
1425
+ from `setup.routes[].oauth` and sent over the wire, and `SkillOAuthConfig`
1426
+ is exported.
1427
+
1428
+ `stateKey`, an https `authUrl` and `tokenUrl`, and `tokenAuth`
1429
+ (`pkce-only`, `secret-in-body` or `secret-in-basic`) are required; an
1430
+ `oauth` route without a valid config is dropped. `extraAuthParams` may not
1431
+ override the parameters the flow owns, such as `redirect_uri` or `state`.
1432
+ (#88)
1433
+
1434
+ ```js
1435
+ const skills = await harness.skills()
1436
+ const route = skills
1437
+ .find((skill) => skill.name === 'spotify')
1438
+ ?.setup?.routes?.find((r) => r.kind === 'oauth')
1439
+
1440
+ // { stateKey, authUrl, tokenUrl, scopes, tokenAuth,
1441
+ // extraAuthParams?, registrationEndpoint?, port?, sharedIosSession? }
1442
+ const oauth = route?.oauth
1443
+ ```
771
1444
 
772
1445
  ### Changed
773
1446
 
774
- - `@qvac/skills` 0.1.6: the eight OAuth skills declare their setup routes'
1447
+ - `@qvac/skills` `^0.1.6`: the eight OAuth skills declare their setup routes'
775
1448
  OAuth config, the Spotify skill always makes the play call after a search,
776
1449
  and the weather skill looks the place up with `weather_lookup`. (#88)
777
1450
 
@@ -788,11 +1461,17 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
788
1461
 
789
1462
  ### Added
790
1463
 
791
- - `sandboxMode` agent option: an agent with no network access. It withholds
792
- every tool that reaches the network (the web search, HTTP request and MCP
793
- toolsets, including skill operations over HTTP or MCP), not just web search.
1464
+ - `sandboxMode` agent option: an agent that cannot reach the network. Set it
1465
+ on the agent and every network tool is withheld before the model sees the
1466
+ catalog: the web search, HTTP request and MCP toolsets, including skill
1467
+ operations compiled over HTTP or MCP, not just web search.
1468
+
794
1469
  `exec` stays, but refuses package installs and well-known network commands
795
- such as `curl`, `git` and `npm`. That is a guard, not an OS-level sandbox.
1470
+ such as `curl`, `git` and `npm`. That is a name-list guard, not an OS-level
1471
+ sandbox: another binary, or Python calling `pyfetch`, can still open a
1472
+ socket. The six toolset switches stay independent; this is the one knob
1473
+ that means "no network". See
1474
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.2.10/docs/decisions/2026-09-21-sandbox-mode-no-network.md).
796
1475
  (#73)
797
1476
 
798
1477
  ```js
@@ -805,12 +1484,17 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
805
1484
  }
806
1485
  ```
807
1486
 
808
- - `@qvac/harness/office`: XLSX and PPTX text extraction for Knowledge beside
809
- DOCX, through the same bundled Python worker. It keeps sheet names, column
810
- positions, cached formula values, slide text, grouped shapes and tables;
811
- speaker notes, images and charts are left out. The format comes from the
812
- file extension, with `mimeType` as the fallback. `@qvac/harness/docx` is
813
- unchanged. (#83)
1487
+ - `@qvac/harness/office`: XLSX and PPTX text extraction for Knowledge, beside
1488
+ DOCX, through the same bundled Python worker. `extractOfficeText` picks the
1489
+ format from the file extension, with `mimeType` as the fallback.
1490
+
1491
+ XLSX keeps sheet names, column positions, cached formula values (formula
1492
+ text where there is no cached value). PPTX keeps slide text, grouped shapes
1493
+ and tables in slide order; speaker notes, images and charts are left out.
1494
+ Legacy XLS and PPT are not supported. `@qvac/harness/docx` is unchanged and
1495
+ shares the worker. See
1496
+ [Office extraction](https://github.com/tetherto/qvac-harness/blob/v0.2.10/README.md#office-extraction)
1497
+ for the cache and wheel paths a host sets. (#83)
814
1498
 
815
1499
  ```js
816
1500
  import { extractOfficeText, closeOfficeWorker } from '@qvac/harness/office'
@@ -822,43 +1506,57 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
822
1506
 
823
1507
  ### Fixed
824
1508
 
825
- - A small model could loop on the same failing tool call, up to the round
826
- cap. A call that already failed this turn with the same arguments is now
1509
+ - A small model no longer loops on the same failing tool call up to the round
1510
+ cap. A call that already failed this turn with the same arguments is
827
1511
  answered from its first result without running again, until a later round
828
- succeeds. Three such answers end the turn's tool rounds. A failure that may
829
- pass on retry, such as a timeout, a 5xx, 408 or 429, still runs again, and
830
- the tools that can fail that way now say so in a `code` or `status` field on
831
- their result. (#78)
832
- - A guessed `auth.tokenCredentialKey` on `http_request` or `mcp_call` was
833
- refused with an error claiming the skill was not loaded. It now resolves to
834
- the one connected credential allowed for that URL, and when none is
835
- connected the error says so and tells the user to connect it in Skills.
836
- (#72)
1512
+ succeeds. Three such answers end the turn's tool rounds; the tool set is
1513
+ never changed, so the prompt cache is kept.
1514
+
1515
+ A failure that may pass on retry, such as a timeout, a 5xx, 408 or 429,
1516
+ still runs again, and the tools that can fail that way now say so in a
1517
+ `code` or `status` field on their result. See
1518
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.2.10/docs/decisions/2026-09-21-a-failed-call-is-answered-from-its-own-result.md).
1519
+ (#78)
1520
+
1521
+ - A guessed `auth.tokenCredentialKey` on `http_request` or `mcp_call` now
1522
+ works instead of failing with an error claiming the skill was not loaded.
1523
+ It resolves to the one connected credential allowed for that URL. When
1524
+ none is connected, the error says so and tells the user to connect it in
1525
+ Skills. (#72)
837
1526
 
838
1527
  ## [0.2.9] - 2026-09-22
839
1528
 
840
1529
  ### Added
841
1530
 
842
- - `captureInferenceCalls` on a completion's context: each round then emits an
843
- `inference-call` event with the exact payload it is about to send (model,
844
- messages, tools, generation params, cache key and prompt prefix), and an
845
- `inference-sent` event once it ends, with the tokens it spent, its `outcome`
846
- (`completed`, `overflowed` or `failed`) and, on the bundled patched SDK, the
847
- slice of `messages` the KV cache let through. An attempt the overflow ladder
848
- rejected is still recorded, with its `droppedTurns`. Off unless asked for,
849
- since every record carries a whole prompt. The flag and both events are new on
850
- the hrpc wire, so the host and the spawned harness both need 0.2.9. (#51)
1531
+ - A host can record exactly what each inference round sent. Set
1532
+ `captureInferenceCalls` on a completion's context, and each round emits an
1533
+ `inference-call` event with the payload it is about to send: model,
1534
+ messages, tools, generation params, cache key and prompt prefix.
1535
+
1536
+ An `inference-sent` event follows once the round ends, with the tokens it
1537
+ spent, its `outcome` (`completed`, `overflowed` or `failed`) and, on the
1538
+ bundled patched SDK, the slice of `messages` the KV cache let through. An
1539
+ attempt the overflow ladder rejected is still recorded, with its
1540
+ `droppedTurns`. See
1541
+ [What actually gets sent](https://github.com/tetherto/qvac-harness/blob/v0.2.9/docs/kvcache-reference.md#what-actually-gets-sent).
1542
+
1543
+ Off unless asked for, since every record carries a whole prompt. The flag
1544
+ and both events are new on the hrpc wire, so the host and the spawned
1545
+ harness both need 0.2.9. (#51)
851
1546
 
852
1547
  ```js
853
- for await (const event of harness.completion({ model, messages, captureInferenceCalls: true })) {
854
- if (event.type === 'inference-call') record(event.call.round, event.call.messages)
855
- if (event.type === 'inference-sent') settle(event.round, event.outcome, event.tokens)
1548
+ const context = { model, agent, messages, captureInferenceCalls: true }
1549
+ for await (const event of harness.completion(context)) {
1550
+ if (event.type === 'inference-call') record(event.call)
1551
+ if (event.type === 'inference-sent') settle(event.round, event.outcome)
856
1552
  }
857
1553
  ```
858
1554
 
859
- - The `metrics` event carries the last round's `stopReason`, and leaves it out
860
- when the SDK reports none. A turn cut off by the context window ends cleanly
861
- with `stopReason: 'length'`, so a host can tell it from a complete answer.
1555
+ - A host can tell a cut-off answer from a complete one. The `metrics` event
1556
+ carries the last round's `stopReason`, and leaves it out when the SDK
1557
+ reports none. A turn cut off by the context window ends cleanly with
1558
+ `stopReason: 'length'`. See
1559
+ [Compaction](https://github.com/tetherto/qvac-harness/blob/v0.2.9/docs/kvcache-reference.md#compaction).
862
1560
  (#62)
863
1561
 
864
1562
  ```js
@@ -875,15 +1573,21 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
875
1573
  ### Added
876
1574
 
877
1575
  - `weather_lookup`, a tool that looks weather up on wttr.in by `location` and
878
- leads its report with the place the service actually resolved. When that
879
- place disagrees with the country or state the user named, it refuses instead
880
- of reporting the weather of wherever the geocoder landed: a nonexistent town
881
- no longer gets a forecast, and a bare "Rome" the service places in Togo says
882
- so. It is offered only once the weather skill is loaded, needs no approval,
883
- and the `http-request` toolset switch turns it off. The spawned sidecar
884
- registers it; an in-process host adds it to its tools. (#52)
1576
+ leads its report with the place the service actually resolved.
1577
+
1578
+ When that place disagrees with the country or state the user named, it
1579
+ refuses instead of reporting the weather of wherever the geocoder landed: a
1580
+ nonexistent town no longer gets a forecast, and a bare "Rome" the service
1581
+ places in Togo says so.
1582
+
1583
+ It is offered only once the weather skill is loaded, needs no approval, and
1584
+ the `http-request` toolset switch turns it off. The spawned sidecar registers
1585
+ it; an in-process host adds it to its tools. See
1586
+ [the decision record](https://github.com/tetherto/qvac-harness/blob/v0.2.8/docs/decisions/2026-09-16-a-lookup-reports-the-place-it-resolved.md).
1587
+ (#52)
885
1588
 
886
1589
  ```js
1590
+ import { Harness } from '@qvac/harness'
887
1591
  import { createWeatherLookupTool } from '@qvac/harness/lib/tools/weather-lookup.ts'
888
1592
 
889
1593
  const harness = new Harness({ tools: [...tools, createWeatherLookupTool()] })
@@ -898,20 +1602,25 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
898
1602
  the version. (#55)
899
1603
 
900
1604
  ```js
901
- // 0.2.7
1605
+ // before
1606
+ import { currentReleaseLine } from '@qvac/harness/schema/release-tags'
902
1607
  const line = currentReleaseLine(root) // '0.2'
903
1608
 
904
- // 0.2.8
1609
+ // after
1610
+ import { currentReleaseVersion } from '@qvac/harness/schema/release-tags'
905
1611
  const version = currentReleaseVersion(root) // Version 0.2.8
906
1612
  const line = `${version.major}.${version.minor}`
907
1613
  ```
908
1614
 
909
- - `previousReleaseTags` from the same module now counts the newest shipped tag
910
- on the line being built as a baseline, not only older lines, so a schema
911
- change is checked against the build users are running. Only tags newer than
912
- the package version are left out. A host that regenerates its `spec/`
913
- against it re-derives hyperschema's version counters once, so expect one
914
- regeneration commit after upgrading. (#55, #58)
1615
+ - A schema change is now checked against the build users are running.
1616
+ `previousReleaseTags` from the same module counts the newest shipped tag on
1617
+ the line being built as a baseline, not only older lines; only tags newer
1618
+ than the package version are left out.
1619
+
1620
+ A host that regenerates its `spec/` against it re-derives hyperschema's
1621
+ version counters once, so expect one regeneration commit after upgrading.
1622
+ (#55, #58)
1623
+
915
1624
  - `@qvac/skills` 0.1.4: the Apple Notes create-note operation no longer
916
1625
  renames a note after creating it, which failed on iCloud accounts and left
917
1626
  duplicate notes behind. (#48)
@@ -920,14 +1629,17 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
920
1629
 
921
1630
  ### Added
922
1631
 
923
- - `@qvac/harness/docx`: `extractDocxText` pulls a DOCX file's text for
924
- Knowledge with the bundled Python runtime, keeping paragraphs and tables in
925
- document order, one row per line with cells joined by `' | '`. It takes up to
926
- 20 MiB and only the shipped wheels, never a network install. Set
927
- `QVAC_PYODIDE_CACHE_DIR` to a writable directory first, so the worker seeds
928
- its cache from those wheels and works offline. A bundled host also sets
929
- `QVAC_PYODIDE_WORKER_ENTRY` and `QVAC_PYTHON_WHEELS_DIR` to its staged
930
- worker and wheels. (#46)
1632
+ - `@qvac/harness/docx`: `extractDocxText` gives Knowledge a DOCX file's text,
1633
+ pulled with the bundled Python runtime. Paragraphs and tables keep document
1634
+ order, one table row per line with cells joined by `' | '`.
1635
+
1636
+ It takes up to 20 MiB and only the shipped wheels, never a network install.
1637
+ Set `QVAC_PYODIDE_CACHE_DIR` to a writable directory first, so the worker
1638
+ seeds its cache from those wheels and works offline. A bundled host also
1639
+ sets `QVAC_PYODIDE_WORKER_ENTRY` and `QVAC_PYTHON_WHEELS_DIR` to its staged
1640
+ worker and wheels. See
1641
+ [DOCX extraction](https://github.com/tetherto/qvac-harness/blob/v0.2.7/README.md#docx-extraction).
1642
+ (#46)
931
1643
 
932
1644
  ```js
933
1645
  import { extractDocxText, closeDocxWorker } from '@qvac/harness/docx'
@@ -959,36 +1671,45 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
959
1671
  })
960
1672
  ```
961
1673
 
962
- - `writeClient` and `writeCapabilities` from `@qvac/harness/schema/generate`
963
- take an options argument whose `optionalRequests` names the request types a
964
- caller may omit: the generated client sends `{}` for them and the generated
965
- capability types mark the input optional. Left out, only `@assistant/empty`
966
- is optional, as before. A set you pass replaces that default, so include it
967
- if you still want it. `writeClient` still takes an export name as a string.
968
- (#44)
1674
+ - A caller can declare which hrpc requests may be omitted. `writeClient` and
1675
+ `writeCapabilities` from `@qvac/harness/schema/generate` take an options
1676
+ argument whose `optionalRequests` names those request types: the generated
1677
+ client sends `{}` for them, and the generated capability types mark the input
1678
+ optional.
1679
+
1680
+ Left out, only `@assistant/empty` is optional, as before. A set you pass
1681
+ replaces that default, so include it if you still want it. `writeClient`
1682
+ still takes an export name as a string. (#44)
969
1683
 
970
1684
  ```js
971
1685
  import { writeClient, writeCapabilities } from '@qvac/harness/schema/generate'
972
1686
 
973
1687
  const optionalRequests = new Set(['@assistant/empty', '@assistant/mesh-scope'])
974
- writeClient(builder, 'spec/calls.js', { exportName: 'engineClient', optionalRequests })
1688
+ writeClient(builder, 'spec/calls.js', {
1689
+ exportName: 'engineClient',
1690
+ optionalRequests
1691
+ })
975
1692
  writeCapabilities(builder, 'spec/capabilities.d.ts', { optionalRequests })
976
1693
  ```
977
1694
 
978
1695
  ### Changed
979
1696
 
980
- - A completion that needs a different model no longer loads it beside one a
981
- live run is holding. It waits for that run to release its model, for up to
982
- five minutes, and then loads anyway rather than failing. A `warmModel` call
983
- waits two seconds at most, and skips the warm if no room was made. (#41)
1697
+ - Switching models no longer stacks a second one in memory beside a live run.
1698
+ A completion that needs a different model waits for that run to release its
1699
+ model, for up to five minutes, and then loads anyway rather than failing.
1700
+
1701
+ A `warmModel` call waits two seconds at most, and skips the warm if no room
1702
+ was made. (#41)
984
1703
 
985
1704
  ## [0.2.5] - 2026-09-14
986
1705
 
987
1706
  ### Added
988
1707
 
989
- - A `gpuStats` option on `spawnHarness`, `harnessChild` and `HarnessOptions`.
990
- A host that already has the GPU reading passes it, and the harness skips its
991
- own probe. Left out, the harness probes for itself. (#36)
1708
+ - A host that already has the GPU reading can hand it over: a `gpuStats`
1709
+ option on `spawnHarness`, `harnessChild` and `HarnessOptions`, and the
1710
+ harness skips its own probe. Left out, the harness probes for itself. It
1711
+ takes the `bare-gpu-info` shape: `memory.totalBytes` and `gpu.devices`, each
1712
+ with a `name` and `videoMemoryBytes`. (#36)
992
1713
 
993
1714
  ```js
994
1715
  import { spawnHarness } from '@qvac/harness/lib/spawn.ts'
@@ -998,54 +1719,72 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
998
1719
  sdkStorage,
999
1720
  gpuStats: {
1000
1721
  memory: { totalBytes: os.totalmem() },
1001
- gpu: { devices: [{ name: 'NVIDIA RTX 4070', videoMemoryBytes: 12884901888 }] }
1722
+ gpu: {
1723
+ devices: [{ name: 'NVIDIA RTX 4070', videoMemoryBytes: 12884901888 }]
1724
+ }
1002
1725
  }
1003
1726
  })
1004
1727
  ```
1005
1728
 
1006
1729
  ### Fixed
1007
1730
 
1008
- - On a Linux snap the harness exited about a second after `open`, and the
1009
- supervisor respawned it in a loop: the boot-time capability probe created a
1010
- second GPU-info instance beside the SDK's own. On Linux and Windows under
1011
- Bare, GPUs are now enumerated in a short-lived child process, which gives up
1012
- after 5 seconds and reports no GPU. (#36)
1013
- - On Windows, asking for an image after a presentation left the chat stuck on
1014
- "Loading … into memory". Pyodide's boot opened the process streams from the
1015
- Python worker thread, which blocked the image-model loader's progress. Boot
1016
- now never touches them; a run's own output capture is unchanged. (#34)
1731
+ - The harness stays up on a Linux snap. It exited about a second after `open`,
1732
+ and the supervisor respawned it in a loop: the boot-time capability probe
1733
+ created a second GPU-info instance beside the SDK's own.
1734
+
1735
+ On Linux and Windows under Bare, GPUs are now enumerated in a short-lived
1736
+ child process, which gives up after 5 seconds and reports no GPU. (#36)
1737
+
1738
+ - On Windows, asking for an image after a presentation no longer leaves the
1739
+ chat stuck on "Loading … into memory". Pyodide's boot opened the process
1740
+ streams from the Python worker thread, which blocked the image-model loader's
1741
+ progress. Boot now never touches them; a run's own output capture is
1742
+ unchanged. (#34)
1017
1743
 
1018
1744
  ## [0.2.4] - 2026-09-12
1019
1745
 
1020
1746
  ### Added
1021
1747
 
1022
- - `@qvac/harness/worker/stow-entry.mjs`: the sidecar entry, published so a
1023
- host running from inside `node_modules` spawns the engine without the
1024
- prebuilt bundle. Every native addon then resolves beside the JS that calls
1025
- it, from one install. With the bundle, the addons come from the host's own
1026
- install, and a version skew there hangs the child on boot rather than
1027
- throwing: `@qvac/core` 0.1.2 shipped that way. The bundle stays published
1028
- for a host that relocates the code out of `node_modules`. (#31)
1748
+ - `@qvac/harness/worker/stow-entry.mjs`: a sidecar entry a host running from
1749
+ inside `node_modules` spawns directly, without the prebuilt bundle. Every
1750
+ native addon then resolves beside the JS that calls it, from one install.
1751
+
1752
+ With the bundle, the addons come from the host's own install, and a version
1753
+ skew there hangs the child on boot rather than throwing: `@qvac/core` 0.1.2
1754
+ shipped that way. The bundle stays published for a host that relocates the
1755
+ code out of `node_modules`. (#31)
1029
1756
 
1030
1757
  ```js
1031
1758
  import { harnessChild } from '@qvac/harness/lib/spawn.ts'
1032
1759
 
1033
1760
  const entry = new URL(import.meta.resolve('@qvac/harness/worker/stow-entry.mjs')).pathname
1034
- const spec = harnessChild(entry, { runner, skillsDir, wheelsDir, sdkStorage: storage.sdk })
1761
+ const spec = harnessChild(entry, {
1762
+ runner,
1763
+ skillsDir,
1764
+ wheelsDir,
1765
+ sdkStorage: storage.sdk
1766
+ })
1035
1767
  ```
1036
1768
 
1037
1769
  ## [0.2.3] - 2026-09-12
1038
1770
 
1039
1771
  ### Added
1040
1772
 
1041
- - `schema/generate` emits `writeStream` on the generated `HandlerFrames`, so a
1042
- duplex handler's frames type without a local widening. (#29)
1773
+ - A duplex handler's frames now type without a local widening:
1774
+ `schema/generate` emits `writeStream` on the generated `HandlerFrames`.
1775
+ (#29)
1776
+
1777
+ ```js
1778
+ // in a generated spec, a duplex handler can watch its write side close
1779
+ frames.writeStream?.on('close', onClose)
1780
+ ```
1043
1781
 
1044
1782
  ### Changed
1045
1783
 
1046
- - On Android and iOS, `resolveStorage` keeps models under the app's own
1047
- persistent root instead of the device HOME, which an app cannot write.
1048
- Desktop and Snap are unchanged, and `overrides.models` still wins. (#29)
1784
+ - Models download on Android and iOS. `resolveStorage` keeps them under the
1785
+ app's own persistent root there, instead of the device HOME, which an app
1786
+ cannot write. Desktop and Snap are unchanged, and `overrides.models` still
1787
+ wins. (#29)
1049
1788
 
1050
1789
  ```js
1051
1790
  import { resolveStorage } from '@qvac/harness/storage'
@@ -1057,37 +1796,41 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1057
1796
 
1058
1797
  ### Fixed
1059
1798
 
1060
- - Every `./lib/tools/*` subpath is published. The tarball listed only the ones
1061
- `qvac-core` imports, so the mobile worklet's file tools (`file-glob`, `grep`,
1062
- `read-files`, `create-documents`, `edit-documents`, `fs/scope`) failed with
1063
- `PACKAGE_PATH_NOT_EXPORTED`. 0.1.1 to 0.2.2 are affected. (#29)
1799
+ - The mobile worklet's file tools load again. Every `./lib/tools/*` subpath is
1800
+ published now; the tarball listed only the ones `qvac-core` imports, so
1801
+ `file-glob`, `grep`, `read-files`, `create-documents`, `edit-documents` and
1802
+ `fs/scope` failed with `PACKAGE_PATH_NOT_EXPORTED`. 0.1.1 to 0.2.2 are
1803
+ affected. (#29)
1064
1804
 
1065
1805
  ## [0.2.2] - 2026-09-11
1066
1806
 
1067
1807
  ### Fixed
1068
1808
 
1069
- - A stale lockfile entry gave `@qvac/sdk` its own nested `@qvac/inference`
1070
- 0.19.1, beside the patched 0.19.0 the harness pins. The bundle is built from
1071
- the pinned copy alone again. (#27)
1809
+ - The engine bundle runs on the patched `@qvac/inference` 0.19.0 the harness
1810
+ pins, and nothing else. A stale lockfile entry had given `@qvac/sdk` its own
1811
+ nested `@qvac/inference` 0.19.1 beside it. A packaging fix with no API
1812
+ change. (#27)
1072
1813
 
1073
1814
  ## [0.2.1] - 2026-09-11
1074
1815
 
1075
1816
  ### Fixed
1076
1817
 
1077
- - The generated wire spec in 0.2.0 stamped the fields it added (`downloaded`
1078
- on a download event and as a model status, and `kvCacheKey`) with schema
1079
- version 7, the version 0.1.2 already shipped. They are version 8 now, so the
1080
- spec matches the schema it is built from. (#25)
1818
+ - The generated wire spec matches the schema it is built from again. 0.2.0
1819
+ stamped the fields it added (`downloaded` on a download event and as a model
1820
+ status, and `kvCacheKey`) with schema version 7, the version 0.1.2 already
1821
+ shipped. They are version 8 now. Only the generated spec changed; no API
1822
+ change. (#25)
1081
1823
 
1082
1824
  ## [0.2.0] - 2026-09-11
1083
1825
 
1084
1826
  ### Added
1085
1827
 
1086
- - `deleteKvCache(kvCacheKey)` deletes one chat's KV cache, every file kept
1087
- under that key, where `clearKvCache()` deletes them all. It is safe on a key
1088
- still in use: the next turn under it primes from scratch. It rides a new
1089
- `delete-kv-cache` hrpc route, and resolves with `{ status }` like
1090
- `clearKvCache`. (#22)
1828
+ - `deleteKvCache(kvCacheKey)` deletes one chat's KV cache: every file kept
1829
+ under that key. `clearKvCache()` still deletes them all.
1830
+
1831
+ It is safe on a key still in use: the next turn under it primes from scratch.
1832
+ It rides a new `delete-kv-cache` hrpc route, and resolves with `{ status }`
1833
+ like `clearKvCache`. (#22)
1091
1834
 
1092
1835
  ```js
1093
1836
  // the cacheKey the chat's completions ran under
@@ -1095,11 +1838,13 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1095
1838
  if (result.status !== 'success') log(result.error)
1096
1839
  ```
1097
1840
 
1098
- - A download's `complete` event carries `downloaded`, `true` when bytes were
1099
- fetched and `false` when the model was already on disk. An image model's
1100
- `model` events report `loading` rather than `downloading` when it is cached,
1101
- and a new `downloaded` status once a fetched one has loaded, so a host that
1102
- switches over `status` has one more case. (#22)
1841
+ - A host can tell a fresh download from a model already on disk. A download's
1842
+ `complete` event carries `downloaded`: `true` when bytes were fetched,
1843
+ `false` when the model was already there.
1844
+
1845
+ An image model's `model` events report `loading` rather than `downloading`
1846
+ when it is cached, and a new `downloaded` status once a fetched one has
1847
+ loaded, so a host that switches over `status` has one more case. (#22)
1103
1848
 
1104
1849
  ```js
1105
1850
  for await (const event of harness.download({ model })) {
@@ -1111,28 +1856,63 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1111
1856
  })
1112
1857
  ```
1113
1858
 
1114
- - `ToolDefinition.prompt`: an optional compact `description` and `parameters`
1115
- the model is shown in place of the full ones, which still validate the call.
1116
- `exec` and `skill` ship one, so every turn spends less of the prompt on
1117
- their schemas. (#22)
1118
- - `CONTEXT_OVERFLOW_MESSAGE` is exported, the message a turn fails with when
1119
- it does not fit the model's context window, so a host can tell that failure
1120
- apart and compact the chat. (#22)
1859
+ - `ToolDefinition.prompt` lets a tool show the model a shorter schema: an
1860
+ optional compact `description` and `parameters` sent in place of the full
1861
+ ones, which still validate the call. `exec` and `skill` ship one, so every
1862
+ turn spends less of the prompt on their schemas. (#22)
1863
+
1864
+ ```js
1865
+ const lookup = {
1866
+ name: 'lookup',
1867
+ description: 'Look a record up by its id, and return ...',
1868
+ parameters: {
1869
+ type: 'object',
1870
+ properties: { id: { type: 'string', description: 'The record id ...' } },
1871
+ required: ['id']
1872
+ },
1873
+ // what the model sees; the full schema above still validates the call
1874
+ prompt: {
1875
+ description: 'Look a record up by id.',
1876
+ parameters: {
1877
+ type: 'object',
1878
+ properties: { id: { type: 'string' } },
1879
+ required: ['id']
1880
+ }
1881
+ },
1882
+ toolset: null,
1883
+ effect: () => 'read',
1884
+ execute: async ({ id }) => find(id)
1885
+ }
1886
+ ```
1887
+
1888
+ - `CONTEXT_OVERFLOW_MESSAGE` is exported: the message a turn fails with when it
1889
+ does not fit the model's context window. A host can tell that failure apart
1890
+ and compact the chat. (#22)
1121
1891
 
1122
1892
  ```js
1123
1893
  import { CONTEXT_OVERFLOW_MESSAGE } from '@qvac/harness'
1894
+
1895
+ if (event.type === 'error' && event.message === CONTEXT_OVERFLOW_MESSAGE) {
1896
+ compact(chat)
1897
+ }
1124
1898
  ```
1125
1899
 
1126
1900
  - With `QVAC_HARNESS_LOG=1`, each turn logs `sdk tool definitions`: the tool
1127
1901
  schemas it sent, largest first, with each one's size and the skill that
1128
1902
  granted it. (#22)
1129
1903
 
1904
+ ```sh
1905
+ QVAC_HARNESS_LOG=1 bare host.js
1906
+ ```
1907
+
1130
1908
  ### Changed
1131
1909
 
1132
1910
  - `@qvac/sdk` 0.19.0, pinned exactly. Under Bare the SDK is now served by
1133
1911
  `@qvac/inference` 0.19.0, through a new `#sdk` import alias.
1134
- `@qvac/audiogen-ggml` joins the deferred addons, so `prebuilds-manifest.json`
1135
- names it and the host provides it too. (#22)
1912
+
1913
+ `@qvac/audiogen-ggml` joins the deferred addons, so
1914
+ `prebuilds-manifest.json` names it and the host provides it too. (#22)
1915
+
1136
1916
  - Image and music generation schemas reach the model once their skill's
1137
1917
  instructions are loaded, not as soon as the skill is enabled. Loading the
1138
1918
  skill unlocks them within the same turn, at the cost of one cache re-prime
@@ -1147,10 +1927,11 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1147
1927
 
1148
1928
  ### Fixed
1149
1929
 
1150
- - `@qvac/harness/sdk-storage` and `@qvac/harness/sdk-config-loader` failed to
1151
- resolve under Bare with `MODULE_NOT_FOUND`: the tarball carried only the Node
1152
- loader behind `#sdk-config-loader`, so `configureSdkStorage()` could not run
1153
- on Bare. Both loaders ship now. 0.1.1 is affected. (#20)
1930
+ - `configureSdkStorage()` runs under Bare again.
1931
+ `@qvac/harness/sdk-storage` and `@qvac/harness/sdk-config-loader` failed to
1932
+ resolve there with `MODULE_NOT_FOUND`, because the tarball carried only the
1933
+ Node loader behind `#sdk-config-loader`. Both loaders ship now. 0.1.1 is
1934
+ affected. (#20)
1154
1935
 
1155
1936
  ```js
1156
1937
  // Bare: loads again, nothing else changes
@@ -1161,13 +1942,15 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1161
1942
 
1162
1943
  ### Added
1163
1944
 
1164
- - The library surface ships compiled: `dist-lib/` carries JavaScript and a
1165
- `.d.ts` for every export, so `@qvac/harness` and its subpaths import from
1166
- npm under plain Node or Bare, with no TypeScript source or type-stripping
1167
- loader. Export keys are unchanged, `.ts`-suffixed deep paths included, so a
1168
- host that took the library from the repository switches to the package
1169
- without editing a specifier. Of `./lib/tools/*`, only the paths `qvac-core`
1170
- imports are published. (#18)
1945
+ - `@qvac/harness` and its subpaths now import from npm under plain Node or
1946
+ Bare, with no TypeScript source or type-stripping loader. The library surface
1947
+ ships compiled: `dist-lib/` carries JavaScript and a `.d.ts` for every
1948
+ export.
1949
+
1950
+ Export keys are unchanged, `.ts`-suffixed deep paths included, so a host that
1951
+ took the library from the repository switches to the package without editing
1952
+ a specifier. Of `./lib/tools/*`, only the paths `qvac-core` imports are
1953
+ published. (#18)
1171
1954
 
1172
1955
  ```js
1173
1956
  import { harnessChild, spawnHarness } from '@qvac/harness/lib/spawn.ts'
@@ -1178,13 +1961,18 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1178
1961
 
1179
1962
  ### Added
1180
1963
 
1181
- - First release of the QVAC engine as `@qvac/harness`. The tarball is the
1182
- prebuilt `dist/harness.bundle`, one bundle for every platform, and the
1183
- generated `dist/harness.cjs` a host requires to spawn it as a bare-sidecar
1184
- process. It spawns and does not import: the export map points at library
1964
+ - First release of the QVAC engine as `@qvac/harness`. A host gets the
1965
+ prebuilt engine, `dist/harness.bundle` (one bundle for every platform), and
1966
+ the generated `dist/harness.cjs` it requires to spawn that bundle as a
1967
+ bare-sidecar process.
1968
+
1969
+ The package spawns; it does not import. Its export map points at library
1185
1970
  source the tarball leaves out, so a host takes `spawnHarness`,
1186
1971
  `harnessChild` and the transport from the repository until a compiled
1187
- surface ships. (#13)
1972
+ surface ships. See
1973
+ [The npm artifact spawns; it does not import](https://github.com/tetherto/qvac-harness/blob/v0.1.0/README.md#the-npm-artifact-spawns-it-does-not-import)
1974
+ and [Spawning](https://github.com/tetherto/qvac-harness/blob/v0.1.0/README.md#spawning).
1975
+ (#13)
1188
1976
 
1189
1977
  ```js
1190
1978
  const harness = require('@qvac/harness/dist/harness.cjs')
@@ -1192,17 +1980,20 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1192
1980
  const { ipc, sidecar, exit } = await harness.start(args)
1193
1981
  ```
1194
1982
 
1195
- - `dist/prebuilds-manifest.json` names every native prebuild directory the
1196
- bundle defers, keyed by its path relative to the bundle. The host assembles
1197
- those beside the bundle from its own `node_modules`, and installs the
1198
- `@qvac` inference addons and `pyodide` alongside. (#13)
1983
+ - `dist/prebuilds-manifest.json` tells a host which native prebuilds to supply.
1984
+ It names every prebuild directory the bundle defers, keyed by its path
1985
+ relative to the bundle. The host assembles those beside the bundle from its
1986
+ own `node_modules`, and installs the `@qvac` inference addons and `pyodide`
1987
+ alongside. (#13)
1199
1988
  - Skills come from `@qvac/skills` 0.1.2, a content-addressed bundle the
1200
1989
  harness materializes at runtime. The Python wheels the tool runtime installs
1201
1990
  ship in `wheels/`, each pinned by hash in `wheels/manifest.json`. (#13, #15)
1202
- - GPU stats are read through `bare-gpu-info`, so every dependency installs from
1203
- the public npm registry. (#12)
1991
+ - Every dependency installs from the public npm registry: GPU stats are read
1992
+ through `bare-gpu-info`. (#12)
1204
1993
 
1205
- [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.8.6...HEAD
1994
+ [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.8.8...HEAD
1995
+ [0.8.8]: https://github.com/tetherto/qvac-harness/compare/v0.8.7...v0.8.8
1996
+ [0.8.7]: https://github.com/tetherto/qvac-harness/compare/v0.8.6...v0.8.7
1206
1997
  [0.8.6]: https://github.com/tetherto/qvac-harness/compare/v0.8.5...v0.8.6
1207
1998
  [0.8.5]: https://github.com/tetherto/qvac-harness/compare/v0.8.0...v0.8.5
1208
1999
  [0.8.0]: https://github.com/tetherto/qvac-harness/compare/v0.7.3...v0.8.0