@qvac/harness 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/.claude/skills/gh-stack/SKILL.md +24 -24
  2. package/.claude/skills/gh-stack/references/commands.md +2 -2
  3. package/.claude/skills/js-house-style/SKILL.md +89 -68
  4. package/.claude/skills/kvcache/SKILL.md +7 -7
  5. package/.claude/skills/pr-create/SKILL.md +58 -15
  6. package/.claude/skills/pr-review/SKILL.md +307 -41
  7. package/CLAUDE.md +3 -0
  8. package/README.md +26 -4
  9. package/dist/harness.bundle +12 -10
  10. package/dist-lib/index.js +58 -58
  11. package/dist-lib/lib/audio-transcode-bare.js +2 -2
  12. package/dist-lib/lib/capabilities/completion.js +30 -30
  13. package/dist-lib/lib/capabilities/download.js +2 -2
  14. package/dist-lib/lib/capabilities/speech-text.js +1 -1
  15. package/dist-lib/lib/capabilities/tool-prompt-log.js +1 -1
  16. package/dist-lib/lib/capabilities/tool-selection.js +2 -2
  17. package/dist-lib/lib/capabilities/tool-turns.js +1 -1
  18. package/dist-lib/lib/capabilities/usage-breakdown.js +1 -1
  19. package/dist-lib/lib/capabilities/voice.js +3 -3
  20. package/dist-lib/lib/connect.js +11 -11
  21. package/dist-lib/lib/context-window.js +1 -1
  22. package/dist-lib/lib/docx/index.d.ts +5 -0
  23. package/dist-lib/lib/docx/index.js +49 -0
  24. package/dist-lib/lib/hardware/capability-probe.js +3 -3
  25. package/dist-lib/lib/hardware/gpu-probe-addon.js +2 -2
  26. package/dist-lib/lib/hardware/gpu-probe-entry.js +1 -1
  27. package/dist-lib/lib/helpers/markup-stream-filter.js +1 -1
  28. package/dist-lib/lib/helpers/run-outcome.js +1 -1
  29. package/dist-lib/lib/helpers/spawn-path.d.ts +1 -1
  30. package/dist-lib/lib/helpers/thinking.js +2 -2
  31. package/dist-lib/lib/helpers/tool-intent.js +1 -1
  32. package/dist-lib/lib/helpers/tool-label.js +3 -3
  33. package/dist-lib/lib/hooks/built-in/arg-repair.d.ts +1 -1
  34. package/dist-lib/lib/hooks/built-in/arg-repair.js +1 -1
  35. package/dist-lib/lib/hooks/built-in/egress.d.ts +1 -1
  36. package/dist-lib/lib/hooks/built-in/egress.js +2 -2
  37. package/dist-lib/lib/hooks/built-in/index.js +3 -3
  38. package/dist-lib/lib/hooks/built-in/skill-catalog.d.ts +1 -1
  39. package/dist-lib/lib/hooks/pipeline.js +4 -4
  40. package/dist-lib/lib/hooks/registry.js +1 -1
  41. package/dist-lib/lib/hooks/run.js +1 -1
  42. package/dist-lib/lib/models/model-catalog.js +4 -4
  43. package/dist-lib/lib/models/model-delete.js +1 -1
  44. package/dist-lib/lib/models/model-handle.js +1 -1
  45. package/dist-lib/lib/models/model-list.js +1 -1
  46. package/dist-lib/lib/models/model-pool.js +8 -8
  47. package/dist-lib/lib/models/model-source.js +1 -1
  48. package/dist-lib/lib/models/music-catalog.d.ts +1 -1
  49. package/dist-lib/lib/no-redirect-fetch-bare.js +3 -3
  50. package/dist-lib/lib/no-redirect-fetch-impl.js +1 -1
  51. package/dist-lib/lib/no-redirect-fetch-node.js +2 -2
  52. package/dist-lib/lib/resolution-detector.js +1 -1
  53. package/dist-lib/lib/sdk-loader.js +2 -2
  54. package/dist-lib/lib/sdk-storage.js +1 -1
  55. package/dist-lib/lib/serve.js +10 -10
  56. package/dist-lib/lib/spawn.js +3 -3
  57. package/dist-lib/lib/staging.js +1 -1
  58. package/dist-lib/lib/tools/catalog.js +1 -1
  59. package/dist-lib/lib/tools/create-documents.js +2 -2
  60. package/dist-lib/lib/tools/edit-documents.js +2 -2
  61. package/dist-lib/lib/tools/edit-image.js +2 -2
  62. package/dist-lib/lib/tools/exec/adapters/python/attachments.js +2 -2
  63. package/dist-lib/lib/tools/exec/adapters/python/create-effect.js +1 -1
  64. package/dist-lib/lib/tools/exec/adapters/python/loader.js +1 -1
  65. package/dist-lib/lib/tools/exec/adapters/python/memfs.js +2 -2
  66. package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +8 -6
  67. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +3 -3
  68. package/dist-lib/lib/tools/exec/adapters/python/run-session.js +5 -5
  69. package/dist-lib/lib/tools/exec/adapters/python/wheels.js +3 -2
  70. package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +4 -4
  71. package/dist-lib/lib/tools/exec/adapters/python/worker/entry.js +4 -4
  72. package/dist-lib/lib/tools/exec/adapters/shell/shell-adapter.js +1 -1
  73. package/dist-lib/lib/tools/exec/obsidian/cli-process.js +2 -2
  74. package/dist-lib/lib/tools/exec/obsidian/cli.js +4 -4
  75. package/dist-lib/lib/tools/exec/obsidian/command.js +1 -1
  76. package/dist-lib/lib/tools/exec/obsidian/route.js +7 -7
  77. package/dist-lib/lib/tools/exec/obsidian/vault-create.js +2 -2
  78. package/dist-lib/lib/tools/exec/obsidian/vault-fs.js +6 -6
  79. package/dist-lib/lib/tools/exec/obsidian/vault-paths.js +2 -2
  80. package/dist-lib/lib/tools/exec/obsidian/vault-read.js +1 -1
  81. package/dist-lib/lib/tools/exec/obsidian/vault-write.js +2 -2
  82. package/dist-lib/lib/tools/exec/runner.js +1 -1
  83. package/dist-lib/lib/tools/exec.js +13 -13
  84. package/dist-lib/lib/tools/file-glob.js +3 -3
  85. package/dist-lib/lib/tools/fs/local.js +1 -1
  86. package/dist-lib/lib/tools/fs/remote.js +1 -1
  87. package/dist-lib/lib/tools/fs/walk.js +2 -2
  88. package/dist-lib/lib/tools/generate-image.js +4 -4
  89. package/dist-lib/lib/tools/generate-music.js +6 -6
  90. package/dist-lib/lib/tools/grep.js +6 -6
  91. package/dist-lib/lib/tools/http-request/truncate-response.js +1 -1
  92. package/dist-lib/lib/tools/http-request.js +9 -9
  93. package/dist-lib/lib/tools/mcp/utils.js +1 -1
  94. package/dist-lib/lib/tools/mcp-call.js +8 -8
  95. package/dist-lib/lib/tools/operations/builders.d.ts +1 -1
  96. package/dist-lib/lib/tools/operations/builders.js +1 -1
  97. package/dist-lib/lib/tools/operations/compile.js +1 -1
  98. package/dist-lib/lib/tools/operations/manifest.d.ts +16 -16
  99. package/dist-lib/lib/tools/operations/manifest.js +1 -1
  100. package/dist-lib/lib/tools/outbound-guard.js +1 -1
  101. package/dist-lib/lib/tools/rag-search.js +1 -1
  102. package/dist-lib/lib/tools/read-files.js +3 -3
  103. package/dist-lib/lib/tools/skill.js +8 -8
  104. package/dist-lib/lib/tools/skills/auth-resolver.js +2 -2
  105. package/dist-lib/lib/tools/skills/bin-check.js +1 -1
  106. package/dist-lib/lib/tools/skills/bundled-dir.js +2 -2
  107. package/dist-lib/lib/tools/skills/catalog.js +10 -10
  108. package/dist-lib/lib/tools/skills/cli-schema-check.js +1 -1
  109. package/dist-lib/lib/tools/skills/cli-schema-load.js +1 -1
  110. package/dist-lib/lib/tools/skills/cli-schema.js +4 -4
  111. package/dist-lib/lib/tools/skills/credential-check.js +3 -3
  112. package/dist-lib/lib/tools/skills/credential-grants.js +1 -1
  113. package/dist-lib/lib/tools/skills/credential-health.js +2 -2
  114. package/dist-lib/lib/tools/skills/frontmatter-parser.js +1 -1
  115. package/dist-lib/lib/tools/skills/index.js +5 -5
  116. package/dist-lib/lib/tools/skills/mention.js +1 -1
  117. package/dist-lib/lib/tools/skills/overlay-crud.js +1 -1
  118. package/dist-lib/lib/tools/skills/setup-routes.js +1 -1
  119. package/dist-lib/lib/tools/skills/skill-settings.js +1 -1
  120. package/dist-lib/lib/tools/skills/skill-state-constants.d.ts +8 -8
  121. package/dist-lib/lib/tools/skills/skill-state.js +9 -9
  122. package/dist-lib/lib/tools/toolsets.d.ts +1 -1
  123. package/dist-lib/lib/tools/url-guard.js +2 -2
  124. package/dist-lib/lib/tools/weather-lookup/place.d.ts +11 -0
  125. package/dist-lib/lib/tools/weather-lookup/place.js +152 -0
  126. package/dist-lib/lib/tools/weather-lookup/report.d.ts +37 -0
  127. package/dist-lib/lib/tools/weather-lookup/report.js +56 -0
  128. package/dist-lib/lib/tools/weather-lookup.d.ts +8 -0
  129. package/dist-lib/lib/tools/weather-lookup.js +110 -0
  130. package/dist-lib/lib/tools/web-fetch.js +10 -10
  131. package/dist-lib/lib/tools/web-search.js +2 -2
  132. package/dist-lib/lib/transport.js +1 -1
  133. package/dist-lib/lib/validating-lookup.js +1 -1
  134. package/dist-lib/lib/web-fetch/embedded-state.js +1 -1
  135. package/dist-lib/lib/web-fetch/extract.js +1 -1
  136. package/dist-lib/lib/web-fetch/images.js +1 -1
  137. package/dist-lib/lib/web-fetch/video.js +1 -1
  138. package/dist-lib/lib/web-search/engine-registry.js +7 -7
  139. package/dist-lib/lib/web-search/engines/aol.js +3 -3
  140. package/dist-lib/lib/web-search/engines/bing.js +4 -4
  141. package/dist-lib/lib/web-search/engines/brave.js +2 -2
  142. package/dist-lib/lib/web-search/engines/duckduckgo.js +3 -3
  143. package/dist-lib/lib/web-search/engines/google.js +4 -4
  144. package/dist-lib/lib/web-search/engines/wikipedia.js +2 -2
  145. package/dist-lib/lib/web-search/engines/yahoo.js +3 -3
  146. package/dist-lib/lib/web-search/fetch-html.js +1 -1
  147. package/dist-lib/lib/web-search/merge-results.js +1 -1
  148. package/dist-lib/lib/web-search/orchestrator.js +3 -3
  149. package/dist-lib/schema/baseline.js +1 -1
  150. package/dist-lib/schema/build.js +3 -3
  151. package/dist-lib/schema/release-tags.d.ts +2 -1
  152. package/dist-lib/schema/release-tags.js +9 -7
  153. package/docs/decisions/2026-05-29-kvcache-tool-turns.md +75 -0
  154. package/docs/decisions/2026-06-02-kvcache-tool-turns-reenabled.md +127 -0
  155. package/docs/decisions/2026-06-23-android-cpu-llm-defaults.md +126 -0
  156. package/docs/decisions/2026-07-29-assistant-owned-storage-layout.md +88 -0
  157. package/docs/decisions/2026-08-04-sidecar-orphan-reaping.md +79 -0
  158. package/docs/decisions/2026-08-11-pyodide-worker-thread.md +68 -0
  159. package/docs/decisions/2026-08-17-harness-hook-pipeline.md +84 -0
  160. package/docs/decisions/2026-08-17-harness-outbound-egress-guard.md +176 -0
  161. package/docs/decisions/2026-08-20-chat-prompt-prefix-is-stable.md +95 -0
  162. package/docs/decisions/2026-08-20-kvcache-static-placement.md +109 -0
  163. package/docs/decisions/2026-08-21-vendored-python-wheels.md +85 -0
  164. package/docs/decisions/2026-08-26-exec-is-not-gated-by-skills.md +64 -0
  165. package/docs/decisions/2026-08-27-writable-skills-overlay.md +56 -0
  166. package/docs/decisions/2026-09-01-skill-operation-manifests.md +53 -0
  167. package/docs/decisions/2026-09-01-usage-breakdown-is-measured.md +78 -0
  168. package/docs/decisions/2026-09-03-usage-breakdown-reported-by-the-harness.md +136 -0
  169. package/docs/decisions/2026-09-10-skill-wording-is-measured-offline.md +63 -0
  170. package/docs/decisions/2026-09-16-a-lookup-reports-the-place-it-resolved.md +70 -0
  171. package/docs/decisions/INDEX.md +40 -0
  172. package/docs/decisions/TEMPLATE.md +32 -0
  173. package/package.json +21 -4
  174. package/worker/harness-sidecar.mjs +2 -0
@@ -0,0 +1,127 @@
1
+ ---
2
+ id: ADR-2026-06-02-kvcache-tool-turns-reenabled
3
+ status: superseded
4
+ introduced_by: PR-QVAC-19770
5
+ supersedes: [ADR-2026-05-29-kvcache-tool-turns]
6
+ superseded_by: [ADR-2026-08-20-kvcache-static-placement]
7
+ ---
8
+
9
+ # KV-cache session is re-enabled for tool-using completions
10
+
11
+ > **Superseded** by
12
+ > [ADR-2026-08-20-kvcache-static-placement](./2026-08-20-kvcache-static-placement.md):
13
+ > the half of this record that still holds is that `kvCache: 'chat-<id>'` rides
14
+ > every completion. The dynamic-mode half was built on a false premise — the
15
+ > addon's compact-tools anchoring never ran on any shipped model (its arch gate
16
+ > is exact `qwen3`; ours are `qwen35`/`qwen36`), so the coherence this record
17
+ > credits to dynamic mode was never exercised, and the "static never surfaced
18
+ > the tool-call format" claim did not survive re-testing. core-v2 loads static
19
+ > with no sliding window since 2026-08-20; mechanics in
20
+ > `docs/kvcache-reference.md`. The `QVAC_PROMPT_CACHE` kill-switch and
21
+ > `deleteChatPromptCache` described below were core-v1 machinery and do not
22
+ > exist in the current runtime.
23
+
24
+ ## Decision
25
+
26
+ The `@qvac/sdk` session prompt cache (`completion({ kvCache: "chat-<id>" })`) is
27
+ passed for **all** completions with a chatId — tool-using and tool-free alike —
28
+ gated only by the `QVAC_PROMPT_CACHE` kill-switch. This reverses
29
+ ADR-2026-05-29, which disabled the cache whenever tools were present.
30
+
31
+ ## Context
32
+
33
+ The prior decision disabled tool-turn caching because the SDK's DYNAMIC cache
34
+ path broke tool-call parsing: it ships a role-based history fragment and trusts
35
+ the addon's tool-chain anchoring, which our reconstructed history did not
36
+ satisfy → `<tool_call>` markup leaked as text. Two things changed:
37
+
38
+ - Models loaded with `modelConfig.toolsMode: "dynamic"` (QVAC-19660). In
39
+ dynamic mode the addon anchors tools per-turn (compact-tools), excludes the
40
+ tool set from the cache config hash, and the fragment slicing keeps
41
+ multi-round tool turns coherent — including across a model evict/reload.
42
+ **No longer true as of `@qvac/sdk` 0.18 (QVAC-23936):** the key was removed
43
+ and the parser rejects it, so static is the only placement. Dynamic was in
44
+ any case a silent no-op on every arch we ship — the addon gates
45
+ `tools_compact` on exact-arch `qwen3` while our catalog is `qwen35`/`qwen36` —
46
+ and the SDK still sliced as-if-dynamic, which made the cache the chat's
47
+ _memory_ rather than an accelerator: after any cache reset it sent only the
48
+ last exchange. Under static placement the tool block sits in the addon's
49
+ protected prefix instead, and 0.18 keeps it out of the primed prefix
50
+ (QVAC-23251) so the qwen3.5 template no longer raises 'No user query found in
51
+ messages.'
52
+ - An SDK e2e guard (`kv-cache-tools-dynamic-reuse`, plus a qwen35-dialect
53
+ variant) verifies a custom-key multi-round tool chain reuses the on-disk cache
54
+ (`cacheTokens > 0`) and keeps tool calls parseable across an evict/reload.
55
+
56
+ With the DYNAMIC path proven coherent across reload, the heavyweight
57
+ "reconstruction invalidation / byte-identical history" work the prior ADR
58
+ deferred is unnecessary; divergence is handled with targeted invalidation.
59
+
60
+ ## Consequences
61
+
62
+ - **Do not pass `toolsMode` at model load.** SDK 0.18 removed the key and its
63
+ parser rejects an unrecognised one, so passing it fails the load outright and
64
+ the app parks on the splash. Static placement is the default and the only mode.
65
+ - Dropping a key from `modelConfig` re-keys `modelId`, which is identity-by-config,
66
+ so expect one cold prime per chat on the first run after such a change.
67
+ - Tool turns reuse the session KV cache: lower TTFT/prefill on multi-round tool
68
+ turns and follow-ups (manually: round-2 TTFT ~1.1s → ~0.3s; cacheTokens grows
69
+ monotonically across a conversation).
70
+ - `QVAC_PROMPT_CACHE` (default on; `0`/`false`/`off`) disables all
71
+ prompt caching as a kill-switch.
72
+ - The per-chat cache is explicitly invalidated on chat delete and agent delete
73
+ (which cascades to its chats) via `deleteChatPromptCache`, plus a defensive
74
+ drop on retry (see below). Edit / branch / truncate flows do not exist yet and
75
+ need no hook.
76
+ - **Why stop / failed / new-message-after-failure need no invalidation.** The
77
+ cache only ever holds _successful_ turns: the SDK rolls back uncommitted turns
78
+ on abort (the custom-key path commits only when not aborted and tokens were
79
+ produced), and failed / canceled / not_delivered replies are excluded from
80
+ model-visible history (`isModelVisibleMessage`, `qvac-app → core/src/services/common/history.ts`).
81
+ So a stopped or failed turn leaves neither a committed cache entry nor a
82
+ visible history row — the reconstructed history and the cached prefix stay
83
+ aligned, and the next turn (new message or retry) does not slice against a
84
+ stale prefix. The SDK's own `kv-cache-cancel-then-new-prompt` e2e covers this
85
+ cancel-then-continue coherence. The **retry** `deleteChatPromptCache` is
86
+ therefore belt-and-suspenders, not load-bearing: it costs one re-prime on a
87
+ rare, user-initiated path and removes any dependence on the rollback timing.
88
+ - **Mobile scope.** Mobile now runs the same assistant and harness completion path
89
+ inside its supervised Bare worklets, so the per-chat cache key reaches the SDK
90
+ without the retired worker-to-host SDK bridge.
91
+ - `chatPromptCacheKey` is the single source of truth for the `chat-<id>` key
92
+ shared by the completion path and the invalidation callers.
93
+ - Known SDK limitation: on a multimodal (M-RoPE, `n_pos_per_embd > 1`) model such
94
+ as Qwen3.5, the SDK's KV sliding-window position shift (`seq_add`/`seq_div` via
95
+ `trySlidePrefill`) GGML_ASSERT-crashes the worker when the context fills. A
96
+ standalone SDK repro confirmed this is **independent of `toolsMode` (static and
97
+ dynamic both crash) and of whether tools are passed**, and is not caused by the
98
+ prompt cache — it is the sliding window itself, unsupported for multi-pos
99
+ models. Interim mitigation (separate from the kill-switch): we temporarily
100
+ stopped setting `n_discarded` at model load so on overflow the SDK took the
101
+ catchable `CONTEXT_OVERFLOW` path instead of the crashing slide.
102
+ **Resolved (2026-06-23):** `@qvac/sdk` 0.13.5 (llm-llamacpp 0.24.0) ships
103
+ M-RoPE/iM-RoPE sliding-context support, so `n_discarded` is set again in
104
+ `@qvac/sdk → model/model-load-config.ts` and the workbench no longer trims history. See
105
+ [`2026-06-23-append-only-history.md`](./2026-06-23-append-only-history.md).
106
+ - Regression guards updated: the contract test now asserts `kvCache` IS sent
107
+ with tools; the spec-fake models dynamic mode (healthy on a tools cache hit);
108
+ invalidation hooks are covered.
109
+ - **The in-repo suite cannot prove real dynamic-mode coherence.** The spec-fake
110
+ (`@qvac/sdk → __fixtures__/fake-completion-sdk.ts`) is parameterized to treat dynamic mode
111
+ as healthy, so the in-repo tests guard against regressing the model load back
112
+ to static mode and against dropping the `kvCache` key — but they do not prove
113
+ the SDK's real DYNAMIC path stays coherent against our reconstructed history.
114
+ That proof lives in the out-of-repo SDK e2e (`kv-cache-tools-dynamic-reuse`
115
+ and its qwen35 variant), which this repo's CI cannot run.
116
+
117
+ ## When Agents Should Read This
118
+
119
+ Read this before changing how `kvCache` is passed to `sdk.completion`,
120
+ `toolsMode` at model load, the agentic loop's history construction, transcript
121
+ reconstruction, or the cache invalidation hooks —
122
+ `qvac-app → core/src/services/message/agentic-completion.ts`,
123
+ `qvac-app → core/src/services/model/service.ts`,
124
+ `qvac-app → core/src/services/execution/service.ts`,
125
+ `qvac-app → core/src/services/api/domain-apis.ts`,
126
+ `@qvac/core → lib/assistant.ts`, and
127
+ `lib/capabilities/completion.ts`.
@@ -0,0 +1,126 @@
1
+ ---
2
+ id: ADR-2026-06-23-android-cpu-llm-defaults
3
+ status: active
4
+ introduced_by: PR-691
5
+ supersedes: []
6
+ ---
7
+
8
+ # Android LLM defaults to CPU except Tensor-era Pixels
9
+
10
+ ## Decision
11
+
12
+ Android LLM inference defaults to CPU with a `q4_0` KV cache, except on
13
+ Tensor-era Google Pixel devices before Pixel 10 where LLM inference defaults
14
+ back to GPU. Pixel 10 and newer remain on CPU.
15
+
16
+ ## Context
17
+
18
+ Android GPU inference runs on Vulkan, which is unreliable across the fleet:
19
+ Adreno on Snapdragon devices, including many Samsung variants, aborts under the
20
+ LLM workload, and older devices ship buggy drivers. CPU is the only
21
+ consistently stable target.
22
+
23
+ The earlier Samsung and Pixel 10 CPU special-casing plus stripping the KV-cache
24
+ quantization fields at the host boundary existed only to dodge the Vulkan abort.
25
+ With all inference on CPU that abort path is gone, so a quantized `q4_0` cache
26
+ is safe and lowers memory use.
27
+
28
+ Pixel 9 profiling later showed that long chat/tool-shaped prompts can collapse
29
+ decode throughput on the SDK CPU path while the GPU path keeps decode TPS
30
+ healthy. Pixel 10 had prior GPU stability issues, so the exception is limited
31
+ to Tensor-era Pixel devices before Pixel 10, including Pixel Tablet and the
32
+ first-generation Pixel Fold. Original Pixel phones are not included in this
33
+ exception.
34
+
35
+ Core v2 device profiling (2026-07) added two KV-cache facts for the GPU
36
+ exception: a quantized V cache aborts the Adreno backend outright, and a
37
+ quantized K cache forces per-token dequantization onto a slow flash-attention
38
+ path on Mali (Pixel 8 decode dropped from ~10 tps to ~3 tps). The GPU branch
39
+ therefore sends no KV-cache override at all; the SDK default f16/f16 is both
40
+ the stable and the fast profile, matching shipped 0.12. The harness
41
+ additionally clamps cache-type-v to f16 on Android GPU loads as a backstop.
42
+
43
+ ## Consequences
44
+
45
+ - Embeddings follow the same split, and only on Android: Android devices and
46
+ every simulator pin `embedDevice: "cpu"`, while iOS devices keep GPU
47
+ embeddings. That matches the retired Core v1 policy and reflects the failure modes:
48
+ Adreno/Vulkan aborts outright, whereas a Metal device-lost fault self-heals
49
+ through the harness escalation in `lib/models/sdk-faults.ts`
50
+ (reload once, fall back to CPU on a repeat).
51
+ - Do not reintroduce host-boundary KV-cache field stripping; it only made sense
52
+ for the GPU/Vulkan path.
53
+ - This trades GPU acceleration on most Android devices for reliability and
54
+ lower memory.
55
+ - Tensor-era Pixel devices before Pixel 10 keep GPU acceleration for LLM
56
+ completions.
57
+ - Pixel 10 and newer stay on the Android CPU/q4_0 policy.
58
+ - Revisit if the SDK gains a capability probe that can distinguish Vulkan
59
+ drivers/devices that are safe under load, allowing a selective GPU re-enable.
60
+
61
+ ## Amendment 2026-08-03: the abort attribution is unverified
62
+
63
+ The Adreno aborts above are attributed to Vulkan, but the app never supplied
64
+ `openclCacheDir` on any load. `@qvac/llm-llamacpp` documents Adreno 700+ as an
65
+ **OpenCL** target, and the OpenCL backend `ggml_abort()`s during init when it
66
+ cannot write its kernel cache, the same symptom this ADR blames on Vulkan. The
67
+ backend that actually ran was never recorded, so which of the two caused the
68
+ aborts is still open.
69
+
70
+ Three things changed to make it answerable rather than re-litigated:
71
+
72
+ - `configureSdkStorage` now exports a writable kernel-cache directory, and the
73
+ harness passes `openclCacheDir` on Android LLM, embedding and transcription
74
+ loads (`lib/models/opencl-cache.ts`).
75
+ - The KV clamp covers **K as well as V** on Android GPU loads
76
+ (`adrenoSafeKvCache`). Adreno OpenCL throws on any K type other than
77
+ `f16`/`f32`/`bf16`, and this ADR's own Mali finding points the same way; only V
78
+ was clamped before, so an agent row pinning `kvCacheK` reached the load
79
+ unclamped.
80
+ - `EXPO_PUBLIC_QVAC_ANDROID_LLM_DEVICE` and
81
+ `EXPO_PUBLIC_QVAC_ANDROID_EMBED_DEVICE` (`"cpu" | "gpu"`) override the Android
82
+ branches per build, so a device can be retested without a code change. The
83
+ simulator branch ignores them.
84
+
85
+ The defaults in this ADR are unchanged and remain in force. Before flipping any
86
+ of them, record on real hardware the `backendDevice` value from
87
+ `hlog('round stats', …)` (the SDK reports `cpu | vulkan | metal | opencl`) plus
88
+ cold and warm `hlog('llm loaded', { loadMs })`. `opencl` means the kernel cache
89
+ is load-bearing and this ADR's attribution needs correcting; `vulkan` means the
90
+ cache dir is inert on this fleet and the CPU pin stands on its original
91
+ reasoning.
92
+
93
+ ## Amendment 2026-09-04: transcription follows the split too
94
+
95
+ The Consequences above extended the split to embeddings and stopped there, so
96
+ the transcription pool loaded parakeet with a hardcoded `useGPU: true` on every
97
+ device — including the Pixel 10 whose GPU this ADR pins to CPU for the LLM. On
98
+ that hardware the model loads, runs for a time proportional to the clip, and
99
+ returns an empty transcript, so the app reports "contained no detectable
100
+ speech" about a file full of speech. Nothing throws, so the
101
+ `voice gpu load refused, retrying on cpu` fallback added for QVAC-24132 never
102
+ fires: it covers a load that fails, not one that succeeds and produces nothing.
103
+
104
+ Transcription now takes the same GPU verdict as the LLM, carried on the host
105
+ boundary as `transcriptionDevice` (`"cpu" | "gpu"`) alongside `llmDevice` and
106
+ `embedDevice`, with `EXPO_PUBLIC_QVAC_ANDROID_TRANSCRIPTION_DEVICE` and
107
+ `EXPO_PUBLIC_QVAC_IOS_TRANSCRIPTION_DEVICE` to retest a device per build. An
108
+ absent value still means GPU, so desktop is unaffected. Parakeet allocates its
109
+ weights in one `ggml_backend_alloc_ctx_tensors` call with no partial-offload
110
+ knob, so a GPU this ADR does not trust with the LLM cannot be trusted with it
111
+ either.
112
+
113
+ Still open: the voice pools have no equivalent of the embedding fault
114
+ escalation in `lib/models/sdk-faults.ts`, so an empty
115
+ transcript from a GPU-loaded model is reported as fact rather than retried on
116
+ CPU. Until that exists, a mis-set device is silent rather than self-healing.
117
+
118
+ ## When Agents Should Read This
119
+
120
+ Read when changing Android device selection or KV-cache configuration for any
121
+ inference category — LLM, embeddings or transcription — in
122
+ `qvac-app → mobile/lib/android-llm-device.ts`, `qvac-app → mobile/lib/core-v2-harness-config.ts` or
123
+ the pool constructors in `index.ts`, or re-evaluating the
124
+ Android Vulkan/CPU and KV-cache Known Risks. Read the 2026-08-03 amendment
125
+ before treating the Vulkan attribution as settled, and the 2026-09-04 amendment
126
+ before adding a category that loads a model on the GPU.
@@ -0,0 +1,88 @@
1
+ ---
2
+ id: ADR-2026-07-29-assistant-owned-storage-layout
3
+ status: active
4
+ introduced_by: PR-000
5
+ supersedes: []
6
+ ---
7
+
8
+ # Assistant-owned storage separates shared models from private KV
9
+
10
+ ## Decision
11
+
12
+ The assistant owns the Desktop and Mobile storage layout. Hosts pass a release
13
+ environment plus optional root/model overrides; they do not construct assistant
14
+ or SDK subpaths.
15
+
16
+ Models stay at the SDK's canonical shared location on every platform:
17
+
18
+ ```text
19
+ ~/.qvac/models/ SDK-wide model and asset cache
20
+ ```
21
+
22
+ (Snap uses `$SNAP_USER_COMMON` as the home base, i.e.
23
+ `$SNAP_USER_COMMON/.qvac/models`.) This deliberately matches the `@qvac/sdk`
24
+ default (`getDefaultCacheDir → $HOME/.qvac/models`) so a standalone SDK, a CLI, or
25
+ another QVAC product on the same machine reuses one downloaded model cache rather
26
+ than each re-downloading multi-GB assets. Because of that sharing requirement
27
+ models are **not** moved under `XDG_DATA_HOME` — the host may still relocate them
28
+ explicitly via `QVAC_MODEL_CACHE_PATH` / the `models` override.
29
+
30
+ The assistant **image cache** resolves via `bare-storage.persistent()` —
31
+ `qvac/image-cache` under the OS data tier on every platform (`$XDG_DATA_HOME` on
32
+ Linux, `~/Library/Application Support` on macOS, `%APPDATA%` on Windows; Flatpak
33
+ `~/.var/app/<id>/data`). It stays in the _data_ tier (not `ephemeral()`/cache)
34
+ because the assistant sweeps it itself (`imageCacheMaxBytes`/`imageCacheMaxAgeMs`
35
+ → `sweepImageCache`), so it does not rely on OS cache eviction.
36
+
37
+ Private state is rooted at the platform persistent-data directory returned by
38
+ `bare-storage` (or `SNAP_USER_COMMON` for Snap):
39
+
40
+ ```text
41
+ qvac/{prod|staging|dev}/
42
+ ├── assistant/ private application state
43
+ └── sdk-home/ private SDK runtime home, registry, and KV cache
44
+ ```
45
+
46
+ The assistant writes an SDK JSON config with `cacheDirectory` pointing at the
47
+ canonical models directory and uses the SDK's supported home override to assign
48
+ each app/channel a private `sdk-home`. The SDK continues to derive its own
49
+ KV-cache subpath; Workbench does not duplicate that SDK implementation detail.
50
+
51
+ On Mobile, Delete All Data asks the live assistant harness to call the SDK's
52
+ `deleteCache({ all: true })` operation before stopping Core, then removes only
53
+ the `assistant` directory. A failed or unsupported KV-cache operation aborts the
54
+ reset so Workbench does not report permanent deletion while conversation-derived
55
+ cache remains. The host never deletes `sdk-home` directly, preserving SDK
56
+ registry metadata alongside the shared model assets.
57
+
58
+ The channel that home override rides on is per-runtime, not per-process. Off
59
+ bare-kit it is `env[SNAP_USER_COMMON]`, which is also the key `resolveStorage`
60
+ reads as the platform root; on bare-kit it is `Bare.argv[0]`, which `@qvac/sdk`
61
+ prefers over `HOME_DIR` and resolves lazily. Bare-kit worklets share one OS
62
+ `environ`, so a host that runs two of them — mobile runs the assistant and the
63
+ harness side by side — would have each `configureSdkStorage` redefine the root
64
+ the other's `resolveStorage` is about to read. Argv is copied into each
65
+ runtime, so the write cannot leave the worklet that made it.
66
+
67
+ Desktop `--storage`, `--tmp`, and `QVAC_STORAGE_PATH` replace only the private
68
+ root. `QVAC_MODEL_CACHE_PATH` is a separate override for hermetic tests and
69
+ other explicit host requirements. Mobile applies the same relative layout, but
70
+ OS app-ID sandboxes naturally prevent sharing across Development, Staging, and
71
+ Production installations.
72
+
73
+ ## Consequences
74
+
75
+ - Desktop apps and release channels reuse downloaded model assets without
76
+ sharing conversations, meshes, runtime locks, or KV caches.
77
+ - Mobile app reset clears every SDK KV cache through the harness and deletes
78
+ private assistant state while leaving shared models and SDK registry metadata
79
+ intact.
80
+ - Existing `~/.qvac/models` downloads continue to be used on every platform —
81
+ models are unchanged and stay aligned with the SDK default. The assistant
82
+ **image cache** moved from `~/.qvac-assistant/image-cache` to
83
+ `bare-storage.persistent()` (`qvac/image-cache`, data tier) — a greenfield
84
+ change with no migration of prior cache data. Legacy global KV and app-data
85
+ directories are not migrated or deleted.
86
+ - SDK cache layout changes remain owned by the SDK: Core supplies the supported
87
+ model-cache and home overrides and invokes SDK cache deletion without
88
+ reproducing the KV subpath.
@@ -0,0 +1,79 @@
1
+ ---
2
+ id: ADR-2026-08-04-sidecar-orphan-reaping
3
+ status: active
4
+ introduced_by: PR-1382
5
+ supersedes: []
6
+ ---
7
+
8
+ # The host reaps the sidecar's process group; the harness cannot reap itself
9
+
10
+ ## Decision
11
+
12
+ When the assistant sidecar exits, the desktop host kills the sidecar's whole process group.
13
+ The sidecar is spawned detached (`qvac-app → desktop/electron/assistant.ts`), so it leads its own group
14
+ and every harness it spawns stays in that group; killing the group on the exit edge reaps the
15
+ strays and can reach nothing else.
16
+
17
+ This is POSIX-only, and so is the bug. On Windows the kernel already ties the tree together:
18
+ libuv assigns every non-detached child to a Job Object created with `KILL_ON_JOB_CLOSE`
19
+ ("so windows will kill it when the parent process dies" — libuv's own comment), and
20
+ `bare-subprocess` is a thin `uv_spawn` binding that sets `UV_PROCESS_DETACHED` only when asked.
21
+ The sidecar and the harness are both spawned non-detached there, so killing the sidecar kills
22
+ the harness at kernel level, mid-inference included. Verified empirically on a real Windows
23
+ machine: sidecar killed mid-generation, both bare processes died, nothing lingered.
24
+
25
+ The harness does not try to detect its own orphaning.
26
+
27
+ ## Context
28
+
29
+ The harness holds gigabytes of loaded model weights. Stow's stop signal is pipe EOF, and a
30
+ sidecar killed outright (SIGKILL, OOM) never runs the ordered teardown that bounds its child —
31
+ so on POSIX the harness reparents to init and holds that memory until the machine reboots.
32
+
33
+ A manual `kill -9` is only the cleanest way to reproduce it. The same state follows from an
34
+ `abort()` or segfault in one of the sidecar's native addons (SQLite, the vector extension,
35
+ ffmpeg image normalization), from the kernel OOM killer picking the sidecar instead of the
36
+ harness, or from a developer killing a hung app.
37
+
38
+ ## Alternatives Considered
39
+
40
+ An in-process watchdog in the harness (poll `os.ppid()`, exit on reparent) was implemented and
41
+ reverted. The shipped bundle did contain the call, unref'd intervals do fire under bare, and
42
+ `os.ppid()` is a live `getppid()` that reflects reparenting — yet a harness orphaned for minutes
43
+ never exited. A harness parked in a native SDK call is not running JS at all, so no _JS-level_
44
+ mechanism can survive it, whatever signal it watches.
45
+
46
+ A native parent-death watcher would survive it, since it does not need the JS thread:
47
+ `WaitForSingleObject` on the parent handle, `PR_SET_PDEATHSIG` on Linux, kqueue `NOTE_EXIT` on
48
+ macOS. All require native work in bare, so the host-side reap was preferred.
49
+
50
+ ## Consequences
51
+
52
+ - The host is the only place that can bound the harness on abrupt sidecar death. The reap must
53
+ stay wired for every sidecar spawned, whether or not a renderer ever adopted it.
54
+ - The `detached: process.platform !== "win32"` split is load-bearing in both directions. POSIX
55
+ needs the detach or the group kill has nothing to aim at; Windows needs the non-detach or
56
+ libuv skips the job assignment and the kernel tie is lost. Setting `detached: true` on Windows
57
+ would create there the very bug this reap fixes on POSIX.
58
+ - Keep the harness in the sidecar's group — never spawn it detached, on any platform. On POSIX
59
+ a detached harness leaves the sidecar's group and silently escapes this reap (no error, no
60
+ failing test unless the real-process test in
61
+ `qvac-app → desktop/test/electron/assistant-sidecar-orphan.test.ts` is kept); on Windows it leaves the
62
+ sidecar's job and escapes the kernel cascade the same way.
63
+ - Prefer the group over a pid on POSIX. The harness's own children (exec and shell tools) are in
64
+ the group too; killing the harness pid alone leaves them orphaned.
65
+ - The win32 branch of `forceKillAssistantSidecarTree` (`taskkill /T` on the sidecar) matters for
66
+ the clean-stop escalation, where the sidecar is alive but will not unwind; on the exit edge it
67
+ is a post-mortem no-op — the job object has already done the work.
68
+ - Not solved on POSIX: the host dying abruptly while the sidecar is itself blocked in a native
69
+ call. Then the sidecar cannot act on its own pipe EOF and nothing alive can reap either
70
+ process. A sweep for stray harnesses at startup is the remedy if that shows up in practice.
71
+ (Windows is covered even here: the host's death closes its job handle and the cascade takes
72
+ the whole tree.)
73
+ - `killStowChildren()` (`bare-supervisor/stow`) still covers a clean host exit; this covers the
74
+ abrupt sidecar death it cannot see.
75
+
76
+ ## When Agents Should Read This
77
+
78
+ Read this when changing the paths, workflows, runtime boundaries, or platform behavior listed
79
+ for this ADR in `docs/decisions/INDEX.md`.
@@ -0,0 +1,68 @@
1
+ ---
2
+ id: ADR-2026-08-11-pyodide-worker-thread
3
+ status: active
4
+ introduced_by: PR-1524
5
+ supersedes: []
6
+ ---
7
+
8
+ # Pyodide runs in a worker thread with a shared interrupt buffer
9
+
10
+ ## Decision
11
+
12
+ The harness executes python (Pyodide, CPython on WebAssembly) in a dedicated
13
+ worker thread — `Bare.Thread` + `bare-channel` on Bare, `worker_threads` on
14
+ Node — never on the harness thread. Cancellation goes through a 1-byte
15
+ `SharedArrayBuffer` registered with `pyodide.setInterruptBuffer()`: the host
16
+ writes SIGINT, CPython raises `KeyboardInterrupt` at the next bytecode
17
+ boundary, and the interpreter survives for the next run. A run that ignores
18
+ the interrupt past a grace window gets the worker killed and respawned.
19
+
20
+ ## Context
21
+
22
+ `runPython` is synchronous inside Pyodide, so on the harness thread a busy
23
+ script froze every tool and chat turn, and timeout/abort could only settle
24
+ before or after a run. An interrupt can only be delivered from another thread
25
+ (the blocked thread cannot run JS), so shared memory plus a second thread is
26
+ the minimal correct shape. Two Bare constraints shaped the implementation:
27
+
28
+ - `Bare.Thread.terminate()` is honored at a safepoint only. It cannot stop a
29
+ busy-spinning thread, and `join()` on one blocks forever — so on Bare a
30
+ wedged worker is abandoned (never joined), while Node's `terminate()`
31
+ genuinely kills. Joining a healthy worker is only safe after its channel end
32
+ has closed (`drainAndReap`), because the channel teardown itself needs the
33
+ host's event loop.
34
+ - A fresh thread's module loader reads plain files only. Spawning a thread
35
+ whose entry lives inside a stowed `.bundle` aborts the whole process, but a
36
+ `.bundle` path _as the entry itself_ boots fine. Every stowed harness —
37
+ desktop (dev and packaged) and mobile — must therefore stage the worker as
38
+ its own bundle and name it via `QVAC_PYODIDE_WORKER_ENTRY`; until a host
39
+ sets it, a bundled harness falls back to the pre-worker in-process path
40
+ rather than crash, so the worker only runs under the harness test suites.
41
+
42
+ ## Alternatives Considered
43
+
44
+ - **In-process with a watchdog thread writing the interrupt buffer**: keeps
45
+ the harness thread blocked during runs and cannot serve a user abort (the
46
+ event loop that would receive it is the blocked one).
47
+ - **Subprocess CPython**: no wasm sandbox, not portable to mobile (iOS forbids
48
+ spawning processes).
49
+
50
+ ## Consequences
51
+
52
+ - The worker thread is an availability boundary, not a security boundary:
53
+ python can still reach the worker realm's globals via `from js import …`, so
54
+ a python grant keeps shell-level trust (see `scope.ts`).
55
+ - Anything that changes worker/entry module paths must keep the
56
+ bundled-harness fallback and the `QVAC_PYODIDE_WORKER_ENTRY` override
57
+ working, or every bundled host crashes on the first python run.
58
+ - The in-process path survives for injected test loaders only; fakes cannot
59
+ cross a thread boundary. Worker transport tests load a fake interpreter
60
+ inside the thread via `QVAC_PYODIDE_TEST_LOADER`.
61
+ - Revisit if Bare threads gain forced termination, or when the packaging
62
+ pipeline ships the staged worker bundle (the fallback then becomes dead
63
+ code).
64
+
65
+ ## When Agents Should Read This
66
+
67
+ Read this when changing the paths, workflows, runtime boundaries, or platform
68
+ behavior listed for this ADR in `docs/decisions/INDEX.md`.
@@ -0,0 +1,84 @@
1
+ ---
2
+ id: ADR-2026-08-17-harness-hook-pipeline
3
+ status: active
4
+ introduced_by: PR-000
5
+ supersedes: []
6
+ ---
7
+
8
+ # Harness hooks fold a whole round before any call runs, and fail closed
9
+
10
+ ## Decision
11
+
12
+ `assistant-harness` exposes four hook phases — `runStart`, `preToolCall`, `postToolCall`,
13
+ `runEnd` — registered in a single process-wide array (`harnessHooks`) and driven by
14
+ `HookPipeline` from inside `completion()`. Hooks receive a deep JSON clone of their payload and
15
+ influence the run only through what they return: new `args` for a pre-hook, a new `result` for a
16
+ post-hook, or a `HookRejection` (`{ error, guidance: true }`).
17
+
18
+ Two ordering rules are durable:
19
+
20
+ - **Pre-hooks fold the whole round before any call in that round executes.** The assistant turn
21
+ written to history replays the folded calls, so history can never disagree with what ran.
22
+ - **Post-hooks fire only after a real `tool.execute()`.** Refused, denied, invalid, guarded,
23
+ capped, and unknown calls never reach a post-hook.
24
+
25
+ A pre-hook may also _repair_ a call: the `args` it returns replace the model's own, and the
26
+ payload carries the tool's declared `parameters` so the repair is schema-driven rather than
27
+ per-tool. `ArgRepairHook` is registered first for that reason — it decodes a structured parameter
28
+ a small model handed over as text (fenced, newline-broken, or missing its outer closer) before
29
+ approval, the activity log, and `execute()` read the call, so all three see the same canonical
30
+ args and the tool itself keeps a strict reader.
31
+
32
+ Failure is closed, not fatal. A throwing tool hook becomes model-facing guidance and the loop
33
+ continues; a throwing `runStart`/`runEnd` hook is logged and ignored, because run authorization
34
+ lives in the policy layer, not here.
35
+
36
+ ## Context
37
+
38
+ Tool calls already pass through validation, the trusted-source guard, effect classification, and
39
+ the approval memo. Extending any of those for cross-cutting concerns (redaction, arg rewriting,
40
+ telemetry, per-tool filtering) would have meant editing the gate chain for every new concern.
41
+
42
+ The round-level fold exists because the assistant turn is written to history once, before the
43
+ calls run. Folding per call would let a pre-hook mutate arg N after the turn already recorded the
44
+ model's original wrapper — history and execution would diverge, and the model would be trained by
45
+ its own record to re-emit the un-mutated form.
46
+
47
+ Deep cloning exists because payloads are the run's live objects: model-emitted args, history-bound
48
+ results, the message list. A hook that mutates one in place would leak into the run without
49
+ returning anything, which makes the "influence only through return values" contract unenforceable.
50
+ Payloads are JSON-domain, so a `JSON.parse(JSON.stringify(...))` round-trip severs every reference;
51
+ non-JSON values (`undefined`, functions, `BigInt`, cycles) pass through as-is rather than
52
+ withholding output that already ran.
53
+
54
+ ## Alternatives Considered
55
+
56
+ - **Per-call fold (pre(N) after post(N-1)).** Rejected: it desynchronizes the assistant turn from
57
+ what executes. Accepted cost of the round fold is that pre-hooks fire for calls a later gate
58
+ (skill lock, search cap, abort) never runs.
59
+ - **Hooks that throw abort the run.** Rejected: a buggy third-party hook would become a run-level
60
+ denial of service. Failing closed on the _call_ keeps the run recoverable.
61
+ - **In-place mutation of the live payload.** Rejected: no way to distinguish a deliberate change
62
+ from an accidental one, and no way to reject a hook's edit.
63
+ - **Structured-clone / a clone library.** Rejected: payloads are already JSON-domain; a dependency
64
+ buys nothing a `JSON` round-trip does not.
65
+
66
+ ## Consequences
67
+
68
+ - A new hook phase must state whether it fires before or after execution, and post-phase hooks
69
+ must never fire for a call that did not execute.
70
+ - Rejection wording must never match the SDK wedge patterns in `models/sdk-faults.ts` — those mark
71
+ a dead native context for eviction, not guidance for the model. A post-hook rejection says the
72
+ tool already executed, so the model does not retry a side effect.
73
+ - With no hook registered a run pays nothing, not even a payload clone. Keep that: `harnessHooks`
74
+ is empty by default.
75
+ - Registration is a process-wide array with no ordering, priority, or per-agent scoping. That is
76
+ deliberate and should be revisited only when a real consumer needs it.
77
+ - Hooks cannot bypass approval, the trusted-source guard, or the denial memo — those gates run on
78
+ the folded args, after the hook.
79
+
80
+ ## When Agents Should Read This
81
+
82
+ Read this when changing the paths listed for this ADR in `docs/decisions/INDEX.md` — the tool loop
83
+ in `capabilities/completion.ts`, anything under `lib/hooks/`, or any new gate that must order
84
+ itself against the fold.