gentle-pi 4.2.0 → 4.4.0

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 (334) hide show
  1. package/README.md +340 -138
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,323 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import test from "node:test";
6
+ import { fileURLToPath } from "node:url";
7
+ import { gzipSync } from "node:zlib";
8
+ import { gentlePiConfigHome } from "../lib/agent-home.ts";
9
+ import type { VoiceModelManifest, VoiceNpmPackage, VoiceRuntimeManifest } from "../lib/voice-artifacts.ts";
10
+ import {
11
+ installVoiceModel,
12
+ installVoiceRuntime,
13
+ readVoiceComponentStatus,
14
+ resolveVoiceHome,
15
+ VoiceInstallError,
16
+ type VoiceFetch,
17
+ type VoiceInstallProgress,
18
+ } from "../lib/voice-install.ts";
19
+
20
+ // Optional voice acquisition (S1-S3): the runtime and the Parakeet model are
21
+ // downloaded only on explicit request, verified by size and digest while they
22
+ // stream, staged privately and published with one rename. Every rejection must
23
+ // leave no published component and no staging behind, and never touch an
24
+ // install that already exists. The network is injected; scratch stays in tmp/.
25
+
26
+ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
27
+
28
+ function scratch(t: test.TestContext): string {
29
+ const base = join(repoRoot, "tmp");
30
+ mkdirSync(base, { recursive: true });
31
+ const dir = mkdtempSync(join(base, "voice-tests-"));
32
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
33
+ return join(dir, "voice");
34
+ }
35
+
36
+ const sha256 = (data: Buffer) => createHash("sha256").update(data).digest("hex");
37
+ const sha512 = (data: Buffer) => `sha512-${createHash("sha512").update(data).digest("base64")}`;
38
+
39
+ interface Route { status?: number; headers?: Record<string, string>; chunks?: Buffer[]; stallAfter?: number }
40
+
41
+ function fakeFetch(routes: Record<string, Route>): { fetch: VoiceFetch; calls: string[] } {
42
+ const calls: string[] = [];
43
+ const fetch: VoiceFetch = async (url, init) => {
44
+ calls.push(url);
45
+ assert.equal(init.redirect, "manual");
46
+ const route = routes[url];
47
+ if (!route) return new Response("missing", { status: 404 });
48
+ const chunks = route.chunks ?? [];
49
+ let index = 0;
50
+ const body = route.status && route.status >= 300 && route.status < 400 ? null : new ReadableStream<Uint8Array>({
51
+ pull(controller) {
52
+ if (route.stallAfter !== undefined && index >= route.stallAfter) return new Promise<void>(() => {});
53
+ if (index >= chunks.length) { controller.close(); return; }
54
+ controller.enqueue(new Uint8Array(chunks[index++]!));
55
+ },
56
+ });
57
+ return new Response(body, { status: route.status ?? 200, headers: route.headers });
58
+ };
59
+ return { fetch, calls };
60
+ }
61
+
62
+ const halves = (data: Buffer) => [data.subarray(0, data.length >> 1), data.subarray(data.length >> 1)];
63
+
64
+ const HF = "https://huggingface.co/acme/tiny/resolve/0123456789abcdef0123456789abcdef01234567";
65
+ const CDN = "https://us.aws.cdn.hf.co/xet-bridge-us/blob";
66
+
67
+ function modelFixture(files: Record<string, Buffer>): VoiceModelManifest {
68
+ return {
69
+ id: "tiny-model-0123456",
70
+ revision: "0123456789abcdef0123456789abcdef01234567",
71
+ baseUrl: HF,
72
+ files: Object.entries(files).map(([name, data]) => ({ name, size: data.length, sha256: sha256(data) })),
73
+ redirects: [
74
+ { origin: "https://huggingface.co", pathPrefix: "/api/resolve-cache/models/acme/tiny/0123456789abcdef0123456789abcdef01234567/" },
75
+ { origin: "https://us.aws.cdn.hf.co", pathPrefix: "/xet-bridge-us/" },
76
+ ],
77
+ };
78
+ }
79
+
80
+ const encoder = Buffer.from("encoder-bytes-".repeat(64));
81
+ const tokens = Buffer.from("a 0\nb 1\n");
82
+
83
+ function modelRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
84
+ return {
85
+ // The observed Hugging Face shapes: a relative 307 into resolve-cache for
86
+ // plain files and an absolute 302 to the regional Xet CDN for LFS files.
87
+ [`${HF}/encoder.onnx`]: { status: 302, headers: { location: `${CDN}/encoder?sig=1` } },
88
+ [`${CDN}/encoder?sig=1`]: { headers: { "content-length": String(encoder.length) }, chunks: halves(encoder) },
89
+ [`${HF}/tokens.txt`]: { status: 307, headers: { location: "/api/resolve-cache/models/acme/tiny/0123456789abcdef0123456789abcdef01234567/tokens.txt?x=1" } },
90
+ ["https://huggingface.co/api/resolve-cache/models/acme/tiny/0123456789abcdef0123456789abcdef01234567/tokens.txt?x=1"]: { chunks: [tokens] },
91
+ ...overrides,
92
+ };
93
+ }
94
+
95
+ const modelManifest = () => modelFixture({ "encoder.onnx": encoder, "tokens.txt": tokens });
96
+
97
+ function leftovers(voiceHome: string): string[] {
98
+ if (!existsSync(voiceHome)) return [];
99
+ return readdirSync(voiceHome).filter((name) => name.startsWith(".staging"));
100
+ }
101
+
102
+ async function rejects(promise: Promise<unknown>, code: string): Promise<VoiceInstallError> {
103
+ try { await promise; } catch (error) {
104
+ assert.ok(error instanceof VoiceInstallError, String(error));
105
+ assert.equal(error.code, code, error.message);
106
+ return error;
107
+ }
108
+ assert.fail(`expected ${code}`);
109
+ }
110
+
111
+ test("the voice home lives under the Gentle AI config home and reports components as missing", async (t) => {
112
+ const env = { GENTLE_PI_CONFIG_HOME: join(repoRoot, "tmp", "voice-config") };
113
+ assert.equal(resolveVoiceHome(env), join(gentlePiConfigHome(env), "voice"));
114
+ const voiceHome = scratch(t);
115
+ const status = await readVoiceComponentStatus({ voiceHome, kind: "model", manifest: modelManifest() });
116
+ assert.equal(status.state, "missing");
117
+ assert.equal(existsSync(voiceHome), false, "status never creates the voice home");
118
+ });
119
+
120
+ test("the model streams through permitted redirects, is verified and published atomically", async (t) => {
121
+ const voiceHome = scratch(t);
122
+ const { fetch } = fakeFetch(modelRoutes());
123
+ const progress: VoiceInstallProgress[] = [];
124
+ const result = await installVoiceModel({ voiceHome, manifest: modelManifest(), fetch, onProgress: (p) => progress.push(p) });
125
+ assert.equal(result.alreadyInstalled, false);
126
+ assert.equal(readFileSync(join(result.path, "encoder.onnx")).equals(encoder), true);
127
+ assert.equal(readFileSync(join(result.path, "tokens.txt")).equals(tokens), true);
128
+ assert.equal((await readVoiceComponentStatus({ voiceHome, kind: "model", manifest: modelManifest() })).state, "installed");
129
+ assert.deepEqual(leftovers(voiceHome), []);
130
+ const downloads = progress.filter((p) => p.phase === "download");
131
+ assert.ok(downloads.length >= 3);
132
+ assert.equal(downloads.at(-1)!.overallReceivedBytes, encoder.length + tokens.length);
133
+ assert.equal(downloads.at(-1)!.overallTotalBytes, encoder.length + tokens.length);
134
+ assert.ok(downloads.every((p, i) => i === 0 || p.overallReceivedBytes >= downloads[i - 1]!.overallReceivedBytes));
135
+ if (process.platform !== "win32") assert.equal(statSync(voiceHome).mode & 0o077, 0, "voice home is private");
136
+ });
137
+
138
+ test("an installed component is reported as such without any network access", async (t) => {
139
+ const voiceHome = scratch(t);
140
+ await installVoiceModel({ voiceHome, manifest: modelManifest(), fetch: fakeFetch(modelRoutes()).fetch });
141
+ const second = fakeFetch(modelRoutes());
142
+ const result = await installVoiceModel({ voiceHome, manifest: modelManifest(), fetch: second.fetch });
143
+ assert.equal(result.alreadyInstalled, true);
144
+ assert.deepEqual(second.calls, []);
145
+ });
146
+
147
+ test("a digest mismatch is rejected and leaves nothing published or staged", async (t) => {
148
+ const voiceHome = scratch(t);
149
+ const tampered = Buffer.from(tokens); tampered[0] = 0x7a;
150
+ const { fetch } = fakeFetch(modelRoutes({ ["https://huggingface.co/api/resolve-cache/models/acme/tiny/0123456789abcdef0123456789abcdef01234567/tokens.txt?x=1"]: { chunks: [tampered] } }));
151
+ await rejects(installVoiceModel({ voiceHome, manifest: modelManifest(), fetch }), "integrity");
152
+ assert.equal((await readVoiceComponentStatus({ voiceHome, kind: "model", manifest: modelManifest() })).state, "missing");
153
+ assert.deepEqual(leftovers(voiceHome), []);
154
+ });
155
+
156
+ test("a body longer than the pinned size is cut off as a size violation", async (t) => {
157
+ const voiceHome = scratch(t);
158
+ const { fetch } = fakeFetch(modelRoutes({ [`${CDN}/encoder?sig=1`]: { chunks: [encoder, Buffer.from("extra")] } }));
159
+ await rejects(installVoiceModel({ voiceHome, manifest: modelManifest(), fetch }), "size");
160
+ const mismatchedLength = fakeFetch(modelRoutes({ [`${CDN}/encoder?sig=1`]: { headers: { "content-length": "1" }, chunks: [encoder] } }));
161
+ await rejects(installVoiceModel({ voiceHome, manifest: modelManifest(), fetch: mismatchedLength.fetch }), "size");
162
+ assert.deepEqual(leftovers(voiceHome), []);
163
+ });
164
+
165
+ test("redirects outside the permitted hosts, paths or HTTPS are refused before they are followed", async (t) => {
166
+ const voiceHome = scratch(t);
167
+ for (const location of ["https://evil.example/xet-bridge-us/blob", "http://us.aws.cdn.hf.co/xet-bridge-us/blob", "https://us.aws.cdn.hf.co/other/blob"]) {
168
+ const { fetch, calls } = fakeFetch(modelRoutes({ [`${HF}/encoder.onnx`]: { status: 302, headers: { location } } }));
169
+ await rejects(installVoiceModel({ voiceHome, manifest: modelManifest(), fetch }), "redirect");
170
+ assert.equal(calls.includes(location), false, location);
171
+ }
172
+ assert.deepEqual(leftovers(voiceHome), []);
173
+ });
174
+
175
+ test("cancelling mid-download aborts, cleans the staging and publishes nothing", async (t) => {
176
+ const voiceHome = scratch(t);
177
+ const controller = new AbortController();
178
+ const { fetch } = fakeFetch(modelRoutes({ [`${CDN}/encoder?sig=1`]: { chunks: halves(encoder), stallAfter: 1 } }));
179
+ const pending = installVoiceModel({
180
+ voiceHome, manifest: modelManifest(), fetch, signal: controller.signal,
181
+ onProgress: (p) => { if (p.phase === "download") controller.abort(); },
182
+ });
183
+ await rejects(pending, "aborted");
184
+ assert.equal((await readVoiceComponentStatus({ voiceHome, kind: "model", manifest: modelManifest() })).state, "missing");
185
+ assert.deepEqual(leftovers(voiceHome), []);
186
+ });
187
+
188
+ test("a stalled download is aborted by the idle time bound", async (t) => {
189
+ const voiceHome = scratch(t);
190
+ const { fetch } = fakeFetch(modelRoutes({ [`${CDN}/encoder?sig=1`]: { chunks: halves(encoder), stallAfter: 1 } }));
191
+ await rejects(installVoiceModel({ voiceHome, manifest: modelManifest(), fetch, timeouts: { idleMs: 30, totalMs: 5_000 } }), "timeout");
192
+ assert.deepEqual(leftovers(voiceHome), []);
193
+ });
194
+
195
+ test("a failed install never disturbs an existing component directory", async (t) => {
196
+ const voiceHome = scratch(t);
197
+ const finalPath = join(voiceHome, "models", modelManifest().id);
198
+ mkdirSync(finalPath, { recursive: true });
199
+ writeFileSync(join(finalPath, "encoder.onnx"), "previous");
200
+ assert.equal((await readVoiceComponentStatus({ voiceHome, kind: "model", manifest: modelManifest() })).state, "invalid");
201
+ const { fetch } = fakeFetch(modelRoutes({ [`${CDN}/encoder?sig=1`]: { chunks: [Buffer.from("wrong")] } }));
202
+ await rejects(installVoiceModel({ voiceHome, manifest: modelManifest(), fetch }), "size");
203
+ assert.equal(readFileSync(join(finalPath, "encoder.onnx"), "utf8"), "previous");
204
+ assert.deepEqual(readdirSync(finalPath), ["encoder.onnx"]);
205
+ // A verified install replaces the invalid one only as a whole.
206
+ const result = await installVoiceModel({ voiceHome, manifest: modelManifest(), fetch: fakeFetch(modelRoutes()).fetch });
207
+ assert.equal(readFileSync(join(result.path, "encoder.onnx")).equals(encoder), true);
208
+ assert.deepEqual(leftovers(voiceHome), []);
209
+ });
210
+
211
+ // --- Runtime packages -------------------------------------------------------
212
+
213
+ interface TarEntry { name: string; data?: Buffer; type?: string; mode?: number; linkname?: string; prefix?: string; size?: number }
214
+
215
+ function tarHeader(entry: TarEntry, size: number): Buffer {
216
+ const header = Buffer.alloc(512);
217
+ header.write(entry.name, 0, 100);
218
+ header.write(`${(entry.mode ?? 0o644).toString(8).padStart(6, "0")} \0`, 100);
219
+ header.write("000000 \0", 108);
220
+ header.write("000000 \0", 116);
221
+ header.write(`${size.toString(8).padStart(11, "0")} `, 124);
222
+ header.write("00000000000 ", 136);
223
+ header.fill(" ", 148, 156);
224
+ header.write(entry.type ?? "0", 156);
225
+ header.write(entry.linkname ?? "", 157, 100);
226
+ header.write("ustar\0", 257);
227
+ header.write("00", 263);
228
+ header.write(entry.prefix ?? "", 345, 155);
229
+ let sum = 0;
230
+ for (const byte of header) sum += byte;
231
+ header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148);
232
+ return header;
233
+ }
234
+
235
+ function tgz(entries: TarEntry[]): Buffer {
236
+ const blocks: Buffer[] = [];
237
+ for (const entry of entries) {
238
+ const data = entry.data ?? Buffer.alloc(0);
239
+ blocks.push(tarHeader(entry, entry.size ?? data.length), data, Buffer.alloc((512 - (data.length % 512)) % 512));
240
+ }
241
+ blocks.push(Buffer.alloc(1024));
242
+ return gzipSync(Buffer.concat(blocks));
243
+ }
244
+
245
+ function npmPackage(name: string, entries: TarEntry[], requiredFiles: string[], limits: { fileCount?: number; unpackedSize?: number } = {}): { pkg: VoiceNpmPackage; tarball: Buffer } {
246
+ const tarball = tgz(entries);
247
+ const unpacked = entries.reduce((total, e) => total + (e.size ?? e.data?.length ?? 0), 0);
248
+ return {
249
+ tarball,
250
+ pkg: {
251
+ name, version: "1.0.0", license: "Apache-2.0", requiredFiles,
252
+ tarball: `https://registry.npmjs.org/${name}/-/${name}-1.0.0.tgz`,
253
+ size: tarball.length, integrity: sha512(tarball),
254
+ fileCount: limits.fileCount ?? entries.length, unpackedSize: limits.unpackedSize ?? unpacked,
255
+ },
256
+ };
257
+ }
258
+
259
+ const loader = Buffer.from("module.exports = require('../fake-native-darwin-arm64/addon.node');\n");
260
+ const addon = Buffer.from("\x7fnative");
261
+
262
+ function runtimeFixture(nativeEntries: TarEntry[] = [{ name: "package/addon.node", data: addon, mode: 0o755 }, { name: "package/package.json", data: Buffer.from("{}") }]) {
263
+ const node = npmPackage("fake-node", [{ name: "package/package.json", data: Buffer.from("{}") }, { name: "package/lib/addon.js", data: loader }], ["package.json", "lib/addon.js"]);
264
+ const native = npmPackage("fake-native-darwin-arm64", nativeEntries, ["addon.node"]);
265
+ const manifest: VoiceRuntimeManifest = { id: "fake-runtime-1.0.0-darwin-arm64", target: "darwin-arm64", packages: [node.pkg, native.pkg], redirects: [] };
266
+ const routes: Record<string, Route> = {
267
+ [node.pkg.tarball]: { chunks: [node.tarball] },
268
+ [native.pkg.tarball]: { chunks: halves(native.tarball) },
269
+ };
270
+ return { manifest, routes };
271
+ }
272
+
273
+ test("runtime packages are verified and extracted as sibling node_modules directories", async (t) => {
274
+ const voiceHome = scratch(t);
275
+ const { manifest, routes } = runtimeFixture();
276
+ const result = await installVoiceRuntime({ voiceHome, target: "darwin-arm64", manifest, fetch: fakeFetch(routes).fetch });
277
+ assert.equal(readFileSync(join(result.path, "node_modules", "fake-node", "lib", "addon.js")).equals(loader), true);
278
+ const native = join(result.path, "node_modules", "fake-native-darwin-arm64", "addon.node");
279
+ assert.equal(readFileSync(native).equals(addon), true);
280
+ if (process.platform !== "win32") assert.equal(statSync(native).mode & 0o111, 0o111, "executable bit is kept");
281
+ assert.equal((await readVoiceComponentStatus({ voiceHome, kind: "runtime", manifest })).state, "installed");
282
+ assert.deepEqual(readdirSync(join(result.path, "node_modules")).sort(), ["fake-native-darwin-arm64", "fake-node"]);
283
+ assert.deepEqual(leftovers(voiceHome), []);
284
+ });
285
+
286
+ test("unsafe archive entries are rejected before anything is extracted", async (t) => {
287
+ const voiceHome = scratch(t);
288
+ const good = { name: "package/addon.node", data: addon };
289
+ const cases: Array<[string, RegExp, TarEntry[], { fileCount?: number; unpackedSize?: number }?]> = [
290
+ ["symlink", /entry type '2'/, [good, { name: "package/link", type: "2", linkname: "/etc/passwd" }]],
291
+ ["hardlink", /entry type '1'/, [good, { name: "package/hard", type: "1", linkname: "package/addon.node" }]],
292
+ ["directory", /entry type '5'/, [good, { name: "package/dir/", type: "5" }]],
293
+ ["traversal", /not a safe relative path/, [good, { name: "package/../../escape.txt", data: Buffer.from("x") }]],
294
+ ["Windows device name", /not a safe relative path/, [good, { name: "package/lib/aux.js", data: Buffer.from("x") }]],
295
+ ["absolute", /outside package\//, [good, { name: "/abs/escape.txt", data: Buffer.from("x") }]],
296
+ ["outside package/", /outside package\//, [good, { name: "other/file.txt", data: Buffer.from("x") }]],
297
+ ["duplicate", /duplicated/, [good, { name: "package/ADDON.node", data: addon }]],
298
+ ["file used as directory", /uses a file as a directory/, [good, { name: "package/addon.node/inner", data: Buffer.from("x") }]],
299
+ ["too many entries", /more than 1 entries/, [good, { name: "package/b.txt", data: Buffer.from("x") }], { fileCount: 1 }],
300
+ ["too many bytes", /unpacked bytes/, [good, { name: "package/b.txt", data: Buffer.alloc(600) }], { unpackedSize: addon.length + 10 }],
301
+ ["missing required file", /addon\.node is missing/, [{ name: "package/other.node", data: addon }]],
302
+ ];
303
+ for (const [label, reason, entries, limits] of cases) {
304
+ const fixture = runtimeFixture();
305
+ const native = npmPackage("fake-native-darwin-arm64", entries, ["addon.node"], limits);
306
+ fixture.manifest.packages[1] = native.pkg;
307
+ fixture.routes[native.pkg.tarball] = { chunks: [native.tarball] };
308
+ const error = await rejects(installVoiceRuntime({ voiceHome, target: "darwin-arm64", manifest: fixture.manifest, fetch: fakeFetch(fixture.routes).fetch }), "archive");
309
+ assert.match(error.message, reason, label);
310
+ assert.equal((await readVoiceComponentStatus({ voiceHome, kind: "runtime", manifest: fixture.manifest })).state, "missing", label);
311
+ assert.deepEqual(leftovers(voiceHome), [], label);
312
+ }
313
+ assert.equal(existsSync(join(dirname(voiceHome), "escape.txt")), false);
314
+ assert.equal(existsSync(join(voiceHome, "escape.txt")), false);
315
+ });
316
+
317
+ test("a runtime is refused for a target outside the five known ones", async (t) => {
318
+ const voiceHome = scratch(t);
319
+ const { fetch, calls } = fakeFetch({});
320
+ await rejects(installVoiceRuntime({ voiceHome, target: "win32-arm64" as never, fetch }), "unsupported");
321
+ assert.deepEqual(calls, []);
322
+ assert.equal(existsSync(voiceHome), false);
323
+ });
@@ -0,0 +1,176 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import fsPromises from "node:fs/promises";
4
+ import { syncBuiltinESMExports } from "node:module";
5
+ import test from "node:test";
6
+ import {
7
+ NODE_CPAL,
8
+ PARAKEET_MODEL_MANIFEST,
9
+ SHERPA_ONNX_NODE,
10
+ VOICE_NOTICES,
11
+ VOICE_TARGETS,
12
+ voiceRuntimeManifest,
13
+ } from "../lib/voice-artifacts.ts";
14
+ import { diagnoseVoicePlatform, readVoicePlatformProbe } from "../lib/voice-platform.ts";
15
+
16
+ // Optional voice (S1-S3): the platform diagnosis decides what /gentle:voice may offer.
17
+ // It must name the five researched targets without ever claiming hardware-verified
18
+ // support, and refuse the unproven ones with a reason instead of a broken install.
19
+
20
+ const glibc = "2.35";
21
+
22
+ test("the five researched targets resolve to a runtime target that is never reported as hardware verified", () => {
23
+ const probes = [
24
+ { platform: "darwin", arch: "x64", target: "darwin-x64" },
25
+ { platform: "darwin", arch: "arm64", target: "darwin-arm64" },
26
+ { platform: "linux", arch: "x64", glibcVersion: glibc, target: "linux-x64" },
27
+ { platform: "linux", arch: "arm64", glibcVersion: glibc, target: "linux-arm64" },
28
+ { platform: "win32", arch: "x64", target: "win32-x64" },
29
+ ];
30
+ assert.deepEqual(probes.map((p) => p.target), [...VOICE_TARGETS]);
31
+ for (const { target, ...probe } of probes) {
32
+ const diagnosis = diagnoseVoicePlatform(probe);
33
+ assert.equal(diagnosis.support, "known", target);
34
+ assert.equal(diagnosis.target, target);
35
+ assert.equal(diagnosis.transcription.available, true, target);
36
+ assert.equal(diagnosis.hardwareVerified, false, target);
37
+ assert.ok(diagnosis.limitations.some((line) => /not been verified on hardware/.test(line)), target);
38
+ }
39
+ });
40
+
41
+ test("Windows ARM64, musl Linux and other platforms are unsupported with a stated reason", () => {
42
+ const cases = [
43
+ { probe: { platform: "win32", arch: "arm64" }, reason: /Windows ARM64/ },
44
+ { probe: { platform: "linux", arch: "x64" }, reason: /glibc/ },
45
+ { probe: { platform: "linux", arch: "ia32", glibcVersion: glibc }, reason: /linux-ia32/ },
46
+ { probe: { platform: "freebsd", arch: "x64" }, reason: /freebsd-x64/ },
47
+ ];
48
+ for (const { probe, reason } of cases) {
49
+ const diagnosis = diagnoseVoicePlatform(probe);
50
+ assert.equal(diagnosis.support, "unsupported", probe.platform + probe.arch);
51
+ assert.equal(diagnosis.target, undefined);
52
+ assert.equal(diagnosis.transcription.available, false);
53
+ assert.equal(diagnosis.microphone.available, false);
54
+ assert.match(diagnosis.transcription.reason ?? "", reason);
55
+ }
56
+ });
57
+
58
+ test("WSL keeps its Linux target but is reported as unproven rather than supported", () => {
59
+ const diagnosis = diagnoseVoicePlatform({ platform: "linux", arch: "x64", glibcVersion: glibc, wsl: true });
60
+ assert.equal(diagnosis.support, "unproven");
61
+ assert.equal(diagnosis.target, "linux-x64");
62
+ assert.ok(diagnosis.limitations.some((line) => /WSL/.test(line)));
63
+ });
64
+
65
+ test("every known target ships the pinned node-cpal capture binary for its own architecture", () => {
66
+ for (const target of VOICE_TARGETS) {
67
+ const [platform, arch] = target.split("-");
68
+ const diagnosis = diagnoseVoicePlatform({ platform: platform!, arch: arch!, glibcVersion: glibc });
69
+ assert.equal(diagnosis.microphone.available, true, target);
70
+ const manifest = voiceRuntimeManifest(target);
71
+ const capture = manifest.packages.find((p) => p.name === NODE_CPAL.name);
72
+ assert.equal(capture?.version, "1.0.0", target);
73
+ assert.ok(capture!.requiredFiles.includes(`bin/${target}/index.node`), target);
74
+ // The capture version is part of the runtime identity, so a runtime
75
+ // installed with an older node-cpal is never mistaken for this one.
76
+ assert.match(manifest.id, /node-cpal-1\.0\.0/, target);
77
+ }
78
+ });
79
+
80
+ test("native prerequisites that are not probed are stated as limitations", () => {
81
+ const linux = diagnoseVoicePlatform({ platform: "linux", arch: "x64", glibcVersion: glibc });
82
+ assert.ok(linux.limitations.some((line) => /libasound\.so\.2/.test(line)));
83
+ const windows = diagnoseVoicePlatform({ platform: "win32", arch: "x64" });
84
+ assert.ok(windows.limitations.some((line) => /vcruntime140\.dll/.test(line) && /not checked/.test(line)));
85
+ });
86
+
87
+ test("Linux with a glibc older than 2.34 is unsupported with the version it needs", () => {
88
+ for (const glibcVersion of ["2.31", "2.33", "1.99"]) {
89
+ const diagnosis = diagnoseVoicePlatform({ platform: "linux", arch: "x64", glibcVersion });
90
+ assert.equal(diagnosis.support, "unsupported", glibcVersion);
91
+ assert.equal(diagnosis.microphone.available, false);
92
+ assert.match(diagnosis.transcription.reason ?? "", new RegExp(`glibc 2\\.34.*${glibcVersion.replace(".", "\\.")}`));
93
+ }
94
+ for (const glibcVersion of ["2.34", "2.39", "3.0"]) {
95
+ assert.equal(diagnoseVoicePlatform({ platform: "linux", arch: "arm64", glibcVersion }).support, "known", glibcVersion);
96
+ }
97
+ });
98
+
99
+ test("the pinned manifest uses exact versions, sibling platform packages and the fixed model revision", () => {
100
+ for (const target of VOICE_TARGETS) {
101
+ const manifest = voiceRuntimeManifest(target);
102
+ const names = manifest.packages.map((p) => p.name);
103
+ const platformName = `sherpa-onnx-${target.replace(/^win32-/, "win-")}`;
104
+ assert.deepEqual(names.slice(0, 2), [SHERPA_ONNX_NODE.name, platformName], target);
105
+ for (const pkg of manifest.packages) {
106
+ assert.match(pkg.version, /^\d+\.\d+\.\d+$/);
107
+ assert.match(pkg.integrity, /^sha512-[A-Za-z0-9+/]{86}==$/);
108
+ assert.equal(pkg.tarball, `https://registry.npmjs.org/${pkg.name}/-/${pkg.name}-${pkg.version}.tgz`);
109
+ assert.ok(pkg.size > 0 && pkg.unpackedSize > 0 && pkg.fileCount > 0);
110
+ }
111
+ assert.equal(manifest.packages[1]!.version, "1.13.8");
112
+ }
113
+ assert.equal(SHERPA_ONNX_NODE.version, "1.13.8");
114
+ assert.equal(NODE_CPAL.version, "1.0.0");
115
+ assert.equal(NODE_CPAL.integrity, "sha512-d71jIzKDjw1HepqaJQMPd+/k3ZLqYZWhLAv6a8aafIGnYoA1QWlShA5iB5CgijIJtQxq9PdQAk99JQKspqlnTA==");
116
+ assert.deepEqual([NODE_CPAL.size, NODE_CPAL.unpackedSize, NODE_CPAL.fileCount], [7723259, 20363834, 40]);
117
+ assert.equal(PARAKEET_MODEL_MANIFEST.revision, "2bda32ec70b097a55adaa07d9a7173915b43cc78");
118
+ assert.deepEqual(
119
+ PARAKEET_MODEL_MANIFEST.files.map((f) => [f.name, f.size, f.sha256]),
120
+ [
121
+ ["encoder.int8.onnx", 652184281, "acfc2b4456377e15d04f0243af540b7fe7c992f8d898d751cf134c3a55fd2247"],
122
+ ["decoder.int8.onnx", 11845275, "179e50c43d1a9de79c8a24149a2f9bac6eb5981823f2a2ed88d655b24248db4e"],
123
+ ["joiner.int8.onnx", 6355277, "3164c13fc2821009440d20fcb5fdc78bff28b4db2f8d0f0b329101719c0948b3"],
124
+ ["tokens.txt", 93939, "d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d"],
125
+ ],
126
+ );
127
+ });
128
+
129
+ test("the notices attribute the model, runtime and capture licenses and flag what is unresolved", () => {
130
+ const byComponent = new Map(VOICE_NOTICES.map((n) => [n.component, n]));
131
+ assert.equal(byComponent.get("parakeet-tdt-0.6b-v3")?.license, "CC-BY-4.0");
132
+ assert.match(byComponent.get("parakeet-tdt-0.6b-v3")?.attribution ?? "", /NVIDIA/);
133
+ assert.equal(byComponent.get("sherpa-onnx-node")?.license, "Apache-2.0");
134
+ assert.equal(byComponent.get("node-cpal")?.license, "ISC");
135
+ assert.ok(VOICE_NOTICES.some((n) => n.unresolved !== undefined));
136
+ });
137
+
138
+ test("readVoicePlatformProbe reports the running platform only when called", () => {
139
+ const probe = readVoicePlatformProbe();
140
+ assert.equal(probe.platform, process.platform);
141
+ assert.equal(probe.arch, process.arch);
142
+ });
143
+
144
+ test("importing the voice modules performs no filesystem or network IO", async (t) => {
145
+ // The module loader reads the sources through fs itself, so only calls made
146
+ // from a repository lib/ frame count as import-time IO.
147
+ const libFrame = new URL("../lib/", import.meta.url).href;
148
+ const calls: string[] = [];
149
+ const restore: Array<() => void> = [];
150
+ const trap = (target: Record<string, unknown>, label: string) => {
151
+ for (const key of Object.keys(target)) {
152
+ const original = target[key];
153
+ if (typeof original !== "function" || /^[A-Z]/.test(key)) continue;
154
+ target[key] = (...args: unknown[]) => {
155
+ if (new Error().stack?.includes(libFrame)) calls.push(`${label}.${key}`);
156
+ return (original as (...a: unknown[]) => unknown)(...args);
157
+ };
158
+ restore.push(() => { target[key] = original; });
159
+ }
160
+ };
161
+ trap(fs as unknown as Record<string, unknown>, "fs");
162
+ trap(fsPromises as unknown as Record<string, unknown>, "fs/promises");
163
+ const originalFetch = globalThis.fetch;
164
+ globalThis.fetch = (async () => { calls.push("fetch"); throw new Error("no network"); }) as typeof fetch;
165
+ syncBuiltinESMExports();
166
+ t.after(() => {
167
+ for (const undo of restore) undo();
168
+ globalThis.fetch = originalFetch;
169
+ syncBuiltinESMExports();
170
+ });
171
+ const fresh = `?import-io-${process.pid}-${Date.now()}`;
172
+ await import(`../lib/voice-artifacts.ts${fresh}`);
173
+ await import(`../lib/voice-platform.ts${fresh}`);
174
+ await import(`../lib/voice-install.ts${fresh}`);
175
+ assert.deepEqual(calls, []);
176
+ });