@qvac/harness 0.8.5 → 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 (88) hide show
  1. package/CHANGELOG.md +1278 -472
  2. package/README.md +49 -0
  3. package/dist-lib/index.d.ts +42 -2
  4. package/dist-lib/index.js +447 -58
  5. package/dist-lib/lib/audio-transcode-bare.d.ts +1 -1
  6. package/dist-lib/lib/audio-transcode-bare.js +22 -25
  7. package/dist-lib/lib/audio-transcode-node.d.ts +1 -1
  8. package/dist-lib/lib/audio-transcode-node.js +1 -3
  9. package/dist-lib/lib/capabilities/audio-resample.d.ts +1 -1
  10. package/dist-lib/lib/capabilities/audio-resample.js +7 -8
  11. package/dist-lib/lib/capabilities/completion.d.ts +6 -3
  12. package/dist-lib/lib/capabilities/completion.js +96 -49
  13. package/dist-lib/lib/capabilities/download.d.ts +3 -1
  14. package/dist-lib/lib/capabilities/download.js +1 -1
  15. package/dist-lib/lib/capabilities/preview-model-source.d.ts +1 -0
  16. package/dist-lib/lib/capabilities/preview-model-source.js +1 -0
  17. package/dist-lib/lib/capabilities/tool-selection.d.ts +1 -3
  18. package/dist-lib/lib/capabilities/tool-selection.js +2 -4
  19. package/dist-lib/lib/capabilities/tool-turns.d.ts +0 -1
  20. package/dist-lib/lib/capabilities/tool-turns.js +0 -6
  21. package/dist-lib/lib/capabilities/usage-breakdown.js +4 -1
  22. package/dist-lib/lib/capabilities/voice.d.ts +5 -1
  23. package/dist-lib/lib/capabilities/voice.js +94 -26
  24. package/dist-lib/lib/connect.d.ts +4 -1
  25. package/dist-lib/lib/connect.js +16 -1
  26. package/dist-lib/lib/contract-types.d.ts +12 -0
  27. package/dist-lib/lib/helpers/tool-call-markup.d.ts +1 -0
  28. package/dist-lib/lib/helpers/tool-call-markup.js +3 -0
  29. package/dist-lib/lib/models/context-choice.d.ts +15 -0
  30. package/dist-lib/lib/models/context-choice.js +55 -0
  31. package/dist-lib/lib/models/download-gate.d.ts +20 -0
  32. package/dist-lib/lib/models/download-gate.js +73 -0
  33. package/dist-lib/lib/models/memory-cap.d.ts +13 -0
  34. package/dist-lib/lib/models/memory-cap.js +21 -0
  35. package/dist-lib/lib/models/model-catalog-constants.d.ts +3 -1
  36. package/dist-lib/lib/models/model-catalog-constants.js +9 -1
  37. package/dist-lib/lib/models/model-catalog.d.ts +6 -2
  38. package/dist-lib/lib/models/model-catalog.js +35 -3
  39. package/dist-lib/lib/models/model-handle.d.ts +8 -1
  40. package/dist-lib/lib/models/model-handle.js +20 -2
  41. package/dist-lib/lib/models/model-list.d.ts +8 -1
  42. package/dist-lib/lib/models/model-list.js +23 -0
  43. package/dist-lib/lib/models/model-pool.d.ts +35 -5
  44. package/dist-lib/lib/models/model-pool.js +97 -32
  45. package/dist-lib/lib/models/sdk-faults.d.ts +1 -0
  46. package/dist-lib/lib/models/sdk-faults.js +6 -0
  47. package/dist-lib/lib/office/engine/pptx/picture-area.d.ts +23 -0
  48. package/dist-lib/lib/office/engine/pptx/picture-area.js +84 -0
  49. package/dist-lib/lib/office/engine/pptx/presentation.d.ts +2 -0
  50. package/dist-lib/lib/office/engine/pptx/presentation.js +55 -17
  51. package/dist-lib/lib/office/engine/pptx/types.d.ts +1 -0
  52. package/dist-lib/lib/office/engine/xlsx/workbook.d.ts +1 -0
  53. package/dist-lib/lib/office/engine/xlsx/workbook.js +34 -3
  54. package/dist-lib/lib/serve.js +23 -3
  55. package/dist-lib/lib/spawn.js +4 -10
  56. package/dist-lib/lib/tools/exec.d.ts +4 -1
  57. package/dist-lib/lib/tools/exec.js +55 -11
  58. package/dist-lib/lib/tools/generate-image.d.ts +4 -2
  59. package/dist-lib/lib/tools/generate-image.js +5 -2
  60. package/dist-lib/lib/tools/generate-music.d.ts +4 -2
  61. package/dist-lib/lib/tools/generate-music.js +5 -2
  62. package/dist-lib/lib/tools/office/ops.js +1 -1
  63. package/dist-lib/lib/tools/office.js +29 -6
  64. package/dist-lib/lib/tools/skills/catalog.d.ts +1 -0
  65. package/dist-lib/lib/tools/skills/catalog.js +2 -0
  66. package/dist-lib/lib/tools/skills/cli-schema-check.d.ts +1 -0
  67. package/dist-lib/lib/tools/skills/cli-schema-check.js +3 -0
  68. package/dist-lib/lib/tools/skills/cli-schema-load.js +10 -0
  69. package/dist-lib/lib/tools/skills/cli-schema-types.d.ts +2 -0
  70. package/dist-lib/lib/tools/skills/cli-schema.js +6 -3
  71. package/dist-lib/lib/tools/skills/frontmatter-parser.d.ts +1 -0
  72. package/dist-lib/lib/tools/skills/manifest-parse.d.ts +1 -0
  73. package/dist-lib/lib/tools/skills/manifest-parse.js +12 -0
  74. package/dist-lib/lib/tools/skills/mention.d.ts +0 -1
  75. package/dist-lib/lib/tools/skills/mention.js +0 -3
  76. package/dist-lib/lib/tools/translate.d.ts +3 -1
  77. package/dist-lib/lib/tools/translate.js +6 -1
  78. package/dist-lib/schema/harness.js +43 -4
  79. package/dist-lib/spec/bind.js +9 -0
  80. package/dist-lib/spec/calls.js +3 -0
  81. package/dist-lib/spec/hrpc/hrpc.json +39 -0
  82. package/dist-lib/spec/hrpc/index.js +39 -3
  83. package/dist-lib/spec/hrpc/messages.js +101 -13
  84. package/dist-lib/spec/hyperschema/index.js +101 -13
  85. package/dist-lib/spec/hyperschema/schema.json +62 -1
  86. package/dist-lib/spec/hyperschema/types.d.ts +20 -2
  87. package/package.json +8 -10
  88. package/worker/harness-sidecar.mjs +2 -1
package/CHANGELOG.md CHANGED
@@ -8,43 +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
+
117
+ ## [0.8.6] - 2026-10-08
118
+
119
+ ### Added
120
+
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
+
150
+ - `setDownloadsAllowed({ allowed })`, in process and over hrpc, lets a host hold
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
162
+ runtimes and every pooled load (chat, embedding, speech, transcription).
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
+
183
+ - Ternary Bonsai 2 27B joins the curated catalog in two builds,
184
+ `Ternary-Bonsai-2-27B-PQ2_0` (7.2 GB) and `Ternary-Bonsai-2-27B-PTQ1_0`
185
+ (5.9 GB), each with its vision projection. Both sit in the `high` tier, by
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)
199
+
200
+ ### Changed
201
+
202
+ - `@qvac/sdk` and `@qvac/inference` 0.21.0, with `@qvac/llm-llamacpp` 0.55.0.
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
207
+ `assessModelFit` input. (#302)
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)
212
+
213
+ ### Fixed
214
+
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)
219
+
11
220
  ## [0.8.5] - 2026-10-06
12
221
 
13
222
  ### Added
14
223
 
15
- - Skill-gated `pptx`, `docx` and `xlsx` tools on the office engine. A call takes
16
- an `attachmentId` (or `create` options), `ops` that name engine methods with
17
- named arguments, and an `output` file name; with an `attachmentId` alone it
18
- returns the file's contents. The first failing op saves nothing and returns
19
- the engine's error. An edit is never saved under the attached file's own
20
- name. Saving the same file again in one turn answers with the one already
21
- attached, and a changed file under that name is refused. The one-line answer
22
- comes back as `reply`. These are the tools the presentations, word and excel
23
- 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)
24
251
 
25
252
  ### Changed
26
253
 
27
254
  - **Breaking:** `@qvac/skills` is now 0.1.12, whose office and pdf skills run on
28
255
  the `pptx`, `docx`, `xlsx` and `pdf` tools instead of Python. A host that
29
256
  passes its own `skillsDir` needs 0.1.12 as well: older office skills import
30
- Python libraries this release no longer ships. (#286)
31
- - `extractOfficeText` and `extractDocxText` read .docx, .xlsx and .pptx in
32
- 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
33
272
  across a workbook. Extracted workbook booleans read `true`/`false` instead of
34
273
  `True`/`False`. `closeOfficeWorker` and `closeDocxWorker` remain as no-ops.
35
274
  (#286)
36
275
  - `exec` refuses Python that imports `pptx`, `docx` or `openpyxl`, or declares
37
- their packages, before it runs, and names the tool (or the skill that grants
38
- 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)
39
279
  - **Breaking:** Python run through `exec` asks for approval like any other
40
- 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)
41
296
 
42
297
  ### Removed
43
298
 
44
299
  - **Breaking:** the vendored Python wheels for python-pptx, python-docx,
45
300
  openpyxl, fpdf2 and pypdf, and their dependencies. Only micropip is still
46
301
  seeded; any other package installs from PyPI, which sandbox mode refuses.
47
- 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
+
48
313
  - The `exec` guidance written for python-pptx, python-docx and fpdf2 code:
49
314
  their error hints and source checks, and the shrink-to-fit pass on saved
50
315
  decks. (#288, #289)
@@ -53,42 +318,64 @@ While the major version is `0`, a breaking change bumps the minor version.
53
318
 
54
319
  ### Added
55
320
 
56
- - A skill-gated `pdf` tool on `bare-pdfium` 1.6.0. With an `attachmentId` and
57
- no `action` it reads a PDF: the page count, each page's text and the form
58
- fields. With an `action` it saves a new file named `output` and attaches it:
59
- `create` from Markdown (chat images placed with `![caption](attachmentId)`),
60
- `merge`, `split`, `rotate`, `watermark`, `fill`, `encrypt`, `decrypt` and
61
- `text`. Errors name what exists, a locked PDF takes its password on any
62
- action, and the one-line answer comes back as `reply`. On Node it reports
63
- that the device cannot open PDFs. The pdf skill in the next `@qvac/skills`
64
- release uses it instead of Python. (#256)
65
- - An office engine in `lib/office/engine/` that reads, edits and creates `.pptx`,
66
- `.docx` and `.xlsx` files in TypeScript on Bare and Node, so office editing no
67
- longer needs Python and can run on mobile. It keeps untouched parts byte for
68
- byte, fails with a `DocsError` whose code names the problem, and moves Excel
69
- references when rows and columns change. It adds `@xmldom/xmldom`, makes
70
- `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)
71
355
 
72
356
  ### Fixed
73
357
 
74
- - Qwen3.5-family models: a tool call that ran goes back into the chat history
75
- in the Qwen3.5 template's own format (`<tool_call>` wrapper, one line per tag,
76
- string values as written) instead of a compact `<function=…>` line. A model
77
- several calls into a turn copied the compact form, the tool grammar rejected
78
- it, and the run ended in "This didn't finish". Models with another tool format
79
- (e.g. MedPsy) still get a format that is not their own, until the SDK accepts
80
- structured tool calls in history. (QVAC-23092, #146)
81
- - A skill loaded with a `file` it does not have, before its instructions were
82
- loaded, now returns those instructions with a line naming the files it has,
83
- so the skill counts as loaded and its skill-gated tools appear. Before, a
84
- one-file skill answered that the model already had its instructions, and its
85
- tools stayed hidden. (#257)
86
- - After a tool attaches a file, the loop tells the model the file is shown and
87
- to answer, allows one more tool round, then writes the answer in a tool-less
88
- round. Small models no longer keep calling tools on their own output until
89
- the round cap. (#258)
90
- - `exec`: a `$` before a digit, a space or punctuation in a shell command is
91
- 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
92
379
  shell expansion is still refused, now with a hint to single-quote the text.
93
380
  (#208)
94
381
 
@@ -96,223 +383,419 @@ While the major version is `0`, a breaking change bumps the minor version.
96
383
 
97
384
  ### Added
98
385
 
99
- - A tool result may carry `answer: true` beside its `content` to end the turn
100
- with that content as the reply, without another round through the model.
101
- The `tool-result` event still fires. `weather_lookup` uses it for an
102
- ambiguous or unknown place: the question that lists the namesakes, or the
103
- 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)
104
410
 
105
411
  ### Fixed
106
412
 
107
- - `weather_lookup` finds the place before it asks for the weather. It looks the
108
- city up on Open-Meteo's geocoder, `geocoding-api.open-meteo.com`, a new
109
- outbound host, and asks wttr.in for the forecast at the coordinates of the
110
- place it picked, which leads the report. A bare "Nassau" now reports Nassau,
111
- Bahamas. It used to ask wttr.in by name, whose own geocoder put it in
112
- Westbury, New York. A name that places of a similar size share, such as
113
- Springfield, gets a question listing up to four of them instead of a
114
- forecast: any namesake with a tenth of the largest one's population counts.
115
- A country or state in the request picks among them. When the geocoder is
116
- blocked, slow or failing, or knows no such place, the lookup asks wttr.in by
117
- 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)
118
429
 
119
430
  ## [0.7.2] - 2026-10-01
120
431
 
121
432
  ### Added
122
433
 
123
- - `web_fetch` attaches a plain-text file (.txt, .csv, .json…) to the chat
124
- besides returning its text, so the user can open or save it and `exec` can
125
- stage it to read past the returned text. The result carries its
126
- `attachmentId`, `fileName` and `byteLength`. A body cut at the fetch cap is
127
- not attached, and a host that cannot store files gets the text alone, as
128
- 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)
129
452
 
130
453
  ### Changed
131
454
 
132
455
  - Speech sounds cleaner on desktops. Devices on the `mid` profile and up now
133
456
  speak with Supertonic 3 FP16 (`Supertonic3-FP16`, 207 MB) instead of Q8_0
134
457
  (127 MB), at 16 inference steps instead of 5, which removes the artifacts
135
- the 5-step output had. Synthesis takes about 2.7× as long, still around 7×
136
- faster than real time on an M-series Mac. Phones (`low`) and `tiny` devices
137
- keep Q4_0 at 5 steps. A host that prefetches `infraModelsFor` downloads the
138
- FP16 model once; `Supertonic3-Q8_0` stays in the catalog, and now also runs
139
- 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)
140
474
 
141
475
  ## [0.7.1] - 2026-09-30
142
476
 
143
477
  ### Added
144
478
 
145
- - `list()` rows carry `isLoading`: true from the moment a load of that model
146
- starts, whoever asked for it, until it is ready or the load fails. It is read
147
- off the pool, so a host shows a model as loading without counting loads
148
- itself. A model loading again under other settings while a copy of it is
149
- resident reads `isLoaded` and `isLoading` both true. Chat models read it
150
- today: image, music and translation models load for each generation, and
151
- speech, transcription and embedding pools key by source, so those rows read
152
- `false` for now. An optional field on the wire; a row from an older harness
153
- 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).
154
491
 
155
492
  ```js
156
493
  const loading = (await harness.list({ cachedOnly: true })).filter((row) => row.isLoading)
157
494
  ```
158
495
 
496
+ (#143)
497
+
159
498
  ### Changed
160
499
 
161
- - The `model` event carries `catalogChanged` at every edge a `list()` re-read
162
- would show: the `loading` that starts a load and the `ready` or `error` that
163
- ends it, beside `unloaded`. Progress ticks still do not. A host that re-reads
164
- the list on `catalogChanged` sees a model turn loading, then loaded, with no
165
- bookkeeping of its own. The served event stream never drops a frame that
166
- 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
+
167
517
  - `ModelInfo` requires `isLoading`, and `listModels`, `customModelRows` and
168
518
  `mergeCustomRows` return `CatalogRow`, the row before the pools are asked. A
169
- 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
+
170
531
  - A custom model's row reads `isLoaded` only once its load has landed; it used
171
532
  to read `true` from the first attempt. (#143)
172
533
 
173
534
  ### Fixed
174
535
 
175
- - A load that ends without the model in memory emits `model` with `error` and
176
- the reason: it failed, or a suspend or close cancelled it. It used to end in
177
- silence, so a host could not tell a failed load from one still running. The
178
- `error` also refreshes the cached `isCached`, since a load may have downloaded
179
- 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)
180
541
 
181
542
  ## [0.7.0] - 2026-09-29
182
543
 
183
544
  ### Added
184
545
 
185
- - `list()` rows carry `taskType`, what the model is for: `llm`, `embeddings`,
186
- `speech-to-text`, `text-to-speech`, `image-generation`, `audio-generation`,
187
- `translation`, `text-recognition` or `other`. It is decided from the addon in
188
- one place, `lib/models/model-task.ts`, on each side of the wire rather than
189
- sent over it. `ModelTaskType` is exported, and grows the four new values a
190
- 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`.
550
+
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)
191
561
 
192
- - `modelsForAgent(agent, host)` lists every model an agent runs on a device,
562
+ - `modelsForAgent(agent, host)` answers which models an agent runs on a device,
193
563
  each with its `use`: the chat model, clamped by `runnableModelName`, then the
194
564
  image and music models its skills need. A host deciding what to download, or
195
565
  which agents a model delete affects, asks this instead of working it out.
196
- `runnableModelName(requested, host)` and `isMobilePlatform(platform)` move here
197
- from `@qvac/core`: on a phone a pick above the device's profile runs as its
198
- recommended model. Whether the host is a phone is the required `mobile` on
199
- `HostCapability`, which `capability()` now reports, so its answer passes
200
- straight in; a host built by hand has to say. Both functions are on the
201
- 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.
202
574
 
203
575
  ```js
204
- 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' }]
585
+ ```
586
+
587
+ ```js
588
+ import { runnableModelName, QWEN_35_4B, QWEN_35_9B } from '@qvac/harness/model-catalog'
589
+
590
+ const host = { capabilityProfile: 'mid', recommendedModelName: QWEN_35_4B, mobile: true }
591
+ runnableModelName(QWEN_35_9B, host) // QWEN_35_4B
205
592
  ```
206
593
 
207
- - `capability()` answers `mobile`, whether the harness runs on a phone, read off
208
- the platform it probes. It rides `capability-response` as an optional field;
209
- a response from an older harness reads as `false`. (#141)
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
+ ```
210
616
 
211
- - `musicModelChoiceFrom(settings)` reads the music model pick out of an agent's
212
- `generate_music` tool settings, so a host no longer knows what `modelChoice`
213
- means there. `isMusicModelChoice` is exported beside it. (#141)
617
+ (#141)
214
618
 
215
619
  ### Changed
216
620
 
217
- - **Breaking:** the package no longer publishes a prebuilt sidecar:
621
+ - **Breaking:** the package no longer publishes a prebuilt sidecar.
218
622
  `dist/harness.bundle`, `dist/harness.cjs`, `dist/prebuilds-manifest.json` and
219
623
  the `./dist/harness.cjs` export are gone. A host stows
220
624
  `worker/harness-sidecar.mjs` itself, as desktop and mobile already do, or
221
- spawns `worker/stow-entry.mjs` straight from its install. (#127)
222
- - **Breaking:** a stowed harness runs python in a worker rather than in-process.
223
- A host that stows the harness stops deferring `pyodide`, keeps
224
- `QVAC_PYODIDE_CACHE_DIR` writable, no longer stages a separate worker bundle,
225
- and ships what stow offloads beside the bundle, which now includes pyodide's
226
- wasm, stdlib and lock file and the vendored wheels. A target that embeds
227
- assets instead of offloading them cannot run python, since `fs` cannot read
228
- inside a bundle. (#127)
229
- - A bundle of the package root carries neither pyodide nor the vendored wheels.
230
- Only an entry that runs python, the exec tool or `@qvac/harness/docx` and
231
- `/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)
232
662
  - `SandboxHook` no longer checks `packages`. The exec tool refuses an install
233
663
  the vendored wheels do not hold in full in sandbox mode, whether declared in
234
664
  `packages` or on a pip line, with the same message as before. (#127)
235
665
  - The vendored wheels are the `#pyodide-wheels` directory asset on Bare;
236
666
  `QVAC_PYTHON_WHEELS_DIR` still overrides it. (#127)
237
667
  - The python worker and the in-process deadline are spawned through
238
- `worker_threads`, which the package maps to `bare-worker`;
668
+ `worker_threads`, which the package maps to `bare-worker`.
239
669
  `#pyodide-worker-spawn` is gone. (#127)
240
- - **Breaking:** `engines.bare` is `>=1.34.0`, so Bare refuses to load the
241
- package on older runtimes. `bare-runtime` is pinned to 1.34.0 and
242
- `bare-sidecar` to 0.5.7 (Bare 1.34.0) across the tree, `bare-supervisor`'s copy
243
- included, with `bare-thread` at least 1.2.7 and `bare-module` 7.1.0 for
244
- `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)
245
675
  - `bare-module` is no longer a dependency or an override, and
246
- `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
247
677
  `asset` condition on `bare-sidecar`'s binary, and a stowed harness then fails
248
678
  to boot on Bare 1.33 and later. (#127)
249
679
  - `skillModelsForAgent` rows carry `use`, `image` or `music`, so a caller can
250
- tell which skill a model is for without looking the name up in the catalogs. (#141)
251
- - `CapabilityInfo` has a required `mobile`, so a `HarnessContract` whose
252
- `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)
253
698
 
254
699
  ### Removed
255
700
 
256
- - **Breaking:** `bareRuntime` is no longer exported from `./lib/tools/exec.ts`.
257
- It is at `./lib/tools/exec/bare-runtime.ts`, so the package root no longer
258
- loads the exec tool to reach it. (#127)
259
- - **Breaking:** `QVAC_PYODIDE_TEST_LOADER`: tests point
260
- `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)
261
726
 
262
727
  ### Fixed
263
728
 
264
- - Python runs start on Bare 1.34, whose threads cannot read a module off disk,
265
- including in a stowed harness. The worker is spawned through `worker_threads`,
266
- which the package maps to `bare-worker` on Bare, pyodide is handed its asm
267
- module as `createPyodideModule`, and the files pyodide reads are named as
268
- 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)
269
734
 
270
735
  ## [0.6.0] - 2026-09-28
271
736
 
272
737
  ### Added
273
738
 
274
- - `loadModel({ model, agent })` loads a downloaded chat, embedding, speech or
275
- transcription model ahead of use, under the key a request for it would load
276
- under, so the first request finds it loaded. `unloadModel({ model })` unloads
277
- every copy of a model once the requests holding it finish, and says whether it
278
- could. Image and music models load for each generation and are refused. A load
279
- or an unload counts as a call in flight for `setModelIdleTimeout`, and one that
280
- lands during an idle unload waits for it. Also on the wire as `load-model` and
281
- `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).
282
753
 
283
754
  ```js
755
+ import { QWEN_35_4B } from '@qvac/harness'
756
+
284
757
  const { loaded } = await harness.loadModel({ model: QWEN_35_4B, agent: { ctxSize: 8192 } })
285
758
  const { unloaded } = await harness.unloadModel({ model: QWEN_35_4B })
286
759
  ```
287
760
 
288
- - A chat model loaded with `loadModel` is pinned: it does not count against
289
- `completionModelCap`, so several stay loaded at once and a request for
290
- another model leaves them in place. A manual load never unloads another model
291
- to fit; it returns `{ loaded: false, reason: 'memory' }` when the SDK's
292
- `assessModelFit` says it would not fit beside what is loaded. A request always
293
- gets its model: pinned models no reply is using give way oldest first when it would not fit,
294
- and a request or a manual load for a model under other settings replaces its idle copy. Image and
295
- 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).
296
775
 
297
776
  ```js
777
+ import { QWEN_35_9B } from '@qvac/harness'
778
+
298
779
  const { loaded, reason } = await harness.loadModel({ model: QWEN_35_9B })
299
780
  if (reason === 'memory') showNotice('Unload a model first')
300
781
  ```
301
782
 
783
+ (#134)
784
+
302
785
  ### Changed
303
786
 
304
- - A model loading or unloading in any pool emits `model` with `catalogChanged`,
305
- not only a chat model, so `list()` reports a fresh `isLoaded` for embedding,
306
- speech and transcription models too. (#133)
307
- - Speech, transcription and embedding requests key their pool entry by the
308
- model's registry src however the caller names it, so a model named once by
309
- 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)
310
793
 
311
794
  ### Fixed
312
795
 
313
796
  - A chat model loads when the host passes no `llmDevice`. 0.5.0 sent `device`,
314
797
  `gpu_layers`, `main-gpu` and `openclCacheDir` to the SDK as `undefined`,
315
- 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
316
799
  "must specify a device: 'gpu' or 'cpu'", and with a device but no
317
800
  `llmGpuLayers` the default of 99 GPU layers was lost. Unset fields are left
318
801
  out again. (#137)
@@ -321,25 +804,39 @@ While the major version is `0`, a breaking change bumps the minor version.
321
804
 
322
805
  ### Added
323
806
 
324
- - `setModelIdleTimeout(ms)` frees memory once the device has been idle that
325
- 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`,
326
809
  `speakStream` or `warmModel` call in flight, every idle resident model is
327
- unloaded, including the image, music and translation runtimes. The next call
328
- loads what it needs again. `0`, the default, is Never, and arms no timer. A
329
- turn waiting on a tool or an approval between rounds counts as busy, so it
330
- 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).
331
821
 
332
822
  ```js
333
- await harness.setModelIdleTimeout(30 * 60_000)
823
+ await harness.setModelIdleTimeout(30 * 60_000) // 0 turns it off
334
824
  ```
335
825
 
826
+ (#131)
827
+
336
828
  - `setCustomModels` entries take an optional `label`, the name the user gave
337
- the model, and `list()` rows carry it as `label`. The tool-call format is
338
- matched against the label as well as the file name, so a custom model
339
- 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)
340
835
 
341
836
  - `setCustomModels` entries take an optional `contextLength`, the model's
342
- 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).
343
840
 
344
841
  ```js
345
842
  await harness.setCustomModels([
@@ -350,23 +847,39 @@ While the major version is `0`, a breaking change bumps the minor version.
350
847
  contextLength: preview.contextLength
351
848
  }
352
849
  ])
850
+
851
+ const [row] = (await harness.list({ type: 'llm' })).filter(
852
+ (model) => model.name === 'custom-7f3a'
853
+ )
854
+ row.label // 'Gemma 4 E4B'
353
855
  ```
354
856
 
857
+ (#124)
858
+
355
859
  ### Changed
356
860
 
357
861
  - A custom model loads with a context of the smaller of 51,200 tokens and its
358
- 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
359
864
  still wins. (#124)
360
865
 
361
866
  ### Fixed
362
867
 
363
868
  - `previewModelSource` takes a direct link to one shard of a split `.gguf` as
364
- the whole set: `src` is the first part, `sizeBytes` adds up every part, and
365
- the label names the count, as in `model.gguf (3 parts)`. A missing part
366
- refuses with `not-found` or `unreachable`. (#124)
367
-
368
- - `isCached` reports a sharded custom download once every shard is on disk,
369
- 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)
370
883
 
371
884
  ## [0.4.0] - 2026-09-24
372
885
 
@@ -375,27 +888,48 @@ While the major version is `0`, a breaking change bumps the minor version.
375
888
  - `previewModelSource({ src, mmprojSrc })` checks a Hugging Face link, a direct
376
889
  `.gguf` link or a local path before a host adds it as a custom model. It
377
890
  reads the Hugging Face metadata and the file's GGUF header, never the whole
378
- model. The answer is a file to register (`src`, `sizeBytes`,
379
- `contextLength`, `architecture`, `sizeLabel`, `taskType`, `license`, `fit`
380
- and `fitReasons`), a list of `files` to pick from for a repo that holds
381
- several, or `ok: false` with a `refusal` code and a `reason` to show. It is
382
- served on the `RemoteHarness` client and on an in-process `Harness`, and its
383
- 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).
384
909
 
385
910
  ```js
386
911
  const preview = await harness.previewModelSource({ src: link })
387
912
 
388
913
  if (!preview.ok) return showError(preview.reason)
389
- if (!preview.src) return pickOneOf(preview.files) // then preview files[i].src
390
- 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
+ }
391
918
  ```
392
919
 
393
- - `setCustomModels` entries take `taskType` and `expectedSize`, and `list()`
394
- returns the registered custom models after the catalog rows, with
395
- `registrySource: 'custom'`, `isLoaded`, and an `isCached` that is false
396
- until every file is on disk and, when `expectedSize` is set, adds up to it.
397
920
  (#121)
398
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
+
399
933
  ```js
400
934
  await harness.setCustomModels([
401
935
  {
@@ -411,56 +945,90 @@ While the major version is `0`, a breaking change bumps the minor version.
411
945
  const custom = models.filter((model) => model.registrySource === 'custom')
412
946
  ```
413
947
 
948
+ (#121)
949
+
414
950
  - A `modelsDir` Harness option says where downloads live, so `list()` and
415
951
  `deleteModel` can find a custom model's files. The spawned sidecar sets it
416
- 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).
417
954
 
418
955
  ```js
419
956
  const harness = new Harness({ modelsDir })
420
957
  ```
421
958
 
959
+ (#121)
960
+
422
961
  ### Changed
423
962
 
424
- - `deleteModel(name)` removes the weight and projection a custom model
425
- downloaded, where it used to remove nothing. It never touches a local path
426
- the user pointed at, and the model stays registered, listed as not cached.
427
- (#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).
428
971
 
429
972
  ```js
430
973
  const { removed, freedBytes } = await harness.deleteModel('nanbeige-3b')
431
974
  ```
432
975
 
976
+ (#121)
977
+
433
978
  - A custom model name resolves only where an `llm` is loaded, never for
434
- another task, and a `setCustomModels` entry whose `taskType` is anything
435
- other than `llm` is not registered. An entry without `taskType` is an `llm`,
436
- 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)
437
982
 
438
983
  ### Fixed
439
984
 
440
- - A turn that preloads a skill with `@skill`, or names an active skill in its
441
- prompt, no longer caches the inlined SKILL.md body under the chat's KV key.
442
- Each such turn left about 7k tokens stranded in the cache, until a later turn
443
- 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)
444
990
 
445
991
  ## [0.3.3] - 2026-09-24
446
992
 
447
993
  ### Changed
448
994
 
449
- - `@qvac/sdk` and `@qvac/inference` 0.20.0. Some addons now ship their
450
- prebuilds as per-platform packages (`@qvac/<addon>-<os>-<cpu>`, for
451
- `audiogen-ggml` 0.4 and `tts-ggml` 0.9), which the addon names in its
452
- `#host-addon` import map, and `@qvac/model-fit` and `@qvac/fabric` join the
453
- native graph as deferred addons. A host that packages the addons resolves
454
- prebuilds through that map, and exempts the new package names from any
455
- install age gate: bun skips a blocked optional dependency without a word,
456
- 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
+
457
1023
  - A user message with several images reaches the SDK as one turn again. The
458
1024
  per-image split added for QVAC-24156 is gone, since SDK 0.20 carries the
459
1025
  upstream fix (llama.cpp #27348) and equal-size still images no longer merge
460
- as video frames and abort the encode. This release must not be paired with
461
- SDK 0.19, where that crash comes back. `<key>-vision-history-v1` cache
462
- directories left by the split are no longer read, and clearing all caches
463
- 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
+
464
1032
  - `@qvac/skills` 0.1.9. The diagrams skill carries every recipe inline, so a 2B
465
1033
  model no longer loops on loading a reference file it cannot name. The excel
466
1034
  skill routes a chat image to create rather than edit, the Google OAuth routes
@@ -478,16 +1046,21 @@ While the major version is `0`, a breaking change bumps the minor version.
478
1046
 
479
1047
  ### Changed
480
1048
 
481
- - A networked Python run's approval label leads with the hosts its source names
482
- 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
483
1054
  `network: no host named in the code`. On an approved run those hosts, and
484
- their `www.` form, are reached without asking; a subdomain is not. Any other
485
- host, such as a CDN a library fans out to, a redirect or a URL built at
486
- 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
487
1059
  `askUser` before its name is looked up. A host allowed once stays allowed
488
1060
  for that run, and silence past a minute counts as a no. A host that does not
489
1061
  wire `askUser` refuses every unnamed host, and a skip-all run asks nothing.
490
- (#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).
491
1064
 
492
1065
  ```js
493
1066
  for await (const event of harness.completion({
@@ -502,52 +1075,85 @@ While the major version is `0`, a breaking change bumps the minor version.
502
1075
  }
503
1076
  ```
504
1077
 
1078
+ (#109)
1079
+
505
1080
  ### Fixed
506
1081
 
507
- - A timed-out Python run spun at full CPU until the process was killed when
508
- Pyodide had no worker thread, as for a stowed bundle whose host does not set
509
- `QVAC_PYODIDE_WORKER_ENTRY`. It is now interrupted at the deadline, rejects
510
- 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
511
1088
  blocking call such as a long `time.sleep` still sees the interrupt only once
512
- it returns. (#104)
513
- - A timed-out Python run on the worker thread could miss its SIGINT, so the
514
- worker was killed after the grace window along with the interpreter's state.
515
- The interrupt is resent every 100 ms until the run settles. (#112)
516
- - A Python run that failed partway through a save attached the half-written
517
- file, and each failing retry attached another copy under the same name. A
518
- non-zero exit now attaches nothing and reports every declared output in
519
- `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)
520
1103
 
521
1104
  ### Security
522
1105
 
523
1106
  - Python in `exec` reached the network whenever the runtime allowed it,
524
1107
  including a call that auto-ran as a read with no approval prompt, so a
525
1108
  document build that loads an image from a URL could reach the web without
526
- anyone seeing it. The network now opens only to a call a person approved or
527
- 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).
528
1115
  (#108)
529
1116
 
530
1117
  ## [0.3.1] - 2026-09-23
531
1118
 
532
1119
  ### Added
533
1120
 
534
- - Python in `exec` reaches the network on Bare: `urllib`, `http.client` and
535
- what is built on them, yt-dlp included, work unchanged, and `requests` works
536
- through urllib3's emscripten backend. Every request goes through the guarded
537
- fetch the web tools use, so each hop is checked before it is sent,
538
- credentials do not follow a redirect to another origin, and a response is
539
- capped at 100 MB. Where the global `fetch` is not the guarded one, as on
540
- Node, Python stays offline, and sandbox mode keeps it offline everywhere. A
541
- Python run may now declare `.mp4`, `.webm`, `.m4a` and `.mp3` outputs, each
542
- checked by its file signature. (#94)
543
- - `SkillOAuthConfig.verify`, parsed from a skill manifest's
544
- `setup.routes[].oauth.verify` and carried over the wire, so a host can probe a
545
- fresh token before saving it. A probe is an https `url` on the same site as
546
- the route's `tokenUrl`, an optional `GET` or `POST` `method` and JSON `body`,
547
- and `reject` rules. A rule matches a listed `status`, or a 2xx JSON body whose
548
- `field` is not `notEqual`, and its `error` names the rejection. A probe that
549
- breaks these rules is dropped and the route kept. `SkillOAuthVerify` and
550
- `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)
551
1157
 
552
1158
  ```js
553
1159
  const skills = await harness.skills()
@@ -562,63 +1168,95 @@ While the major version is `0`, a breaking change bumps the minor version.
562
1168
  ```
563
1169
 
564
1170
  - A skill operation may name `response.decode` to turn a provider's JSON body
565
- into a compact shape before the model sees it, starting with
566
- `gmail-message`: the ids and labels, the From, To, Subject, Date, Message-ID
567
- and References headers, the snippet, and the body as text, capped at 4000
568
- characters. A `gmail-get` request builder joins it. A manifest naming both
569
- `pick` and `decode`, or a builder or decoder this harness does not have, is
570
- 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
+ ```
571
1194
 
572
1195
  ### Changed
573
1196
 
574
- - A compiled skill operation whose HTTP method is `GET`, `HEAD` or `OPTIONS` is
575
- a read: it auto-runs without an approval prompt, like the same request through
576
- `http_request`, and takes no `intent` argument. MCP operations and every other
577
- method are still writes that ask. (#69)
578
- - `@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
579
1202
  `gmail_get` instead of telling the model to decode base64, which needs this
580
1203
  release's decoder, and Asana rejects a non-MCP app at connect time through
581
1204
  a `verify` probe. (#105)
582
1205
 
583
1206
  ### Fixed
584
1207
 
585
- - A malformed `<tool_call>` left its JSON arguments in the reply. The whole span
586
- is now stripped, and an unclosed one is stripped to the end of the text.
587
- (#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)
588
1211
  - A misspelt, placeholder or repeated `skill` load returned an error the model
589
1212
  retried until the turn ran out. A placeholder or the skill's own name now
590
- reads the body, a near miss of one of its files loads that file, and a repeat
591
- 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)
592
1215
  - When the model reached for `pip` or a video tool on the shell path, it was
593
1216
  only told what the path is not, and retried the same call. Each refusal now
594
1217
  names the call that works, such as `packages: ["requests"]` with the pin a
595
- vendored package installs offline under. A missing import names the package
596
- that provides it, and `python -c`, a heredoc or bare Python source runs as
597
- 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)
598
1225
 
599
1226
  ## [0.3.0] - 2026-09-23
600
1227
 
601
1228
  ### Added
602
1229
 
603
- - A `translate` capability for `invoke`: it translates text without loading an
604
- LLM. The language pair picks the route before anything loads. English and
605
- one of nine European languages go through the TranslatePsy-EuroNano packs,
606
- the other 43 languages Bergamot serves go through their per-pair models
607
- (pivoting through English when neither side is English), and 16 languages
608
- Bergamot has no model for go through TranslatePsy-AfriSLM, named by ISO 639-1
609
- or FLORES-200 code. An omitted `from` is detected from the text, except for
610
- the AfriSLM languages, which need it named. The `tool-result` carries the
611
- 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)
612
1247
 
613
1248
  ```js
614
1249
  for await (const event of harness.invoke({
615
1250
  capability: 'translate',
616
1251
  args: { text: 'The weather is nice today.', to: 'de' }
617
1252
  })) {
618
- 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
+ }
619
1257
  if (event.type === 'error') showError(event.message)
620
1258
  }
621
- // result: { text, from, to: 'de', engine: 'nmt', pivoted: false }
1259
+ // result: { text, from: 'en', to: 'de', engine: 'nmt', pivoted: false }
622
1260
  ```
623
1261
 
624
1262
  - `translationPlanFor`, `NMT_TO_ENGLISH`, `NMT_FROM_ENGLISH`,
@@ -635,18 +1273,23 @@ While the major version is `0`, a breaking change bumps the minor version.
635
1273
  if ('error' in plan) showError(plan.error)
636
1274
  ```
637
1275
 
638
- - `enhance: 'never' | 'auto'` on `invoke`. With `'auto'`, an image prompt is
639
- rewritten by a model already resident on the device, and never triggers a
640
- 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
+
641
1282
  `metrics` reports the `route` taken (`direct` or `enhanced`), the
642
- `routeModel` used and the tokens the rewrite spent. Omitting `enhance`
643
- 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)
644
1286
 
645
1287
  ```js
646
1288
  for await (const event of harness.invoke({
647
1289
  capability: 'image',
648
1290
  args: { prompt: 'funny meme' },
649
- enhance: 'auto'
1291
+ enhance: 'auto',
1292
+ attach
650
1293
  })) {
651
1294
  if (event.type === 'metrics') log(event.route, event.routeModel)
652
1295
  }
@@ -654,33 +1297,56 @@ While the major version is `0`, a breaking change bumps the minor version.
654
1297
 
655
1298
  ### Changed
656
1299
 
657
- - The bundle defers `@qvac/translation-nmtcpp` like the other inference
658
- addons, so `prebuilds-manifest.json` now names its prebuilds and a host that
659
- assembles them beside the bundle has to include it. It is already a
660
- 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)
661
1307
 
662
1308
  ## [0.2.12] - 2026-09-22
663
1309
 
1310
+ The first published release with the 0.2.11 changes (`invoke`,
1311
+ `detectLanguage`, `SkillOAuthConfig`), plus the fix below.
1312
+
664
1313
  ### Fixed
665
1314
 
666
- - Sandbox mode refused every `exec` call that installs packages, which broke
667
- the pdf, presentations, word and excel skills. A request whose packages are
668
- all vendored now installs from the shipped wheels; one that would fall back
669
- 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)
670
1325
 
671
1326
  ## [0.2.11] - 2026-09-22
672
1327
 
673
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.
674
1330
 
675
1331
  ### Added
676
1332
 
677
1333
  - `invoke({ capability, args })` runs one capability on its own, with no
678
1334
  model, no dispatch and no approval callback: the caller already decided.
679
- `image` is the first capability. It is a frame on the existing `completion`
680
- stream, so it takes the same `attach`, `progress` and abort signal and emits
681
- the same `tool-call`, `tool-result`, `metrics`, `error` and `aborted` events.
682
- `metrics` reports `totalTokens: 0`. It is on `Harness` and `RemoteHarness`
683
- 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)
684
1350
 
685
1351
  ```js
686
1352
  for await (const event of harness.invoke({
@@ -690,14 +1356,20 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
690
1356
  progress,
691
1357
  signal
692
1358
  })) {
693
- 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
+ }
694
1364
  if (event.type === 'error') showError(event.message)
695
1365
  }
696
1366
  ```
697
1367
 
698
- - `detectLanguage({ text, topK })`, served over hrpc as `detect-language` and
699
- on `RemoteHarness`: language detection with no model to load. Without
700
- `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
701
1373
  probability. Above that it returns ranked candidates with their
702
1374
  probabilities, best first, and may return fewer than asked. Text it cannot
703
1375
  place answers `und`. (#75)
@@ -710,15 +1382,31 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
710
1382
  // [{ code, language, probability }, ...]
711
1383
  ```
712
1384
 
713
- - `SkillOAuthConfig` on `SkillSetupRoute.oauth`, parsed from a skill
714
- manifest's `setup.routes[].oauth` and carried over the wire, so a host can
715
- run a skill's OAuth setup from its manifest instead of its own provider
716
- table. A route missing `stateKey`, `authUrl`, `tokenUrl` or `tokenAuth`
717
- 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
+ ```
718
1406
 
719
1407
  ### Changed
720
1408
 
721
- - `@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'
722
1410
  OAuth config, the Spotify skill always makes the play call after a search,
723
1411
  and the weather skill looks the place up with `weather_lookup`. (#88)
724
1412
 
@@ -735,11 +1423,17 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
735
1423
 
736
1424
  ### Added
737
1425
 
738
- - `sandboxMode` agent option: an agent with no network access. It withholds
739
- every tool that reaches the network (the web search, HTTP request and MCP
740
- 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
+
741
1431
  `exec` stays, but refuses package installs and well-known network commands
742
- 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).
743
1437
  (#73)
744
1438
 
745
1439
  ```js
@@ -752,12 +1446,17 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
752
1446
  }
753
1447
  ```
754
1448
 
755
- - `@qvac/harness/office`: XLSX and PPTX text extraction for Knowledge beside
756
- DOCX, through the same bundled Python worker. It keeps sheet names, column
757
- positions, cached formula values, slide text, grouped shapes and tables;
758
- speaker notes, images and charts are left out. The format comes from the
759
- file extension, with `mimeType` as the fallback. `@qvac/harness/docx` is
760
- 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)
761
1460
 
762
1461
  ```js
763
1462
  import { extractOfficeText, closeOfficeWorker } from '@qvac/harness/office'
@@ -769,43 +1468,57 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
769
1468
 
770
1469
  ### Fixed
771
1470
 
772
- - A small model could loop on the same failing tool call, up to the round
773
- 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
774
1473
  answered from its first result without running again, until a later round
775
- succeeds. Three such answers end the turn's tool rounds. A failure that may
776
- pass on retry, such as a timeout, a 5xx, 408 or 429, still runs again, and
777
- the tools that can fail that way now say so in a `code` or `status` field on
778
- their result. (#78)
779
- - A guessed `auth.tokenCredentialKey` on `http_request` or `mcp_call` was
780
- refused with an error claiming the skill was not loaded. It now resolves to
781
- the one connected credential allowed for that URL, and when none is
782
- connected the error says so and tells the user to connect it in Skills.
783
- (#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)
784
1488
 
785
1489
  ## [0.2.9] - 2026-09-22
786
1490
 
787
1491
  ### Added
788
1492
 
789
- - `captureInferenceCalls` on a completion's context: each round then emits an
790
- `inference-call` event with the exact payload it is about to send (model,
791
- messages, tools, generation params, cache key and prompt prefix), and an
792
- `inference-sent` event once it ends, with the tokens it spent, its `outcome`
793
- (`completed`, `overflowed` or `failed`) and, on the bundled patched SDK, the
794
- slice of `messages` the KV cache let through. An attempt the overflow ladder
795
- rejected is still recorded, with its `droppedTurns`. Off unless asked for,
796
- since every record carries a whole prompt. The flag and both events are new on
797
- 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)
798
1508
 
799
1509
  ```js
800
- for await (const event of harness.completion({ model, messages, captureInferenceCalls: true })) {
801
- if (event.type === 'inference-call') record(event.call.round, event.call.messages)
802
- 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)
803
1514
  }
804
1515
  ```
805
1516
 
806
- - The `metrics` event carries the last round's `stopReason`, and leaves it out
807
- when the SDK reports none. A turn cut off by the context window ends cleanly
808
- 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).
809
1522
  (#62)
810
1523
 
811
1524
  ```js
@@ -822,15 +1535,21 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
822
1535
  ### Added
823
1536
 
824
1537
  - `weather_lookup`, a tool that looks weather up on wttr.in by `location` and
825
- leads its report with the place the service actually resolved. When that
826
- place disagrees with the country or state the user named, it refuses instead
827
- of reporting the weather of wherever the geocoder landed: a nonexistent town
828
- no longer gets a forecast, and a bare "Rome" the service places in Togo says
829
- so. It is offered only once the weather skill is loaded, needs no approval,
830
- and the `http-request` toolset switch turns it off. The spawned sidecar
831
- 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)
832
1550
 
833
1551
  ```js
1552
+ import { Harness } from '@qvac/harness'
834
1553
  import { createWeatherLookupTool } from '@qvac/harness/lib/tools/weather-lookup.ts'
835
1554
 
836
1555
  const harness = new Harness({ tools: [...tools, createWeatherLookupTool()] })
@@ -845,20 +1564,25 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
845
1564
  the version. (#55)
846
1565
 
847
1566
  ```js
848
- // 0.2.7
1567
+ // before
1568
+ import { currentReleaseLine } from '@qvac/harness/schema/release-tags'
849
1569
  const line = currentReleaseLine(root) // '0.2'
850
1570
 
851
- // 0.2.8
1571
+ // after
1572
+ import { currentReleaseVersion } from '@qvac/harness/schema/release-tags'
852
1573
  const version = currentReleaseVersion(root) // Version 0.2.8
853
1574
  const line = `${version.major}.${version.minor}`
854
1575
  ```
855
1576
 
856
- - `previousReleaseTags` from the same module now counts the newest shipped tag
857
- on the line being built as a baseline, not only older lines, so a schema
858
- change is checked against the build users are running. Only tags newer than
859
- the package version are left out. A host that regenerates its `spec/`
860
- against it re-derives hyperschema's version counters once, so expect one
861
- 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
+
862
1586
  - `@qvac/skills` 0.1.4: the Apple Notes create-note operation no longer
863
1587
  renames a note after creating it, which failed on iCloud accounts and left
864
1588
  duplicate notes behind. (#48)
@@ -867,14 +1591,17 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
867
1591
 
868
1592
  ### Added
869
1593
 
870
- - `@qvac/harness/docx`: `extractDocxText` pulls a DOCX file's text for
871
- Knowledge with the bundled Python runtime, keeping paragraphs and tables in
872
- document order, one row per line with cells joined by `' | '`. It takes up to
873
- 20 MiB and only the shipped wheels, never a network install. Set
874
- `QVAC_PYODIDE_CACHE_DIR` to a writable directory first, so the worker seeds
875
- its cache from those wheels and works offline. A bundled host also sets
876
- `QVAC_PYODIDE_WORKER_ENTRY` and `QVAC_PYTHON_WHEELS_DIR` to its staged
877
- 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)
878
1605
 
879
1606
  ```js
880
1607
  import { extractDocxText, closeDocxWorker } from '@qvac/harness/docx'
@@ -906,36 +1633,45 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
906
1633
  })
907
1634
  ```
908
1635
 
909
- - `writeClient` and `writeCapabilities` from `@qvac/harness/schema/generate`
910
- take an options argument whose `optionalRequests` names the request types a
911
- caller may omit: the generated client sends `{}` for them and the generated
912
- capability types mark the input optional. Left out, only `@assistant/empty`
913
- is optional, as before. A set you pass replaces that default, so include it
914
- if you still want it. `writeClient` still takes an export name as a string.
915
- (#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)
916
1645
 
917
1646
  ```js
918
1647
  import { writeClient, writeCapabilities } from '@qvac/harness/schema/generate'
919
1648
 
920
1649
  const optionalRequests = new Set(['@assistant/empty', '@assistant/mesh-scope'])
921
- writeClient(builder, 'spec/calls.js', { exportName: 'engineClient', optionalRequests })
1650
+ writeClient(builder, 'spec/calls.js', {
1651
+ exportName: 'engineClient',
1652
+ optionalRequests
1653
+ })
922
1654
  writeCapabilities(builder, 'spec/capabilities.d.ts', { optionalRequests })
923
1655
  ```
924
1656
 
925
1657
  ### Changed
926
1658
 
927
- - A completion that needs a different model no longer loads it beside one a
928
- live run is holding. It waits for that run to release its model, for up to
929
- five minutes, and then loads anyway rather than failing. A `warmModel` call
930
- 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)
931
1665
 
932
1666
  ## [0.2.5] - 2026-09-14
933
1667
 
934
1668
  ### Added
935
1669
 
936
- - A `gpuStats` option on `spawnHarness`, `harnessChild` and `HarnessOptions`.
937
- A host that already has the GPU reading passes it, and the harness skips its
938
- 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)
939
1675
 
940
1676
  ```js
941
1677
  import { spawnHarness } from '@qvac/harness/lib/spawn.ts'
@@ -945,54 +1681,72 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
945
1681
  sdkStorage,
946
1682
  gpuStats: {
947
1683
  memory: { totalBytes: os.totalmem() },
948
- gpu: { devices: [{ name: 'NVIDIA RTX 4070', videoMemoryBytes: 12884901888 }] }
1684
+ gpu: {
1685
+ devices: [{ name: 'NVIDIA RTX 4070', videoMemoryBytes: 12884901888 }]
1686
+ }
949
1687
  }
950
1688
  })
951
1689
  ```
952
1690
 
953
1691
  ### Fixed
954
1692
 
955
- - On a Linux snap the harness exited about a second after `open`, and the
956
- supervisor respawned it in a loop: the boot-time capability probe created a
957
- second GPU-info instance beside the SDK's own. On Linux and Windows under
958
- Bare, GPUs are now enumerated in a short-lived child process, which gives up
959
- after 5 seconds and reports no GPU. (#36)
960
- - On Windows, asking for an image after a presentation left the chat stuck on
961
- "Loading … into memory". Pyodide's boot opened the process streams from the
962
- Python worker thread, which blocked the image-model loader's progress. Boot
963
- 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)
964
1705
 
965
1706
  ## [0.2.4] - 2026-09-12
966
1707
 
967
1708
  ### Added
968
1709
 
969
- - `@qvac/harness/worker/stow-entry.mjs`: the sidecar entry, published so a
970
- host running from inside `node_modules` spawns the engine without the
971
- prebuilt bundle. Every native addon then resolves beside the JS that calls
972
- it, from one install. With the bundle, the addons come from the host's own
973
- install, and a version skew there hangs the child on boot rather than
974
- throwing: `@qvac/core` 0.1.2 shipped that way. The bundle stays published
975
- 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)
976
1718
 
977
1719
  ```js
978
1720
  import { harnessChild } from '@qvac/harness/lib/spawn.ts'
979
1721
 
980
1722
  const entry = new URL(import.meta.resolve('@qvac/harness/worker/stow-entry.mjs')).pathname
981
- 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
+ })
982
1729
  ```
983
1730
 
984
1731
  ## [0.2.3] - 2026-09-12
985
1732
 
986
1733
  ### Added
987
1734
 
988
- - `schema/generate` emits `writeStream` on the generated `HandlerFrames`, so a
989
- 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
+ ```
990
1743
 
991
1744
  ### Changed
992
1745
 
993
- - On Android and iOS, `resolveStorage` keeps models under the app's own
994
- persistent root instead of the device HOME, which an app cannot write.
995
- 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)
996
1750
 
997
1751
  ```js
998
1752
  import { resolveStorage } from '@qvac/harness/storage'
@@ -1004,37 +1758,41 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1004
1758
 
1005
1759
  ### Fixed
1006
1760
 
1007
- - Every `./lib/tools/*` subpath is published. The tarball listed only the ones
1008
- `qvac-core` imports, so the mobile worklet's file tools (`file-glob`, `grep`,
1009
- `read-files`, `create-documents`, `edit-documents`, `fs/scope`) failed with
1010
- `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)
1011
1766
 
1012
1767
  ## [0.2.2] - 2026-09-11
1013
1768
 
1014
1769
  ### Fixed
1015
1770
 
1016
- - A stale lockfile entry gave `@qvac/sdk` its own nested `@qvac/inference`
1017
- 0.19.1, beside the patched 0.19.0 the harness pins. The bundle is built from
1018
- 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)
1019
1775
 
1020
1776
  ## [0.2.1] - 2026-09-11
1021
1777
 
1022
1778
  ### Fixed
1023
1779
 
1024
- - The generated wire spec in 0.2.0 stamped the fields it added (`downloaded`
1025
- on a download event and as a model status, and `kvCacheKey`) with schema
1026
- version 7, the version 0.1.2 already shipped. They are version 8 now, so the
1027
- 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)
1028
1785
 
1029
1786
  ## [0.2.0] - 2026-09-11
1030
1787
 
1031
1788
  ### Added
1032
1789
 
1033
- - `deleteKvCache(kvCacheKey)` deletes one chat's KV cache, every file kept
1034
- under that key, where `clearKvCache()` deletes them all. It is safe on a key
1035
- still in use: the next turn under it primes from scratch. It rides a new
1036
- `delete-kv-cache` hrpc route, and resolves with `{ status }` like
1037
- `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)
1038
1796
 
1039
1797
  ```js
1040
1798
  // the cacheKey the chat's completions ran under
@@ -1042,11 +1800,13 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1042
1800
  if (result.status !== 'success') log(result.error)
1043
1801
  ```
1044
1802
 
1045
- - A download's `complete` event carries `downloaded`, `true` when bytes were
1046
- fetched and `false` when the model was already on disk. An image model's
1047
- `model` events report `loading` rather than `downloading` when it is cached,
1048
- and a new `downloaded` status once a fetched one has loaded, so a host that
1049
- 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)
1050
1810
 
1051
1811
  ```js
1052
1812
  for await (const event of harness.download({ model })) {
@@ -1058,28 +1818,63 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1058
1818
  })
1059
1819
  ```
1060
1820
 
1061
- - `ToolDefinition.prompt`: an optional compact `description` and `parameters`
1062
- the model is shown in place of the full ones, which still validate the call.
1063
- `exec` and `skill` ship one, so every turn spends less of the prompt on
1064
- their schemas. (#22)
1065
- - `CONTEXT_OVERFLOW_MESSAGE` is exported, the message a turn fails with when
1066
- it does not fit the model's context window, so a host can tell that failure
1067
- 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)
1068
1853
 
1069
1854
  ```js
1070
1855
  import { CONTEXT_OVERFLOW_MESSAGE } from '@qvac/harness'
1856
+
1857
+ if (event.type === 'error' && event.message === CONTEXT_OVERFLOW_MESSAGE) {
1858
+ compact(chat)
1859
+ }
1071
1860
  ```
1072
1861
 
1073
1862
  - With `QVAC_HARNESS_LOG=1`, each turn logs `sdk tool definitions`: the tool
1074
1863
  schemas it sent, largest first, with each one's size and the skill that
1075
1864
  granted it. (#22)
1076
1865
 
1866
+ ```sh
1867
+ QVAC_HARNESS_LOG=1 bare host.js
1868
+ ```
1869
+
1077
1870
  ### Changed
1078
1871
 
1079
1872
  - `@qvac/sdk` 0.19.0, pinned exactly. Under Bare the SDK is now served by
1080
1873
  `@qvac/inference` 0.19.0, through a new `#sdk` import alias.
1081
- `@qvac/audiogen-ggml` joins the deferred addons, so `prebuilds-manifest.json`
1082
- 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
+
1083
1878
  - Image and music generation schemas reach the model once their skill's
1084
1879
  instructions are loaded, not as soon as the skill is enabled. Loading the
1085
1880
  skill unlocks them within the same turn, at the cost of one cache re-prime
@@ -1094,10 +1889,11 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1094
1889
 
1095
1890
  ### Fixed
1096
1891
 
1097
- - `@qvac/harness/sdk-storage` and `@qvac/harness/sdk-config-loader` failed to
1098
- resolve under Bare with `MODULE_NOT_FOUND`: the tarball carried only the Node
1099
- loader behind `#sdk-config-loader`, so `configureSdkStorage()` could not run
1100
- 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)
1101
1897
 
1102
1898
  ```js
1103
1899
  // Bare: loads again, nothing else changes
@@ -1108,13 +1904,15 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1108
1904
 
1109
1905
  ### Added
1110
1906
 
1111
- - The library surface ships compiled: `dist-lib/` carries JavaScript and a
1112
- `.d.ts` for every export, so `@qvac/harness` and its subpaths import from
1113
- npm under plain Node or Bare, with no TypeScript source or type-stripping
1114
- loader. Export keys are unchanged, `.ts`-suffixed deep paths included, so a
1115
- host that took the library from the repository switches to the package
1116
- without editing a specifier. Of `./lib/tools/*`, only the paths `qvac-core`
1117
- 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)
1118
1916
 
1119
1917
  ```js
1120
1918
  import { harnessChild, spawnHarness } from '@qvac/harness/lib/spawn.ts'
@@ -1125,13 +1923,18 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1125
1923
 
1126
1924
  ### Added
1127
1925
 
1128
- - First release of the QVAC engine as `@qvac/harness`. The tarball is the
1129
- prebuilt `dist/harness.bundle`, one bundle for every platform, and the
1130
- generated `dist/harness.cjs` a host requires to spawn it as a bare-sidecar
1131
- 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
1132
1932
  source the tarball leaves out, so a host takes `spawnHarness`,
1133
1933
  `harnessChild` and the transport from the repository until a compiled
1134
- 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)
1135
1938
 
1136
1939
  ```js
1137
1940
  const harness = require('@qvac/harness/dist/harness.cjs')
@@ -1139,17 +1942,20 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1139
1942
  const { ipc, sidecar, exit } = await harness.start(args)
1140
1943
  ```
1141
1944
 
1142
- - `dist/prebuilds-manifest.json` names every native prebuild directory the
1143
- bundle defers, keyed by its path relative to the bundle. The host assembles
1144
- those beside the bundle from its own `node_modules`, and installs the
1145
- `@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)
1146
1950
  - Skills come from `@qvac/skills` 0.1.2, a content-addressed bundle the
1147
1951
  harness materializes at runtime. The Python wheels the tool runtime installs
1148
1952
  ship in `wheels/`, each pinned by hash in `wheels/manifest.json`. (#13, #15)
1149
- - GPU stats are read through `bare-gpu-info`, so every dependency installs from
1150
- the public npm registry. (#12)
1953
+ - Every dependency installs from the public npm registry: GPU stats are read
1954
+ through `bare-gpu-info`. (#12)
1151
1955
 
1152
- [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.8.5...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
1958
+ [0.8.6]: https://github.com/tetherto/qvac-harness/compare/v0.8.5...v0.8.6
1153
1959
  [0.8.5]: https://github.com/tetherto/qvac-harness/compare/v0.8.0...v0.8.5
1154
1960
  [0.8.0]: https://github.com/tetherto/qvac-harness/compare/v0.7.3...v0.8.0
1155
1961
  [0.7.3]: https://github.com/tetherto/qvac-harness/compare/v0.7.2...v0.7.3