@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.
Files changed (43) hide show
  1. package/LICENSE +224 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/audio/ffmpeg.d.ts +12 -0
  4. package/dist/audio/ffmpeg.js +81 -0
  5. package/dist/diarize/sherpaDiarizer.d.ts +26 -0
  6. package/dist/diarize/sherpaDiarizer.js +64 -0
  7. package/dist/index.d.ts +33 -0
  8. package/dist/index.js +19 -0
  9. package/dist/llm/anthropic.d.ts +36 -0
  10. package/dist/llm/anthropic.js +92 -0
  11. package/dist/llm/claudeCli.d.ts +32 -0
  12. package/dist/llm/claudeCli.js +63 -0
  13. package/dist/llm/llamaCpp.d.ts +38 -0
  14. package/dist/llm/llamaCpp.js +90 -0
  15. package/dist/llm/models.d.ts +23 -0
  16. package/dist/llm/models.js +97 -0
  17. package/dist/llm/openAiCompatible.d.ts +35 -0
  18. package/dist/llm/openAiCompatible.js +84 -0
  19. package/dist/process/pager.d.ts +36 -0
  20. package/dist/process/pager.js +69 -0
  21. package/dist/process/run.d.ts +35 -0
  22. package/dist/process/run.js +101 -0
  23. package/dist/provision/download.d.ts +19 -0
  24. package/dist/provision/download.js +80 -0
  25. package/dist/provision/llamaInstall.d.ts +38 -0
  26. package/dist/provision/llamaInstall.js +75 -0
  27. package/dist/provision/packageManager.d.ts +50 -0
  28. package/dist/provision/packageManager.js +66 -0
  29. package/dist/provision/sherpaInstall.d.ts +34 -0
  30. package/dist/provision/sherpaInstall.js +78 -0
  31. package/dist/provision/whisperInstall.d.ts +61 -0
  32. package/dist/provision/whisperInstall.js +89 -0
  33. package/dist/store/sqliteStore.d.ts +64 -0
  34. package/dist/store/sqliteStore.js +433 -0
  35. package/dist/stt/whisperCpp.d.ts +50 -0
  36. package/dist/stt/whisperCpp.js +116 -0
  37. package/dist/system/nodeFs.d.ts +14 -0
  38. package/dist/system/nodeFs.js +80 -0
  39. package/dist/system/systemClock.d.ts +20 -0
  40. package/dist/system/systemClock.js +55 -0
  41. package/dist/vad/whisperVad.d.ts +14 -0
  42. package/dist/vad/whisperVad.js +58 -0
  43. 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
+ }