@qvac/harness 0.8.6 → 0.8.7

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