@stacksjs/ai 0.75.374 → 0.75.378

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.
package/README.md CHANGED
@@ -96,3 +96,21 @@ For casual chit-chat with others using this package:
96
96
  The MIT License (MIT). Please see [LICENSE](https://github.com/stacksjs/stacks/tree/main/LICENSE.md) for more information.
97
97
 
98
98
  Made with 💙
99
+
100
+ ## Verified local speech models
101
+
102
+ Install the pinned multilingual Whisper base model before starting a private CPU speech server:
103
+
104
+ ```ts
105
+ import { ensureLocalSpeechModel } from '@stacksjs/ai/local-speech-model'
106
+
107
+ const modelPath = await ensureLocalSpeechModel({ directory: 'storage/speech/models' })
108
+ ```
109
+
110
+ The installer checks the exact byte length and SHA-256, reuses a verified file,
111
+ and publishes new bytes atomically. Interrupted downloads resume through a
112
+ validated HTTP Range response. An origin that ignores Range starts a clean
113
+ response instead. Four attempts, each with a three-minute transfer timeout, are
114
+ the defaults; `attempts`, `timeoutMs` and an abort `signal` are configurable.
115
+ A failed install preserves the previous target and removes only its own partial
116
+ file. Custom trusted model metadata can be passed through `model`.
@@ -0,0 +1,17 @@
1
+ /** Publish a bounded, checksum-verified model atomically; callers own the models directory. */
2
+ export declare function ensureLocalSpeechModel(options: LocalSpeechModelOptions): Promise<string>;
3
+ /** Immutable multilingual Whisper base model used by the private CPU runtime. */
4
+ export declare const MULTILINGUAL_BASE_SPEECH_MODEL: Readonly<LocalSpeechModel>;
5
+ export declare interface LocalSpeechModel {
6
+ filename: string
7
+ url: string
8
+ sha256: string
9
+ bytes: number
10
+ }
11
+ export declare interface LocalSpeechModelOptions {
12
+ directory: string
13
+ model?: Readonly<LocalSpeechModel>
14
+ timeoutMs?: number
15
+ attempts?: number
16
+ signal?: AbortSignal
17
+ }
@@ -0,0 +1 @@
1
+ import{createHash,randomUUID}from"node:crypto";import{createReadStream}from"node:fs";import{mkdir,open,rename,rm,stat}from"node:fs/promises";import{join}from"node:path";export const MULTILINGUAL_BASE_SPEECH_MODEL=Object.freeze({filename:"ggml-base.bin",url:"https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base.bin",sha256:"60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe",bytes:147951465});async function verified(path,model){try{if((await stat(path)).size!==model.bytes)return!1}catch(error){if(error.code==="ENOENT")return!1;throw error}const hash=createHash("sha256");for await(const chunk of createReadStream(path))hash.update(chunk);return hash.digest("hex")===model.sha256.toLowerCase()}export async function ensureLocalSpeechModel(options){const model=options.model??MULTILINGUAL_BASE_SPEECH_MODEL,timeoutMs=options.timeoutMs??180000,attempts=options.attempts??4;if(!/^[a-z0-9][a-z0-9._-]*$/i.test(model.filename)||!/^[a-f0-9]{64}$/i.test(model.sha256)||!Number.isSafeInteger(model.bytes)||model.bytes<=0)throw TypeError("A speech model needs a safe filename, SHA-256 and positive byte length");if(!Number.isSafeInteger(timeoutMs)||timeoutMs<=0||timeoutMs>2147483647)throw RangeError("timeoutMs must fit the platform timer range");if(!Number.isSafeInteger(attempts)||attempts<1||attempts>10)throw RangeError("attempts must be between one and ten");options.signal?.throwIfAborted();await mkdir(options.directory,{recursive:!0,mode:448});const target=join(options.directory,model.filename);if(await verified(target,model))return target;const partial=join(options.directory,`.${model.filename}.${randomUUID()}.partial`);let failure;try{for(let attempt=0;attempt<attempts;attempt++){options.signal?.throwIfAborted();let reset=!1;try{let received=await stat(partial).then((file)=>file.size,(error)=>{if(error.code==="ENOENT")return 0;throw error});if(received===model.bytes){if(await verified(partial,model)){await rename(partial,target);return target}await rm(partial,{force:!0});received=0}const signal=options.signal?AbortSignal.any([options.signal,AbortSignal.timeout(timeoutMs)]):AbortSignal.timeout(timeoutMs),headers={"Accept-Encoding":"identity"};if(received)headers.Range=`bytes=${received}-`;const response=await fetch(model.url,{signal,headers});if(![200,206].includes(response.status)||!response.body){await response.body?.cancel();throw Error(`Speech model download failed (HTTP ${response.status})`)}if(response.status===206){const range=/^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get("content-range")||"");if(!range||Number(range[1])!==received||Number(range[3])!==model.bytes||Number(range[2])<received||Number(range[2])>=model.bytes){await response.body.cancel();throw Error("Speech model response did not match the requested byte range")}}else received=0;const file=await open(partial,response.status===206?"a":"w",384),reader=response.body.getReader();try{for(;;){const{value,done}=await reader.read();if(done)break;received+=value.length;if(received>model.bytes){reset=!0;throw Error("Speech model exceeded its pinned byte length")}await file.writeFile(value)}}finally{await reader.cancel().catch(()=>{});await file.close()}if(received!==model.bytes)throw Error("Speech model response ended before its pinned byte length");if(!await verified(partial,model)){reset=!0;throw Error("Speech model did not match its pinned SHA-256")}await rename(partial,target);return target}catch(error){failure=error;options.signal?.throwIfAborted();if(reset)await rm(partial,{force:!0});if(attempt+1<attempts)await Bun.sleep(Math.min(250*2**attempt,2000))}}throw Error(`Speech model could not be verified after ${attempts} download attempts`,{cause:failure})}finally{await rm(partial,{force:!0})}}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/ai",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.374",
5
+ "version": "0.75.378",
6
6
  "description": "Stacks Artificial Intelligence.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -65,11 +65,11 @@
65
65
  "prepublishOnly": "bun run build"
66
66
  },
67
67
  "dependencies": {
68
- "@stacksjs/cli": "0.75.374",
69
- "@stacksjs/config": "0.75.374",
70
- "@stacksjs/router": "0.75.374",
71
- "@stacksjs/storage": "0.75.374",
72
- "@stacksjs/ts-cloud": "^0.16.69"
68
+ "@stacksjs/cli": "0.75.378",
69
+ "@stacksjs/config": "0.75.378",
70
+ "@stacksjs/router": "0.75.378",
71
+ "@stacksjs/storage": "0.75.378",
72
+ "@stacksjs/ts-cloud": "^0.16.89"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@anthropic-ai/claude-agent-sdk": "^0.2.79"