@ailoud/providers 1.0.0-dev.1
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/LICENSE +224 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/audio/ffmpeg.d.ts +12 -0
- package/dist/audio/ffmpeg.js +81 -0
- package/dist/diarize/sherpaDiarizer.d.ts +26 -0
- package/dist/diarize/sherpaDiarizer.js +64 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +19 -0
- package/dist/llm/anthropic.d.ts +36 -0
- package/dist/llm/anthropic.js +92 -0
- package/dist/llm/claudeCli.d.ts +32 -0
- package/dist/llm/claudeCli.js +63 -0
- package/dist/llm/llamaCpp.d.ts +38 -0
- package/dist/llm/llamaCpp.js +90 -0
- package/dist/llm/models.d.ts +23 -0
- package/dist/llm/models.js +97 -0
- package/dist/llm/openAiCompatible.d.ts +35 -0
- package/dist/llm/openAiCompatible.js +84 -0
- package/dist/process/pager.d.ts +36 -0
- package/dist/process/pager.js +69 -0
- package/dist/process/run.d.ts +35 -0
- package/dist/process/run.js +101 -0
- package/dist/provision/download.d.ts +19 -0
- package/dist/provision/download.js +80 -0
- package/dist/provision/llamaInstall.d.ts +38 -0
- package/dist/provision/llamaInstall.js +75 -0
- package/dist/provision/packageManager.d.ts +50 -0
- package/dist/provision/packageManager.js +66 -0
- package/dist/provision/sherpaInstall.d.ts +34 -0
- package/dist/provision/sherpaInstall.js +78 -0
- package/dist/provision/whisperInstall.d.ts +61 -0
- package/dist/provision/whisperInstall.js +89 -0
- package/dist/store/sqliteStore.d.ts +64 -0
- package/dist/store/sqliteStore.js +433 -0
- package/dist/stt/whisperCpp.d.ts +50 -0
- package/dist/stt/whisperCpp.js +116 -0
- package/dist/system/nodeFs.d.ts +14 -0
- package/dist/system/nodeFs.js +80 -0
- package/dist/system/systemClock.d.ts +20 -0
- package/dist/system/systemClock.js +55 -0
- package/dist/vad/whisperVad.d.ts +14 -0
- package/dist/vad/whisperVad.js +58 -0
- package/package.json +48 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Output longer than this goes through a pager, when there is a terminal to
|
|
4
|
+
* page on. Below it, paging would put a full-screen program in front of
|
|
5
|
+
* something that already fitted.
|
|
6
|
+
*/
|
|
7
|
+
export const PAGER_LINE_THRESHOLD = 30;
|
|
8
|
+
/**
|
|
9
|
+
* Whether this output should be paged at all.
|
|
10
|
+
*
|
|
11
|
+
* Never when stdout is not a terminal. A redirect or a pipe wants the bytes,
|
|
12
|
+
* and handing them to `less` would either hang waiting for a keypress nobody
|
|
13
|
+
* can give or write escape sequences into a file. This is the same
|
|
14
|
+
* distinction `show` already draws between the frame and the payload.
|
|
15
|
+
*
|
|
16
|
+
* Also never when PAGER is set to the empty string, which is the
|
|
17
|
+
* conventional way to say "do not page".
|
|
18
|
+
*/
|
|
19
|
+
export function shouldPage(text, isTTY, env = process.env) {
|
|
20
|
+
if (!isTTY)
|
|
21
|
+
return false;
|
|
22
|
+
if (env['PAGER'] === '')
|
|
23
|
+
return false;
|
|
24
|
+
return text.split('\n').length > PAGER_LINE_THRESHOLD;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Shows `text` in the user's pager, resolving when they leave it.
|
|
28
|
+
*
|
|
29
|
+
* Uses the system pager rather than scrolling in-process. Up, down, page
|
|
30
|
+
* keys, search, and quitting on `q` all arrive already implemented and
|
|
31
|
+
* already behaving the way the user's other tools do -- git, man and less
|
|
32
|
+
* itself -- which is what "native" means here. A hand-rolled scroller would
|
|
33
|
+
* be a worse version of `less` that nobody had configured.
|
|
34
|
+
*
|
|
35
|
+
* Defaults to `less -R`: -R lets colour through rather than printing escape
|
|
36
|
+
* sequences literally. LESS is set only if the user has not, so their own
|
|
37
|
+
* configuration keeps winning.
|
|
38
|
+
*
|
|
39
|
+
* Falls back to writing the text out plainly if the pager cannot be started
|
|
40
|
+
* at all -- a machine without `less` should still be able to read a
|
|
41
|
+
* transcript.
|
|
42
|
+
*/
|
|
43
|
+
export async function page(text, write, env = process.env) {
|
|
44
|
+
const command = env['PAGER'] ?? 'less';
|
|
45
|
+
const [program, ...args] = command.split(/\s+/).filter((part) => part !== '');
|
|
46
|
+
if (program === undefined) {
|
|
47
|
+
write(text);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await new Promise((resolve) => {
|
|
51
|
+
const child = spawn(program, args, {
|
|
52
|
+
// The pager owns the terminal while it runs: it needs the real stdout
|
|
53
|
+
// to draw on and the real stdin to read keys from. Only its input is a
|
|
54
|
+
// pipe, because that is what we are filling.
|
|
55
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
56
|
+
env: { ...env, ...(env['LESS'] === undefined ? { LESS: '-R' } : {}) },
|
|
57
|
+
});
|
|
58
|
+
child.on('error', () => {
|
|
59
|
+
// No pager on this machine. Printing beats failing.
|
|
60
|
+
write(text);
|
|
61
|
+
resolve();
|
|
62
|
+
});
|
|
63
|
+
// EPIPE, which is what quitting the pager early looks like from here. It
|
|
64
|
+
// is the user saying "I have read enough", not a failure.
|
|
65
|
+
child.stdin.on('error', () => undefined);
|
|
66
|
+
child.on('close', () => resolve());
|
|
67
|
+
child.stdin.end(text);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface RunResult {
|
|
2
|
+
readonly code: number;
|
|
3
|
+
readonly stdout: string;
|
|
4
|
+
readonly stderr: string;
|
|
5
|
+
}
|
|
6
|
+
export interface RunOptions {
|
|
7
|
+
readonly timeoutMs?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Written to the child's stdin, which is then closed.
|
|
10
|
+
*
|
|
11
|
+
* The reason this exists: a prompt carrying a transcript does not fit in an
|
|
12
|
+
* argument. ARG_MAX is about a megabyte on macOS, less once the environment
|
|
13
|
+
* is counted, and a few hours of speech passes it -- the spawn then fails
|
|
14
|
+
* with E2BIG, which is a failure the user can do nothing about.
|
|
15
|
+
*/
|
|
16
|
+
readonly stdin?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function run(command: string, args: readonly string[], options?: RunOptions): Promise<RunResult>;
|
|
19
|
+
/**
|
|
20
|
+
* Runs a command with the parent's stdio, so anything it prints -- including
|
|
21
|
+
* a sudo password prompt -- reaches the real terminal. run() above buffers
|
|
22
|
+
* output instead, which is exactly wrong here: the prompt would vanish into
|
|
23
|
+
* a buffer and the user would stare at a hung terminal.
|
|
24
|
+
*
|
|
25
|
+
* Returns the exit code rather than throwing on a non-zero one: a failed
|
|
26
|
+
* install is a reportable outcome for provisioning, not an exception --
|
|
27
|
+
* one failed action must not abandon the rest of the plan.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately has no timeout, unlike run(): a hard bound would kill a
|
|
30
|
+
* legitimate wait at a password prompt. The consequence is that with
|
|
31
|
+
* nothing real attached to stdin (no TTY), this can wait indefinitely, so
|
|
32
|
+
* callers must not invoke it non-interactively -- a later task enforces
|
|
33
|
+
* that at the call site.
|
|
34
|
+
*/
|
|
35
|
+
export declare function runInteractive(command: string, args: readonly string[]): Promise<number>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:os';
|
|
3
|
+
import { EnvironmentError, FailureError } from '@ailoud/core';
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
5
|
+
/**
|
|
6
|
+
* `code` is null exactly when the child died from a signal rather than
|
|
7
|
+
* exiting on its own -- e.g. a user pressing Ctrl-C at a sudo password
|
|
8
|
+
* prompt. Collapsing that to a fixed sentinel like -1 or 1 makes "you
|
|
9
|
+
* cancelled" indistinguishable from "the command genuinely failed with
|
|
10
|
+
* that exit code", which is the wrong thing to report to someone trying to
|
|
11
|
+
* understand what just happened. 128 + signal number is the same mapping
|
|
12
|
+
* shells use (`$?` after a Ctrl-C'd command is 130, i.e. 128 + SIGINT's 2),
|
|
13
|
+
* so callers get a value that is both unambiguous and already familiar.
|
|
14
|
+
*/
|
|
15
|
+
function exitCodeForClose(code, signal) {
|
|
16
|
+
if (code !== null)
|
|
17
|
+
return code;
|
|
18
|
+
if (signal !== null)
|
|
19
|
+
return 128 + (constants.signals[signal] ?? 0);
|
|
20
|
+
return -1;
|
|
21
|
+
}
|
|
22
|
+
export function run(command, args, options = {}) {
|
|
23
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
// shell: false is the default and must stay that way: paths reaching this
|
|
26
|
+
// function come from user input, and a shell would interpret them.
|
|
27
|
+
const child = spawn(command, [...args], { shell: false });
|
|
28
|
+
if (options.stdin !== undefined) {
|
|
29
|
+
// EPIPE if the child exits before reading it all -- which is a normal
|
|
30
|
+
// way for a child to behave, not an error worth failing the run over.
|
|
31
|
+
child.stdin.on('error', () => { });
|
|
32
|
+
child.stdin.end(options.stdin, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
let stdout = '';
|
|
35
|
+
let stderr = '';
|
|
36
|
+
let timedOut = false;
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
timedOut = true;
|
|
39
|
+
child.kill('SIGKILL');
|
|
40
|
+
}, timeoutMs);
|
|
41
|
+
child.stdout.on('data', (chunk) => {
|
|
42
|
+
stdout += chunk.toString('utf8');
|
|
43
|
+
});
|
|
44
|
+
child.stderr.on('data', (chunk) => {
|
|
45
|
+
stderr += chunk.toString('utf8');
|
|
46
|
+
});
|
|
47
|
+
child.on('error', (error) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
if (error.code === 'ENOENT') {
|
|
50
|
+
reject(new EnvironmentError(`${command} was not found on PATH. Install it, or set its path in the ailoud config; run "ailoud doctor" for details.`));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
reject(error);
|
|
54
|
+
});
|
|
55
|
+
child.on('close', (code, signal) => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
if (timedOut) {
|
|
58
|
+
// A timeout is not "the machine is not set up": the binary was
|
|
59
|
+
// found and started fine, it just did not finish in the time this
|
|
60
|
+
// one call allowed. That is a failure of this particular run, not
|
|
61
|
+
// of the environment, so it is a FailureError (exit 1) rather than
|
|
62
|
+
// an EnvironmentError (exit 3) -- there is no "fix" for doctor to
|
|
63
|
+
// report, only a run that needs to be retried, given more time, or
|
|
64
|
+
// investigated as its own problem.
|
|
65
|
+
reject(new FailureError(`${command} timed out after ${timeoutMs} ms and was killed`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
resolve({ code: exitCodeForClose(code, signal), stdout, stderr });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Runs a command with the parent's stdio, so anything it prints -- including
|
|
74
|
+
* a sudo password prompt -- reaches the real terminal. run() above buffers
|
|
75
|
+
* output instead, which is exactly wrong here: the prompt would vanish into
|
|
76
|
+
* a buffer and the user would stare at a hung terminal.
|
|
77
|
+
*
|
|
78
|
+
* Returns the exit code rather than throwing on a non-zero one: a failed
|
|
79
|
+
* install is a reportable outcome for provisioning, not an exception --
|
|
80
|
+
* one failed action must not abandon the rest of the plan.
|
|
81
|
+
*
|
|
82
|
+
* Deliberately has no timeout, unlike run(): a hard bound would kill a
|
|
83
|
+
* legitimate wait at a password prompt. The consequence is that with
|
|
84
|
+
* nothing real attached to stdin (no TTY), this can wait indefinitely, so
|
|
85
|
+
* callers must not invoke it non-interactively -- a later task enforces
|
|
86
|
+
* that at the call site.
|
|
87
|
+
*/
|
|
88
|
+
export function runInteractive(command, args) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
// shell: false for the same reason as run() above: these paths come
|
|
91
|
+
// from user input, and a shell would interpret them.
|
|
92
|
+
const child = spawn(command, [...args], { shell: false, stdio: 'inherit' });
|
|
93
|
+
child.on('error', (error) => {
|
|
94
|
+
reject(new EnvironmentError(`could not run "${command}": ${error instanceof Error ? error.message : String(error)}`));
|
|
95
|
+
});
|
|
96
|
+
// See exitCodeForClose above run(): the same code-vs-signal ambiguity
|
|
97
|
+
// applies here, and matters more, since Ctrl-C at a sudo prompt is the
|
|
98
|
+
// expected way a user cancels this specific function.
|
|
99
|
+
child.on('close', (code, signal) => resolve(exitCodeForClose(code, signal)));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface DownloadOptions {
|
|
2
|
+
readonly onProgress?: (received: number, total: number | null) => void;
|
|
3
|
+
readonly fetchImpl?: typeof fetch;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Downloads `url` to `targetPath`, atomically.
|
|
7
|
+
*
|
|
8
|
+
* The body streams into `<targetPath>.part` and is renamed onto the target
|
|
9
|
+
* only once it is complete. A 465 MB model interrupted at 300 MB must never
|
|
10
|
+
* leave a file that `doctor` reports as present -- that failure would
|
|
11
|
+
* resurface far away, as whisper failing to load a model, and be miserable
|
|
12
|
+
* to trace back to a dropped connection.
|
|
13
|
+
*
|
|
14
|
+
* Completeness is judged against the response's own Content-Length, not
|
|
15
|
+
* against a size compiled into ailoud: an upstream reupload should not be able
|
|
16
|
+
* to make installation fail. When the server advertises no length, the
|
|
17
|
+
* stream ending cleanly is all there is to go on, and that is accepted.
|
|
18
|
+
*/
|
|
19
|
+
export declare function downloadFile(url: string, targetPath: string, options?: DownloadOptions): Promise<void>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { access, mkdir, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { FailureError } from '@ailoud/core';
|
|
8
|
+
async function exists(path) {
|
|
9
|
+
try {
|
|
10
|
+
await access(path, constants.F_OK);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Downloads `url` to `targetPath`, atomically.
|
|
19
|
+
*
|
|
20
|
+
* The body streams into `<targetPath>.part` and is renamed onto the target
|
|
21
|
+
* only once it is complete. A 465 MB model interrupted at 300 MB must never
|
|
22
|
+
* leave a file that `doctor` reports as present -- that failure would
|
|
23
|
+
* resurface far away, as whisper failing to load a model, and be miserable
|
|
24
|
+
* to trace back to a dropped connection.
|
|
25
|
+
*
|
|
26
|
+
* Completeness is judged against the response's own Content-Length, not
|
|
27
|
+
* against a size compiled into ailoud: an upstream reupload should not be able
|
|
28
|
+
* to make installation fail. When the server advertises no length, the
|
|
29
|
+
* stream ending cleanly is all there is to go on, and that is accepted.
|
|
30
|
+
*/
|
|
31
|
+
export async function downloadFile(url, targetPath, options = {}) {
|
|
32
|
+
if (await exists(targetPath))
|
|
33
|
+
return;
|
|
34
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
35
|
+
const partPath = `${targetPath}.part`;
|
|
36
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
37
|
+
const response = await fetchImpl(url);
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new FailureError(`download failed for ${url}: HTTP ${response.status}`);
|
|
40
|
+
}
|
|
41
|
+
if (response.body === null) {
|
|
42
|
+
throw new FailureError(`download failed for ${url}: the response had no body`);
|
|
43
|
+
}
|
|
44
|
+
const header = response.headers.get('content-length');
|
|
45
|
+
// Number('') is 0, not NaN, so an empty header must be rejected by hand
|
|
46
|
+
// before the numeric check below; a header that is otherwise not a
|
|
47
|
+
// finite, non-negative number (non-numeric or negative) carries no usable
|
|
48
|
+
// length either. Treat all of these the same as "no length advertised"
|
|
49
|
+
// rather than as a total of 0, which would fail every download that
|
|
50
|
+
// completed fine just because the server sent junk.
|
|
51
|
+
const parsedHeader = header === null || header.trim() === '' ? NaN : Number(header);
|
|
52
|
+
const total = Number.isFinite(parsedHeader) && parsedHeader >= 0 ? parsedHeader : null;
|
|
53
|
+
let received = 0;
|
|
54
|
+
try {
|
|
55
|
+
const source = Readable.fromWeb(response.body);
|
|
56
|
+
source.on('data', (chunk) => {
|
|
57
|
+
received += chunk.length;
|
|
58
|
+
options.onProgress?.(received, total);
|
|
59
|
+
});
|
|
60
|
+
await pipeline(source, createWriteStream(partPath));
|
|
61
|
+
if (total !== null && received !== total) {
|
|
62
|
+
const direction = received < total ? 'incomplete' : 'longer than advertised';
|
|
63
|
+
throw new FailureError(`download ${direction} for ${url}: expected ${total} bytes, received ${received}`);
|
|
64
|
+
}
|
|
65
|
+
await rename(partPath, targetPath);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
try {
|
|
69
|
+
await rm(partPath, { force: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Cleanup failing (e.g. EACCES, EBUSY, a full disk on unlink) must not
|
|
73
|
+
// replace the original error -- that original is the whole reason
|
|
74
|
+
// this function exists: the network drop or truncation that explains
|
|
75
|
+
// why the model is missing. A user seeing an unlink error instead
|
|
76
|
+
// would have no idea their download actually died.
|
|
77
|
+
}
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The llama.cpp build ailoud installs. Pinned, never "latest", for the same
|
|
3
|
+
* reason the whisper.cpp and sherpa-onnx pins exist: two people running the
|
|
4
|
+
* same `ailoud setup` a week apart must get the same binary, and an upstream
|
|
5
|
+
* change must not be able to break installation for everyone at once.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LLAMA_VERSION = "b10712";
|
|
8
|
+
/**
|
|
9
|
+
* The prebuilt archive for this platform and CPU.
|
|
10
|
+
*
|
|
11
|
+
* Every target ailoud supports is published here -- macOS on both
|
|
12
|
+
* architectures, Ubuntu on both -- which is a happier position than the
|
|
13
|
+
* diarizer, where generic Linux arm64 simply does not exist.
|
|
14
|
+
*/
|
|
15
|
+
export declare function llamaTarballUrl(platform: NodeJS.Platform, arch: string): string;
|
|
16
|
+
export interface InstallLlamaOptions {
|
|
17
|
+
readonly platform: NodeJS.Platform;
|
|
18
|
+
readonly arch: string;
|
|
19
|
+
readonly dataDir: string;
|
|
20
|
+
readonly interactive: boolean;
|
|
21
|
+
readonly useBrew: boolean;
|
|
22
|
+
readonly onProgress?: (received: number, total: number | null) => void;
|
|
23
|
+
}
|
|
24
|
+
export interface InstallLlamaResult {
|
|
25
|
+
/** Absolute path to llama-cli, or null when brew put it on PATH. */
|
|
26
|
+
readonly binary: string | null;
|
|
27
|
+
/** True when the install was skipped because there was no terminal for it. */
|
|
28
|
+
readonly skipped: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Installs llama.cpp and reports where its binary ended up.
|
|
32
|
+
*
|
|
33
|
+
* `binary: null` after a brew install, for the same reason the whisper
|
|
34
|
+
* installer returns null there: brew puts llama-cli on PATH where the config
|
|
35
|
+
* default already finds it, and writing an absolute Cellar path into the
|
|
36
|
+
* user's config would break on their next `brew upgrade`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function installLlama(options: InstallLlamaOptions): Promise<InstallLlamaResult>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { FailureError } from '@ailoud/core';
|
|
4
|
+
import { run, runInteractive } from '../process/run.js';
|
|
5
|
+
import { downloadFile } from './download.js';
|
|
6
|
+
/**
|
|
7
|
+
* The llama.cpp build ailoud installs. Pinned, never "latest", for the same
|
|
8
|
+
* reason the whisper.cpp and sherpa-onnx pins exist: two people running the
|
|
9
|
+
* same `ailoud setup` a week apart must get the same binary, and an upstream
|
|
10
|
+
* change must not be able to break installation for everyone at once.
|
|
11
|
+
*/
|
|
12
|
+
export const LLAMA_VERSION = 'b10712';
|
|
13
|
+
const RELEASES = 'https://github.com/ggml-org/llama.cpp/releases/download';
|
|
14
|
+
/**
|
|
15
|
+
* The prebuilt archive for this platform and CPU.
|
|
16
|
+
*
|
|
17
|
+
* Every target ailoud supports is published here -- macOS on both
|
|
18
|
+
* architectures, Ubuntu on both -- which is a happier position than the
|
|
19
|
+
* diarizer, where generic Linux arm64 simply does not exist.
|
|
20
|
+
*/
|
|
21
|
+
export function llamaTarballUrl(platform, arch) {
|
|
22
|
+
const target = platform === 'darwin' && (arch === 'arm64' || arch === 'x64')
|
|
23
|
+
? `macos-${arch}`
|
|
24
|
+
: platform === 'linux' && (arch === 'arm64' || arch === 'x64')
|
|
25
|
+
? `ubuntu-${arch}`
|
|
26
|
+
: null;
|
|
27
|
+
if (target === null) {
|
|
28
|
+
throw new FailureError(`no prebuilt llama.cpp is published for ${platform} ${arch}; build it from source and set ` +
|
|
29
|
+
'"llm.llamaCpp.binary" to the resulting llama-cli, or set "llm.provider" to a hosted ' +
|
|
30
|
+
'model instead');
|
|
31
|
+
}
|
|
32
|
+
return `${RELEASES}/${LLAMA_VERSION}/llama-${LLAMA_VERSION}-bin-${target}.tar.gz`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Installs llama.cpp and reports where its binary ended up.
|
|
36
|
+
*
|
|
37
|
+
* `binary: null` after a brew install, for the same reason the whisper
|
|
38
|
+
* installer returns null there: brew puts llama-cli on PATH where the config
|
|
39
|
+
* default already finds it, and writing an absolute Cellar path into the
|
|
40
|
+
* user's config would break on their next `brew upgrade`.
|
|
41
|
+
*/
|
|
42
|
+
export async function installLlama(options) {
|
|
43
|
+
const { platform, arch, dataDir } = options;
|
|
44
|
+
if (options.useBrew) {
|
|
45
|
+
if (!options.interactive) {
|
|
46
|
+
// brew can stop to ask about Xcode tools or directory ownership, and
|
|
47
|
+
// there is no terminal here to answer on. runInteractive has no timeout
|
|
48
|
+
// by design, so this would hang rather than fail.
|
|
49
|
+
return { binary: null, skipped: true };
|
|
50
|
+
}
|
|
51
|
+
const code = await runInteractive('brew', ['install', 'llama.cpp']);
|
|
52
|
+
if (code !== 0)
|
|
53
|
+
throw new FailureError(`"brew install llama.cpp" exited with code ${code}`);
|
|
54
|
+
return { binary: null, skipped: false };
|
|
55
|
+
}
|
|
56
|
+
const url = llamaTarballUrl(platform, arch);
|
|
57
|
+
const root = join(dataDir, 'llama', LLAMA_VERSION);
|
|
58
|
+
const archive = join(dataDir, 'llama', `llama-${LLAMA_VERSION}-${arch}.tar.gz`);
|
|
59
|
+
await mkdir(root, { recursive: true });
|
|
60
|
+
await downloadFile(url, archive, {
|
|
61
|
+
...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }),
|
|
62
|
+
});
|
|
63
|
+
// .tar.gz here, unlike sherpa's .tar.bz2: -xzf, not -xjf.
|
|
64
|
+
const extract = await run('tar', ['-xzf', archive, '-C', root, '--strip-components=1']);
|
|
65
|
+
if (extract.code !== 0) {
|
|
66
|
+
throw new FailureError(`extracting ${archive} failed: ${extract.stderr.trim()}`);
|
|
67
|
+
}
|
|
68
|
+
await rm(archive, { force: true });
|
|
69
|
+
// Verified against the real archive rather than assumed: it holds a single
|
|
70
|
+
// top-level directory with llama-cli sitting directly inside it, beside the
|
|
71
|
+
// shared libraries it loads. So --strip-components=1 lands the binary at
|
|
72
|
+
// the root, and the tree must stay intact -- moving the binary out would
|
|
73
|
+
// leave its libraries behind.
|
|
74
|
+
return { binary: join(root, 'llama-cli'), skipped: false };
|
|
75
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type PackageManager = 'brew' | 'apt-get';
|
|
2
|
+
export interface InstallCommand {
|
|
3
|
+
readonly command: string;
|
|
4
|
+
readonly args: readonly string[];
|
|
5
|
+
readonly needsSudo: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* A non-zero exit from this step is reported but does not abandon the
|
|
8
|
+
* steps after it. `apt-get update` is the case this exists for: refreshing
|
|
9
|
+
* the package lists can fail on one unreachable third-party repository
|
|
10
|
+
* while the install that follows still succeeds from the repositories that
|
|
11
|
+
* did refresh, so treating it as fatal would block an install that works.
|
|
12
|
+
*/
|
|
13
|
+
readonly optional?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** The exact command line, as it must appear in the consent plan and in logs. */
|
|
16
|
+
export declare function formatInstallCommand(command: InstallCommand): string;
|
|
17
|
+
/** Injected in tests; in production it asks the real filesystem via the real PATH. */
|
|
18
|
+
export type BinaryProbe = (name: string) => Promise<boolean>;
|
|
19
|
+
/**
|
|
20
|
+
* Which package manager ailoud may drive on this platform, or null.
|
|
21
|
+
*
|
|
22
|
+
* Only the managers this project has verified are listed: brew on macOS,
|
|
23
|
+
* apt-get on Debian and Ubuntu. Adding dnf or pacman is a one-line change
|
|
24
|
+
* plus a verification pass on that distribution -- guessing at their flags
|
|
25
|
+
* from memory is how an installer breaks somebody's machine.
|
|
26
|
+
*/
|
|
27
|
+
export declare function detectPackageManager(platform: NodeJS.Platform, probe?: BinaryProbe): Promise<PackageManager | null>;
|
|
28
|
+
/**
|
|
29
|
+
* The exact commands ailoud would run to install ffmpeg, in order.
|
|
30
|
+
*
|
|
31
|
+
* A list rather than a single command because apt needs two: on a
|
|
32
|
+
* container-fresh Debian or Ubuntu `/var/lib/apt/lists` is empty, and
|
|
33
|
+
* `apt-get install` there exits 100 with nothing a user could act on. The
|
|
34
|
+
* refresh is part of the install, and being a separate command means it
|
|
35
|
+
* also appears verbatim in the consent plan -- ailoud never runs a command
|
|
36
|
+
* the user did not read first.
|
|
37
|
+
*
|
|
38
|
+
* brew deliberately refuses to run under sudo, and apt-get requires it.
|
|
39
|
+
* `needsSudo` is surfaced so the caller can warn before a password prompt
|
|
40
|
+
* appears, and can skip the action entirely when there is no terminal to
|
|
41
|
+
* type a password into.
|
|
42
|
+
*/
|
|
43
|
+
export declare function ffmpegInstallCommands(manager: PackageManager): readonly InstallCommand[];
|
|
44
|
+
/**
|
|
45
|
+
* The exact commands ailoud would run to install whisper.cpp through a package
|
|
46
|
+
* manager, which is only ever brew: there is no apt package for whisper.cpp,
|
|
47
|
+
* so on Linux ailoud downloads the project's own prebuilt release tarball
|
|
48
|
+
* instead and this list is empty.
|
|
49
|
+
*/
|
|
50
|
+
export declare function whisperInstallCommands(manager: PackageManager): readonly InstallCommand[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { run } from '../process/run.js';
|
|
2
|
+
/** The exact command line, as it must appear in the consent plan and in logs. */
|
|
3
|
+
export function formatInstallCommand(command) {
|
|
4
|
+
return [command.command, ...command.args].join(' ');
|
|
5
|
+
}
|
|
6
|
+
const realProbe = async (name) => {
|
|
7
|
+
try {
|
|
8
|
+
const result = await run(name, ['--version'], { timeoutMs: 10_000 });
|
|
9
|
+
return result.code === 0;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Which package manager ailoud may drive on this platform, or null.
|
|
17
|
+
*
|
|
18
|
+
* Only the managers this project has verified are listed: brew on macOS,
|
|
19
|
+
* apt-get on Debian and Ubuntu. Adding dnf or pacman is a one-line change
|
|
20
|
+
* plus a verification pass on that distribution -- guessing at their flags
|
|
21
|
+
* from memory is how an installer breaks somebody's machine.
|
|
22
|
+
*/
|
|
23
|
+
export async function detectPackageManager(platform, probe = realProbe) {
|
|
24
|
+
const candidates = platform === 'darwin' ? ['brew'] : platform === 'linux' ? ['apt-get'] : [];
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
if (await probe(candidate))
|
|
27
|
+
return candidate;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The exact commands ailoud would run to install ffmpeg, in order.
|
|
33
|
+
*
|
|
34
|
+
* A list rather than a single command because apt needs two: on a
|
|
35
|
+
* container-fresh Debian or Ubuntu `/var/lib/apt/lists` is empty, and
|
|
36
|
+
* `apt-get install` there exits 100 with nothing a user could act on. The
|
|
37
|
+
* refresh is part of the install, and being a separate command means it
|
|
38
|
+
* also appears verbatim in the consent plan -- ailoud never runs a command
|
|
39
|
+
* the user did not read first.
|
|
40
|
+
*
|
|
41
|
+
* brew deliberately refuses to run under sudo, and apt-get requires it.
|
|
42
|
+
* `needsSudo` is surfaced so the caller can warn before a password prompt
|
|
43
|
+
* appears, and can skip the action entirely when there is no terminal to
|
|
44
|
+
* type a password into.
|
|
45
|
+
*/
|
|
46
|
+
export function ffmpegInstallCommands(manager) {
|
|
47
|
+
if (manager === 'brew') {
|
|
48
|
+
return [{ command: 'brew', args: ['install', 'ffmpeg'], needsSudo: false }];
|
|
49
|
+
}
|
|
50
|
+
return [
|
|
51
|
+
{ command: 'sudo', args: ['apt-get', 'update'], needsSudo: true, optional: true },
|
|
52
|
+
{ command: 'sudo', args: ['apt-get', 'install', '-y', 'ffmpeg'], needsSudo: true },
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The exact commands ailoud would run to install whisper.cpp through a package
|
|
57
|
+
* manager, which is only ever brew: there is no apt package for whisper.cpp,
|
|
58
|
+
* so on Linux ailoud downloads the project's own prebuilt release tarball
|
|
59
|
+
* instead and this list is empty.
|
|
60
|
+
*/
|
|
61
|
+
export function whisperInstallCommands(manager) {
|
|
62
|
+
if (manager === 'brew') {
|
|
63
|
+
return [{ command: 'brew', args: ['install', 'whisper-cpp'], needsSudo: false }];
|
|
64
|
+
}
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sherpa-onnx release ailoud installs. Pinned, never "latest": two people
|
|
3
|
+
* running `ailoud setup` a week apart must get the same binary, and an
|
|
4
|
+
* upstream change must not be able to break installation for everyone at
|
|
5
|
+
* once. Bumping this is a reviewable commit.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SHERPA_VERSION = "v1.13.6";
|
|
8
|
+
/**
|
|
9
|
+
* The prebuilt tarball for this platform and CPU.
|
|
10
|
+
*
|
|
11
|
+
* Only macOS arm64 and Linux x64 are covered: those are the two generic
|
|
12
|
+
* shared-library builds this release publishes. Linux arm64 has no
|
|
13
|
+
* equivalent -- the only aarch64 assets in this release are vendor NPU
|
|
14
|
+
* builds (axcl, axera, rknn) that do not run on an ordinary ARM machine, so
|
|
15
|
+
* that target is refused rather than handed a binary that cannot execute.
|
|
16
|
+
*/
|
|
17
|
+
export declare function sherpaTarballUrl(platform: NodeJS.Platform, arch: string): string;
|
|
18
|
+
export interface InstallSherpaOptions {
|
|
19
|
+
readonly platform: NodeJS.Platform;
|
|
20
|
+
readonly arch: string;
|
|
21
|
+
readonly dataDir: string;
|
|
22
|
+
readonly onProgress?: (received: number, total: number | null) => void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Installs the sherpa-onnx diarizer and returns the absolute path to its
|
|
26
|
+
* binary.
|
|
27
|
+
*
|
|
28
|
+
* The extracted tree is kept intact, exactly like the whisper.cpp tree in
|
|
29
|
+
* whisperInstall.ts: the binary is invoked by absolute path from inside
|
|
30
|
+
* `<dataDir>/sherpa/<SHERPA_VERSION>/`, not copied or symlinked elsewhere,
|
|
31
|
+
* because it ran correctly during the design spike only when left next to
|
|
32
|
+
* whatever else the archive placed alongside it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function installSherpa(options: InstallSherpaOptions): Promise<string>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { FailureError } from '@ailoud/core';
|
|
4
|
+
import { run } from '../process/run.js';
|
|
5
|
+
import { downloadFile } from './download.js';
|
|
6
|
+
/**
|
|
7
|
+
* The sherpa-onnx release ailoud installs. Pinned, never "latest": two people
|
|
8
|
+
* running `ailoud setup` a week apart must get the same binary, and an
|
|
9
|
+
* upstream change must not be able to break installation for everyone at
|
|
10
|
+
* once. Bumping this is a reviewable commit.
|
|
11
|
+
*/
|
|
12
|
+
export const SHERPA_VERSION = 'v1.13.6';
|
|
13
|
+
const RELEASES = 'https://github.com/k2-fsa/sherpa-onnx/releases/download';
|
|
14
|
+
/**
|
|
15
|
+
* The prebuilt tarball for this platform and CPU.
|
|
16
|
+
*
|
|
17
|
+
* Only macOS arm64 and Linux x64 are covered: those are the two generic
|
|
18
|
+
* shared-library builds this release publishes. Linux arm64 has no
|
|
19
|
+
* equivalent -- the only aarch64 assets in this release are vendor NPU
|
|
20
|
+
* builds (axcl, axera, rknn) that do not run on an ordinary ARM machine, so
|
|
21
|
+
* that target is refused rather than handed a binary that cannot execute.
|
|
22
|
+
*/
|
|
23
|
+
export function sherpaTarballUrl(platform, arch) {
|
|
24
|
+
if (platform === 'darwin') {
|
|
25
|
+
if (arch !== 'arm64') {
|
|
26
|
+
// Name the way out, not just the obstacle: this release has no Intel
|
|
27
|
+
// Mac asset at all, so the only route is building from source.
|
|
28
|
+
throw new FailureError(`no prebuilt sherpa-onnx diarizer is published for macOS ${arch}; only macOS arm64 is ` +
|
|
29
|
+
'packaged in this release. Build sherpa-onnx from source for this CPU and set ' +
|
|
30
|
+
'"stt.diarization.binary" to the resulting sherpa-onnx-offline-speaker-diarization ' +
|
|
31
|
+
'binary');
|
|
32
|
+
}
|
|
33
|
+
return `${RELEASES}/${SHERPA_VERSION}/sherpa-onnx-${SHERPA_VERSION}-onnxruntime-1.24.4-osx-arm64-shared.tar.bz2`;
|
|
34
|
+
}
|
|
35
|
+
if (platform === 'linux') {
|
|
36
|
+
if (arch !== 'x64') {
|
|
37
|
+
// Name the way out, not just the obstacle: a user on Linux arm64 has
|
|
38
|
+
// no automated route at all (see the vendor-NPU note above) and needs
|
|
39
|
+
// to know building from source is the supported answer, plus which
|
|
40
|
+
// config key to point at the result.
|
|
41
|
+
throw new FailureError(`no usable sherpa-onnx diarizer is published for Linux ${arch}; the only aarch64 ` +
|
|
42
|
+
'assets in this release are vendor NPU builds that do not run on a generic ARM ' +
|
|
43
|
+
'machine. Build sherpa-onnx from source for this CPU and set "stt.diarization.binary" ' +
|
|
44
|
+
'to the resulting sherpa-onnx-offline-speaker-diarization binary');
|
|
45
|
+
}
|
|
46
|
+
return `${RELEASES}/${SHERPA_VERSION}/sherpa-onnx-${SHERPA_VERSION}-linux-x64-shared.tar.bz2`;
|
|
47
|
+
}
|
|
48
|
+
throw new FailureError(`no prebuilt sherpa-onnx diarizer is published for ${platform}; build sherpa-onnx from ` +
|
|
49
|
+
'source and set "stt.diarization.binary" to the resulting ' +
|
|
50
|
+
'sherpa-onnx-offline-speaker-diarization binary');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Installs the sherpa-onnx diarizer and returns the absolute path to its
|
|
54
|
+
* binary.
|
|
55
|
+
*
|
|
56
|
+
* The extracted tree is kept intact, exactly like the whisper.cpp tree in
|
|
57
|
+
* whisperInstall.ts: the binary is invoked by absolute path from inside
|
|
58
|
+
* `<dataDir>/sherpa/<SHERPA_VERSION>/`, not copied or symlinked elsewhere,
|
|
59
|
+
* because it ran correctly during the design spike only when left next to
|
|
60
|
+
* whatever else the archive placed alongside it.
|
|
61
|
+
*/
|
|
62
|
+
export async function installSherpa(options) {
|
|
63
|
+
const { platform, arch, dataDir } = options;
|
|
64
|
+
const url = sherpaTarballUrl(platform, arch);
|
|
65
|
+
const root = join(dataDir, 'sherpa', SHERPA_VERSION);
|
|
66
|
+
const archive = join(dataDir, 'sherpa', `sherpa-${SHERPA_VERSION}-${platform}-${arch}.tar.bz2`);
|
|
67
|
+
await mkdir(root, { recursive: true });
|
|
68
|
+
await downloadFile(url, archive, { onProgress: options.onProgress });
|
|
69
|
+
// --strip-components=1 drops the "sherpa-onnx-<version>-.../" wrapper so
|
|
70
|
+
// `bin/`, `lib/`, etc. land directly in `root`. The archive is .tar.bz2,
|
|
71
|
+
// not .tar.gz -- the flag is -xjf, not -xzf.
|
|
72
|
+
const extract = await run('tar', ['-xjf', archive, '-C', root, '--strip-components=1']);
|
|
73
|
+
if (extract.code !== 0) {
|
|
74
|
+
throw new FailureError(`extracting ${archive} failed: ${extract.stderr.trim()}`);
|
|
75
|
+
}
|
|
76
|
+
await rm(archive, { force: true });
|
|
77
|
+
return join(root, 'bin', 'sherpa-onnx-offline-speaker-diarization');
|
|
78
|
+
}
|