@drakulavich/kesha-voice-kit 1.24.0 → 1.24.2
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/package.json +2 -2
- package/src/cli/dispatch.ts +28 -24
- package/src/cli/install.ts +4 -3
- package/src/cli/main.ts +2 -1
- package/src/cli/say.ts +2 -1
- package/src/cli/support-bundle.ts +2 -1
- package/src/diagnostic-log.ts +2 -1
- package/src/doctor.ts +5 -15
- package/src/engine-install.ts +127 -104
- package/src/engine.ts +2 -1
- package/src/error-utils.ts +4 -0
- package/src/format.ts +13 -0
- package/src/install-plan.ts +22 -25
- package/src/mcp/tools.ts +5 -8
- package/src/stats.ts +71 -98
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.2",
|
|
4
4
|
"description": "Give your tools a voice — speech to text and back, 25 languages, up to ~19× faster than Whisper. On your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
"keshaEngine": {
|
|
41
|
-
"version": "1.
|
|
41
|
+
"version": "1.24.2"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"test": "bun test",
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -52,6 +52,30 @@ function isFalsey(v: string): boolean {
|
|
|
52
52
|
const USER_FORCED_NO_COLOR =
|
|
53
53
|
process.env.NO_COLOR !== undefined && !isFalsey(process.env.NO_COLOR);
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Strip a boolean global flag out of rawArgs so citty never sees it. Matches
|
|
57
|
+
* any of `names` bare (sets the flag) and the `--name=<value>` form for the
|
|
58
|
+
* long names (`<falsey>` explicitly turns the flag back off).
|
|
59
|
+
*/
|
|
60
|
+
function stripBooleanFlag(
|
|
61
|
+
rawArgs: string[],
|
|
62
|
+
names: string[],
|
|
63
|
+
): { value: boolean; rawArgs: string[] } {
|
|
64
|
+
const valuedPrefixes = names.filter((n) => n.startsWith("--")).map((n) => `${n}=`);
|
|
65
|
+
let value = false;
|
|
66
|
+
const cleaned: string[] = [];
|
|
67
|
+
for (const arg of rawArgs) {
|
|
68
|
+
if (names.includes(arg)) {
|
|
69
|
+
value = true;
|
|
70
|
+
} else if (valuedPrefixes.some((prefix) => arg.startsWith(prefix))) {
|
|
71
|
+
value = !isFalsey(arg.slice(arg.indexOf("=") + 1));
|
|
72
|
+
} else {
|
|
73
|
+
cleaned.push(arg);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { value, rawArgs: cleaned };
|
|
77
|
+
}
|
|
78
|
+
|
|
55
79
|
/**
|
|
56
80
|
* Decide whether ANSI colors should be disabled (#531) and return rawArgs with
|
|
57
81
|
* any `--no-color[=value]` token stripped so citty never sees it.
|
|
@@ -65,19 +89,9 @@ export function resolveColorMode(
|
|
|
65
89
|
rawArgs: string[],
|
|
66
90
|
env: { CI?: string } = process.env as { CI?: string },
|
|
67
91
|
): { disableColor: boolean; rawArgs: string[] } {
|
|
68
|
-
|
|
69
|
-
const cleaned: string[] = [];
|
|
70
|
-
for (const arg of rawArgs) {
|
|
71
|
-
if (arg === "--no-color") {
|
|
72
|
-
flag = true;
|
|
73
|
-
} else if (arg.startsWith("--no-color=")) {
|
|
74
|
-
flag = !isFalsey(arg.slice("--no-color=".length));
|
|
75
|
-
} else {
|
|
76
|
-
cleaned.push(arg);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
92
|
+
const { value, rawArgs: cleaned } = stripBooleanFlag(rawArgs, ["--no-color"]);
|
|
79
93
|
const ci = env.CI !== undefined && !isFalsey(env.CI);
|
|
80
|
-
return { disableColor:
|
|
94
|
+
return { disableColor: value || ci, rawArgs: cleaned };
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
/**
|
|
@@ -87,18 +101,8 @@ export function resolveColorMode(
|
|
|
87
101
|
* subcommand that doesn't declare it never sees the flag.
|
|
88
102
|
*/
|
|
89
103
|
export function resolveQuietMode(rawArgs: string[]): { quiet: boolean; rawArgs: string[] } {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
for (const arg of rawArgs) {
|
|
93
|
-
if (arg === "--quiet" || arg === "-q") {
|
|
94
|
-
flag = true;
|
|
95
|
-
} else if (arg.startsWith("--quiet=")) {
|
|
96
|
-
flag = !isFalsey(arg.slice("--quiet=".length));
|
|
97
|
-
} else {
|
|
98
|
-
cleaned.push(arg);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return { quiet: flag, rawArgs: cleaned };
|
|
104
|
+
const { value, rawArgs: cleaned } = stripBooleanFlag(rawArgs, ["--quiet", "-q"]);
|
|
105
|
+
return { quiet: value, rawArgs: cleaned };
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
|
package/src/cli/install.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
+
import { errorMessage } from "../error-utils";
|
|
2
3
|
import { downloadEngine } from "../engine-install";
|
|
3
4
|
import { getEngineBinPath, getEngineCapabilities } from "../engine";
|
|
4
5
|
import { renderInstallPlan } from "../install-plan";
|
|
@@ -111,7 +112,7 @@ function finishInstallDiagnostic(
|
|
|
111
112
|
});
|
|
112
113
|
diagnosticLog.finish(status);
|
|
113
114
|
} catch (err) {
|
|
114
|
-
log.debug(`install diagnostic log finish dropped: ${
|
|
115
|
+
log.debug(`install diagnostic log finish dropped: ${errorMessage(err)}`);
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
@@ -161,7 +162,7 @@ export async function performInstall(
|
|
|
161
162
|
await maybeAskForStar(getEngineBinPath(), packageVersion, log);
|
|
162
163
|
finishInstallDiagnostic(diagnosticLog, startedAt, "success");
|
|
163
164
|
} catch (err: unknown) {
|
|
164
|
-
const message =
|
|
165
|
+
const message = errorMessage(err);
|
|
165
166
|
finishInstallDiagnostic(diagnosticLog, startedAt, "failed", errorKind);
|
|
166
167
|
log.error(message);
|
|
167
168
|
process.exit(1);
|
|
@@ -219,7 +220,7 @@ export const installCommand = defineCommand({
|
|
|
219
220
|
try {
|
|
220
221
|
ttsLangs = resolveTtsLangs({ tts: args.tts === true, positionals }, supported);
|
|
221
222
|
} catch (err) {
|
|
222
|
-
log.error(
|
|
223
|
+
log.error(errorMessage(err));
|
|
223
224
|
process.exit(1);
|
|
224
225
|
}
|
|
225
226
|
await performInstall(
|
package/src/cli/main.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
+
import { errorMessage } from "../error-utils";
|
|
2
3
|
import { existsSync } from "fs";
|
|
3
4
|
import { detect } from "tinyld";
|
|
4
5
|
import { preflightTranscribeWithSegments, transcribeWithSegments } from "../transcribe";
|
|
@@ -435,7 +436,7 @@ export const mainCommand = defineCommand({
|
|
|
435
436
|
} catch (err: unknown) {
|
|
436
437
|
progress?.stop();
|
|
437
438
|
hasError = true;
|
|
438
|
-
const stderrText =
|
|
439
|
+
const stderrText = errorMessage(err);
|
|
439
440
|
const code = extractEngineErrorCode(stderrText) ?? ENGINE_CODES.TRANSCRIBE_FAILED;
|
|
440
441
|
stats.recordError("transcribe", err, code);
|
|
441
442
|
diagnosticLog.event("engine.exit", {
|
package/src/cli/say.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
+
import { errorMessage } from "../error-utils";
|
|
2
3
|
import { detectTextLanguageEngine, getEngineBinPath } from "../engine";
|
|
3
4
|
import { log } from "../log";
|
|
4
5
|
import { say, SayError, type SayFormat } from "../synth";
|
|
@@ -289,7 +290,7 @@ export const sayCommand = defineCommand({
|
|
|
289
290
|
log.error(err.stderr.trim() || err.message);
|
|
290
291
|
process.exit(err.exitCode);
|
|
291
292
|
}
|
|
292
|
-
const message =
|
|
293
|
+
const message = errorMessage(err);
|
|
293
294
|
log.error(message);
|
|
294
295
|
process.exit(4);
|
|
295
296
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
+
import { errorMessage } from "../error-utils";
|
|
2
3
|
import { createSupportBundle } from "../support-bundle";
|
|
3
4
|
import { log } from "../log";
|
|
4
5
|
|
|
@@ -33,7 +34,7 @@ export const supportBundleCommand = defineCommand({
|
|
|
33
34
|
log.info(`Entries: ${bundle.entries.length}`);
|
|
34
35
|
log.info(`Size: ${bundle.sizeBytes} bytes`);
|
|
35
36
|
} catch (err) {
|
|
36
|
-
log.error(
|
|
37
|
+
log.error(errorMessage(err));
|
|
37
38
|
process.exit(1);
|
|
38
39
|
}
|
|
39
40
|
},
|
package/src/diagnostic-log.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorMessage } from "./error-utils";
|
|
1
2
|
import {
|
|
2
3
|
closeSync,
|
|
3
4
|
existsSync,
|
|
@@ -232,7 +233,7 @@ export function createDiagnosticLogSession(): DiagnosticLogSession {
|
|
|
232
233
|
appendDiagnosticLogLine(line, status);
|
|
233
234
|
return true;
|
|
234
235
|
} catch (err) {
|
|
235
|
-
log.debug(`diagnostic log event dropped: ${
|
|
236
|
+
log.debug(`diagnostic log event dropped: ${errorMessage(err)}`);
|
|
236
237
|
return false;
|
|
237
238
|
}
|
|
238
239
|
},
|
package/src/doctor.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { existsSync, statSync } from "fs";
|
|
2
|
+
import { errorMessage } from "./error-utils";
|
|
3
|
+
import { humanBytes } from "./format";
|
|
2
4
|
import { dirname, join, sep } from "path";
|
|
3
5
|
import {
|
|
4
6
|
getEngineBinPath,
|
|
@@ -82,18 +84,6 @@ export interface DoctorReport {
|
|
|
82
84
|
env: Record<string, string | null>;
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
function humanBytes(bytes: number): string {
|
|
86
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
87
|
-
const units = ["KB", "MB", "GB", "TB"];
|
|
88
|
-
let n = bytes / 1024;
|
|
89
|
-
let i = 0;
|
|
90
|
-
while (n >= 1024 && i < units.length - 1) {
|
|
91
|
-
n /= 1024;
|
|
92
|
-
i++;
|
|
93
|
-
}
|
|
94
|
-
return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
87
|
function pathSummary(path: string): PathSummary {
|
|
98
88
|
return {
|
|
99
89
|
path,
|
|
@@ -194,7 +184,7 @@ async function collectEngine(redact: boolean): Promise<DoctorReport["engine"]> {
|
|
|
194
184
|
capabilities = await getEngineCapabilities();
|
|
195
185
|
if (!capabilities) probeError = "capabilities probe returned no data";
|
|
196
186
|
} catch (err) {
|
|
197
|
-
probeError =
|
|
187
|
+
probeError = errorMessage(err);
|
|
198
188
|
}
|
|
199
189
|
}
|
|
200
190
|
|
|
@@ -305,7 +295,7 @@ function collectStats(redact: boolean): DoctorReport["stats"] {
|
|
|
305
295
|
: status;
|
|
306
296
|
} catch (err) {
|
|
307
297
|
return {
|
|
308
|
-
error: redactString("statsError",
|
|
298
|
+
error: redactString("statsError", errorMessage(err), redact) ?? "unknown",
|
|
309
299
|
};
|
|
310
300
|
}
|
|
311
301
|
}
|
|
@@ -337,7 +327,7 @@ function collectDiagnosticLogs(redact: boolean): DoctorReport["diagnosticLogs"]
|
|
|
337
327
|
maxBytes: 10 * 1024 * 1024,
|
|
338
328
|
retain: 5,
|
|
339
329
|
error:
|
|
340
|
-
redactString("diagnosticLogsError",
|
|
330
|
+
redactString("diagnosticLogsError", errorMessage(err), redact) ??
|
|
341
331
|
"unknown",
|
|
342
332
|
};
|
|
343
333
|
}
|
package/src/engine-install.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dirname, join } from "path";
|
|
2
|
+
import { errorMessage } from "./error-utils";
|
|
2
3
|
import { tmpdir } from "os";
|
|
3
4
|
import { existsSync, mkdirSync, chmodSync, accessSync, constants, rmSync } from "fs";
|
|
4
5
|
import {
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
getEngineCapabilities,
|
|
7
8
|
TRANSCRIBE_DIARIZE_FEATURE,
|
|
8
9
|
} from "./engine";
|
|
10
|
+
import { isDarwinArm64 } from "./fluid-kokoro-cache";
|
|
9
11
|
import { log } from "./log";
|
|
10
12
|
import { engineVersion } from "./package-info";
|
|
11
13
|
import { streamResponseToFile } from "./progress";
|
|
@@ -61,7 +63,7 @@ interface SidecarSpec {
|
|
|
61
63
|
unavailableHint: string;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
const SIDECARS: SidecarSpec[] = [
|
|
66
|
+
export const SIDECARS: SidecarSpec[] = [
|
|
65
67
|
{
|
|
66
68
|
fileBasename: "say-avspeech",
|
|
67
69
|
assetName: "say-avspeech-darwin-arm64",
|
|
@@ -110,7 +112,7 @@ export function cleanupRetiredSidecars(engineDir: string): string[] {
|
|
|
110
112
|
removed.push(filename);
|
|
111
113
|
} catch (e) {
|
|
112
114
|
log.warn(
|
|
113
|
-
`Could not remove retired sidecar ${filename} (${e
|
|
115
|
+
`Could not remove retired sidecar ${filename} (${errorMessage(e)}); continuing.`,
|
|
114
116
|
);
|
|
115
117
|
}
|
|
116
118
|
}
|
|
@@ -163,7 +165,7 @@ function darwinTrustBinary(path: string, displayName: string): void {
|
|
|
163
165
|
}
|
|
164
166
|
} catch (e) {
|
|
165
167
|
log.debug(
|
|
166
|
-
`codesign on ${displayName} threw: ${e
|
|
168
|
+
`codesign on ${displayName} threw: ${errorMessage(e)}`,
|
|
167
169
|
);
|
|
168
170
|
}
|
|
169
171
|
try {
|
|
@@ -184,7 +186,7 @@ function darwinTrustBinary(path: string, displayName: string): void {
|
|
|
184
186
|
}
|
|
185
187
|
} catch (e) {
|
|
186
188
|
log.debug(
|
|
187
|
-
`xattr -d on ${displayName} threw: ${e
|
|
189
|
+
`xattr -d on ${displayName} threw: ${errorMessage(e)}`,
|
|
188
190
|
);
|
|
189
191
|
}
|
|
190
192
|
if (!codesignOk && !xattrOk) {
|
|
@@ -212,7 +214,7 @@ async function downloadSidecar(
|
|
|
212
214
|
binPath: string,
|
|
213
215
|
engineVersion: string,
|
|
214
216
|
): Promise<void> {
|
|
215
|
-
if (
|
|
217
|
+
if (!isDarwinArm64()) return;
|
|
216
218
|
|
|
217
219
|
const sidecarPath = join(dirname(binPath), spec.fileBasename);
|
|
218
220
|
const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/${spec.assetName}`;
|
|
@@ -222,7 +224,7 @@ async function downloadSidecar(
|
|
|
222
224
|
res = await fetch(url, { redirect: "follow" });
|
|
223
225
|
} catch (e) {
|
|
224
226
|
log.warn(
|
|
225
|
-
`Could not fetch ${spec.displayName} (${e
|
|
227
|
+
`Could not fetch ${spec.displayName} (${errorMessage(e)}); ${spec.unavailableHint}.`,
|
|
226
228
|
);
|
|
227
229
|
return;
|
|
228
230
|
}
|
|
@@ -248,13 +250,13 @@ async function downloadSidecar(
|
|
|
248
250
|
log.success(`${spec.displayName} installed (${spec.availableHint}).`);
|
|
249
251
|
} catch (e) {
|
|
250
252
|
log.warn(
|
|
251
|
-
`${spec.displayName} install failed (${e
|
|
253
|
+
`${spec.displayName} install failed (${errorMessage(e)}); ${spec.unavailableHint}.`,
|
|
252
254
|
);
|
|
253
255
|
}
|
|
254
256
|
}
|
|
255
257
|
|
|
256
258
|
async function warmDarwinKokoro(binPath: string): Promise<void> {
|
|
257
|
-
if (
|
|
259
|
+
if (!isDarwinArm64()) return;
|
|
258
260
|
// Kokoro now runs in-engine (FluidAudio CoreML, system_kokoro) — warm it by
|
|
259
261
|
// exercising the engine's own `say`, not a sidecar. The first synthesis
|
|
260
262
|
// compiles/fetches FluidAudio's CoreML Kokoro cache.
|
|
@@ -312,7 +314,7 @@ async function warmDarwinKokoro(binPath: string): Promise<void> {
|
|
|
312
314
|
);
|
|
313
315
|
} catch (e) {
|
|
314
316
|
log.warn(
|
|
315
|
-
`FluidAudio Kokoro warmup failed (${e
|
|
317
|
+
`FluidAudio Kokoro warmup failed (${errorMessage(e)}); ` +
|
|
316
318
|
"first `kesha say en-*` may still be slow.",
|
|
317
319
|
);
|
|
318
320
|
} finally {
|
|
@@ -351,6 +353,120 @@ export interface InstallOptions {
|
|
|
351
353
|
diarize?: boolean;
|
|
352
354
|
}
|
|
353
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Cache-valid path: the binary at binPath already matches engineVersion.
|
|
358
|
+
* Re-trusts it and tops up any sidecars the cached install is missing.
|
|
359
|
+
*/
|
|
360
|
+
async function refreshCachedEngine(
|
|
361
|
+
binPath: string,
|
|
362
|
+
canWriteEngineDir: boolean,
|
|
363
|
+
noCache: boolean,
|
|
364
|
+
): Promise<void> {
|
|
365
|
+
const engineDir = dirname(binPath);
|
|
366
|
+
if (noCache && !canWriteEngineDir) {
|
|
367
|
+
log.info(
|
|
368
|
+
`Engine binary at v${engineVersion} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
|
|
369
|
+
);
|
|
370
|
+
} else {
|
|
371
|
+
log.success(`Engine binary already installed (v${engineVersion}).`);
|
|
372
|
+
}
|
|
373
|
+
// Re-run the macOS trust step against the cached binary too. Reason:
|
|
374
|
+
// a user who upgraded to macOS 15+ Sequoia AFTER installing kesha
|
|
375
|
+
// would have a v<X> binary on disk with `com.apple.provenance` still
|
|
376
|
+
// attached, and `kesha install` would normally bail at "already
|
|
377
|
+
// installed" without healing the SIGKILL. Idempotent — re-signing
|
|
378
|
+
// an already-correctly-signed binary is a ~10 ms no-op; `xattr -d`
|
|
379
|
+
// on an absent attr is handled (exit 1 + "No such xattr" treated
|
|
380
|
+
// as success in `darwinTrustBinary`). Skipped on read-only
|
|
381
|
+
// filesystems (Nix-store installs) — no write access anyway.
|
|
382
|
+
if (canWriteEngineDir && existsSync(binPath)) {
|
|
383
|
+
darwinTrustBinary(binPath, "kesha-engine binary");
|
|
384
|
+
}
|
|
385
|
+
// Top up any sidecars missing from this cached install. Pre-#141 / pre-#199
|
|
386
|
+
// engines never shipped them, so a cache-valid binary may still need
|
|
387
|
+
// fetching. Run independent fetches concurrently — same shape as the
|
|
388
|
+
// cold path in fetchEngineBinary.
|
|
389
|
+
//
|
|
390
|
+
// Skip the top-up entirely when the engine sits in a read-only
|
|
391
|
+
// filesystem (e.g., a Nix-store install). Those installs stage the
|
|
392
|
+
// supported sidecars at build time; writing more is impossible, and
|
|
393
|
+
// attempting it would emit a confusing "install failed" warning for
|
|
394
|
+
// a feature the Nix README explicitly documents as unsupported
|
|
395
|
+
// (diarize on Nix needs network access at build time, which the
|
|
396
|
+
// sandbox forbids).
|
|
397
|
+
if (canWriteEngineDir) {
|
|
398
|
+
const missing = SIDECARS.filter(
|
|
399
|
+
(s) => !existsSync(join(engineDir, s.fileBasename)),
|
|
400
|
+
);
|
|
401
|
+
await Promise.all(missing.map((s) => downloadSidecar(s, binPath, engineVersion)));
|
|
402
|
+
// The cache-valid branch also re-trusts any sidecars that already
|
|
403
|
+
// exist alongside the engine — same upgrade-to-Sequoia scenario as
|
|
404
|
+
// above. The `missing.map` above only handles NEW sidecars; this
|
|
405
|
+
// loop handles ALREADY-PRESENT ones.
|
|
406
|
+
for (const s of SIDECARS) {
|
|
407
|
+
const p = join(engineDir, s.fileBasename);
|
|
408
|
+
if (existsSync(p)) darwinTrustBinary(p, s.displayName);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Cold path: download the engine binary (and sidecars, concurrently). */
|
|
414
|
+
async function fetchEngineBinary(
|
|
415
|
+
binPath: string,
|
|
416
|
+
installedVersion: string | null,
|
|
417
|
+
): Promise<void> {
|
|
418
|
+
// Log why we're downloading — helps diagnose surprising re-downloads.
|
|
419
|
+
if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
|
|
420
|
+
log.progress(
|
|
421
|
+
`Upgrading engine v${installedVersion} → v${engineVersion}...`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const binaryName = getEngineBinaryName();
|
|
425
|
+
const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/${binaryName}`;
|
|
426
|
+
|
|
427
|
+
mkdirSync(dirname(binPath), { recursive: true });
|
|
428
|
+
|
|
429
|
+
// Kick off all sidecar fetches concurrently with the engine fetch. They
|
|
430
|
+
// target independent github.com release assets, so overlapping the HTTP
|
|
431
|
+
// round-trips saves ~15-30s on a cold install. Each sidecar is
|
|
432
|
+
// best-effort (404 on older engines, warn + continue) so a failure
|
|
433
|
+
// doesn't cascade into the engine path.
|
|
434
|
+
const sidecarPromises = SIDECARS.map((s) =>
|
|
435
|
+
downloadSidecar(s, binPath, engineVersion),
|
|
436
|
+
);
|
|
437
|
+
// Defense-in-depth: if the engine fetch throws below, attach no-op
|
|
438
|
+
// rejection handlers so we don't surface unhandledRejection errors
|
|
439
|
+
// from sidecar paths whose internal try/catch ever drifts. Logs from
|
|
440
|
+
// sidecars whose own work is still in flight will print when they
|
|
441
|
+
// complete; the engine error is what the user needs to see now.
|
|
442
|
+
const muteSidecarRejections = () =>
|
|
443
|
+
sidecarPromises.forEach((p) => p.catch(() => {}));
|
|
444
|
+
|
|
445
|
+
let res: Response;
|
|
446
|
+
try {
|
|
447
|
+
res = await fetch(url, { redirect: "follow" });
|
|
448
|
+
} catch (e) {
|
|
449
|
+
muteSidecarRejections();
|
|
450
|
+
throw new Error(
|
|
451
|
+
`Failed to fetch engine binary: ${errorMessage(e)}\n Fix: Check your network connection and try again`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!res.ok) {
|
|
456
|
+
muteSidecarRejections();
|
|
457
|
+
throw new Error(
|
|
458
|
+
`Failed to download engine binary (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
await streamResponseToFile(res, binPath, "kesha-engine binary");
|
|
463
|
+
chmodSync(binPath, 0o755);
|
|
464
|
+
darwinTrustBinary(binPath, "kesha-engine binary");
|
|
465
|
+
writeInstalledEngineVersion(binPath, engineVersion);
|
|
466
|
+
log.success(`Engine binary downloaded (v${engineVersion}).`);
|
|
467
|
+
await Promise.all(sidecarPromises);
|
|
468
|
+
}
|
|
469
|
+
|
|
354
470
|
export async function downloadEngine(
|
|
355
471
|
noCache = false,
|
|
356
472
|
backend?: string,
|
|
@@ -383,102 +499,9 @@ export async function downloadEngine(
|
|
|
383
499
|
const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
|
|
384
500
|
|
|
385
501
|
if (cacheValid) {
|
|
386
|
-
|
|
387
|
-
log.info(
|
|
388
|
-
`Engine binary at v${engineVersion} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
|
|
389
|
-
);
|
|
390
|
-
} else {
|
|
391
|
-
log.success(`Engine binary already installed (v${engineVersion}).`);
|
|
392
|
-
}
|
|
393
|
-
// Re-run the macOS trust step against the cached binary too. Reason:
|
|
394
|
-
// a user who upgraded to macOS 15+ Sequoia AFTER installing kesha
|
|
395
|
-
// would have a v<X> binary on disk with `com.apple.provenance` still
|
|
396
|
-
// attached, and `kesha install` would normally bail at "already
|
|
397
|
-
// installed" without healing the SIGKILL. Idempotent — re-signing
|
|
398
|
-
// an already-correctly-signed binary is a ~10 ms no-op; `xattr -d`
|
|
399
|
-
// on an absent attr is handled (exit 1 + "No such xattr" treated
|
|
400
|
-
// as success in `darwinTrustBinary`). Skipped on read-only
|
|
401
|
-
// filesystems (Nix-store installs) — no write access anyway.
|
|
402
|
-
if (canWriteEngineDir && existsSync(binPath)) {
|
|
403
|
-
darwinTrustBinary(binPath, "kesha-engine binary");
|
|
404
|
-
}
|
|
405
|
-
// Top up any sidecars missing from this cached install. Pre-#141 / pre-#199
|
|
406
|
-
// engines never shipped them, so a cache-valid binary may still need
|
|
407
|
-
// fetching. Run independent fetches concurrently — same shape as the
|
|
408
|
-
// cold path below.
|
|
409
|
-
//
|
|
410
|
-
// Skip the top-up entirely when the engine sits in a read-only
|
|
411
|
-
// filesystem (e.g., a Nix-store install). Those installs stage the
|
|
412
|
-
// supported sidecars at build time; writing more is impossible, and
|
|
413
|
-
// attempting it would emit a confusing "install failed" warning for
|
|
414
|
-
// a feature the Nix README explicitly documents as unsupported
|
|
415
|
-
// (diarize on Nix needs network access at build time, which the
|
|
416
|
-
// sandbox forbids).
|
|
417
|
-
if (canWriteEngineDir) {
|
|
418
|
-
const missing = SIDECARS.filter(
|
|
419
|
-
(s) => !existsSync(join(engineDir, s.fileBasename)),
|
|
420
|
-
);
|
|
421
|
-
await Promise.all(missing.map((s) => downloadSidecar(s, binPath, engineVersion)));
|
|
422
|
-
// The cache-valid branch also re-trusts any sidecars that already
|
|
423
|
-
// exist alongside the engine — same upgrade-to-Sequoia scenario as
|
|
424
|
-
// above. The `missing.map` above only handles NEW sidecars; this
|
|
425
|
-
// loop handles ALREADY-PRESENT ones.
|
|
426
|
-
for (const s of SIDECARS) {
|
|
427
|
-
const p = join(engineDir, s.fileBasename);
|
|
428
|
-
if (existsSync(p)) darwinTrustBinary(p, s.displayName);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
502
|
+
await refreshCachedEngine(binPath, canWriteEngineDir, noCache);
|
|
431
503
|
} else {
|
|
432
|
-
|
|
433
|
-
if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
|
|
434
|
-
log.progress(
|
|
435
|
-
`Upgrading engine v${installedVersion} → v${engineVersion}...`,
|
|
436
|
-
);
|
|
437
|
-
}
|
|
438
|
-
const binaryName = getEngineBinaryName();
|
|
439
|
-
const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/${binaryName}`;
|
|
440
|
-
|
|
441
|
-
mkdirSync(dirname(binPath), { recursive: true });
|
|
442
|
-
|
|
443
|
-
// Kick off all sidecar fetches concurrently with the engine fetch. They
|
|
444
|
-
// target independent github.com release assets, so overlapping the HTTP
|
|
445
|
-
// round-trips saves ~15-30s on a cold install. Each sidecar is
|
|
446
|
-
// best-effort (404 on older engines, warn + continue) so a failure
|
|
447
|
-
// doesn't cascade into the engine path.
|
|
448
|
-
const sidecarPromises = SIDECARS.map((s) =>
|
|
449
|
-
downloadSidecar(s, binPath, engineVersion),
|
|
450
|
-
);
|
|
451
|
-
// Defense-in-depth: if the engine fetch throws below, attach no-op
|
|
452
|
-
// rejection handlers so we don't surface unhandledRejection errors
|
|
453
|
-
// from sidecar paths whose internal try/catch ever drifts. Logs from
|
|
454
|
-
// sidecars whose own work is still in flight will print when they
|
|
455
|
-
// complete; the engine error is what the user needs to see now.
|
|
456
|
-
const muteSidecarRejections = () =>
|
|
457
|
-
sidecarPromises.forEach((p) => p.catch(() => {}));
|
|
458
|
-
|
|
459
|
-
let res: Response;
|
|
460
|
-
try {
|
|
461
|
-
res = await fetch(url, { redirect: "follow" });
|
|
462
|
-
} catch (e) {
|
|
463
|
-
muteSidecarRejections();
|
|
464
|
-
throw new Error(
|
|
465
|
-
`Failed to fetch engine binary: ${e instanceof Error ? e.message : e}\n Fix: Check your network connection and try again`,
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
if (!res.ok) {
|
|
470
|
-
muteSidecarRejections();
|
|
471
|
-
throw new Error(
|
|
472
|
-
`Failed to download engine binary (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
|
|
473
|
-
);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
await streamResponseToFile(res, binPath, "kesha-engine binary");
|
|
477
|
-
chmodSync(binPath, 0o755);
|
|
478
|
-
darwinTrustBinary(binPath, "kesha-engine binary");
|
|
479
|
-
writeInstalledEngineVersion(binPath, engineVersion);
|
|
480
|
-
log.success(`Engine binary downloaded (v${engineVersion}).`);
|
|
481
|
-
await Promise.all(sidecarPromises);
|
|
504
|
+
await fetchEngineBinary(binPath, installedVersion);
|
|
482
505
|
}
|
|
483
506
|
|
|
484
507
|
if (backend) {
|
package/src/engine.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, statSync } from "fs";
|
|
2
|
+
import { errorMessage } from "./error-utils";
|
|
2
3
|
import { join } from "path";
|
|
3
4
|
import { installHint } from "./install-hint";
|
|
4
5
|
import { log } from "./log";
|
|
@@ -280,7 +281,7 @@ export async function transcribeEngineWithSegments(
|
|
|
280
281
|
try {
|
|
281
282
|
return parseTranscriptionOutput(stdout);
|
|
282
283
|
} catch (err: unknown) {
|
|
283
|
-
const message =
|
|
284
|
+
const message = errorMessage(err);
|
|
284
285
|
throw new Error(`${message}: ${stdout}`);
|
|
285
286
|
}
|
|
286
287
|
}
|
package/src/format.ts
CHANGED
|
@@ -60,3 +60,16 @@ export function formatJsonOutput(
|
|
|
60
60
|
errors === undefined ? results : { results, errors };
|
|
61
61
|
return JSON.stringify(payload, null, 2) + "\n";
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/** Render a byte count as a human-readable size (e.g. "1.5 MB"). */
|
|
65
|
+
export function humanBytes(bytes: number): string {
|
|
66
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
67
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
68
|
+
let n = bytes / 1024;
|
|
69
|
+
let i = 0;
|
|
70
|
+
while (n >= 1024 && i < units.length - 1) {
|
|
71
|
+
n /= 1024;
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
|
|
75
|
+
}
|
package/src/install-plan.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, statSync } from "fs";
|
|
2
|
+
import { humanBytes } from "./format";
|
|
2
3
|
import { dirname, join } from "path";
|
|
3
4
|
import { getEngineBinPath } from "./engine";
|
|
5
|
+
import { SIDECARS } from "./engine-install";
|
|
4
6
|
import { readInstalledEngineVersion } from "./engine-version-marker";
|
|
5
7
|
import { keshaCacheDir } from "./paths";
|
|
6
8
|
import { engineVersion, packageVersion } from "./package-info";
|
|
@@ -119,29 +121,30 @@ const DIARIZE_FILES: PlanFile[] = [
|
|
|
119
121
|
},
|
|
120
122
|
];
|
|
121
123
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
// The sidecar list (and the asset-name → installed-filename mapping) lives in
|
|
125
|
+
// SIDECARS in engine-install.ts; only the release-asset sizes are pinned here,
|
|
126
|
+
// like the model tables above.
|
|
127
|
+
const SIDECAR_ASSET_SIZES: Record<string, number> = {
|
|
128
|
+
"say-avspeech-darwin-arm64": 63_056,
|
|
129
|
+
"kesha-textlang-darwin-arm64": 57_648,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const DARWIN_SIDECARS = SIDECARS.map((s) => {
|
|
133
|
+
const sizeBytes = SIDECAR_ASSET_SIZES[s.assetName];
|
|
134
|
+
if (sizeBytes === undefined) {
|
|
135
|
+
// Fail fast at module load: a sidecar added to SIDECARS without a size
|
|
136
|
+
// entry here would otherwise silently render as "0 B" in the plan.
|
|
137
|
+
throw new Error(
|
|
138
|
+
`install-plan: missing release-asset size for sidecar "${s.assetName}"; add it to SIDECAR_ASSET_SIZES`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return { assetName: s.assetName, fileBasename: s.fileBasename, sizeBytes };
|
|
142
|
+
});
|
|
128
143
|
|
|
129
144
|
function sumFiles(files: PlanFile[]): number {
|
|
130
145
|
return files.reduce((sum, file) => sum + file.sizeBytes, 0);
|
|
131
146
|
}
|
|
132
147
|
|
|
133
|
-
function humanBytes(bytes: number): string {
|
|
134
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
135
|
-
const units = ["KB", "MB", "GB", "TB"];
|
|
136
|
-
let n = bytes / 1024;
|
|
137
|
-
let i = 0;
|
|
138
|
-
while (n >= 1024 && i < units.length - 1) {
|
|
139
|
-
n /= 1024;
|
|
140
|
-
i++;
|
|
141
|
-
}
|
|
142
|
-
return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
148
|
function engineAssetForPlatform(): ReleaseAssetSpec | null {
|
|
146
149
|
if (process.platform === "darwin" && process.arch === "arm64") {
|
|
147
150
|
return { assetName: "kesha-engine-darwin-arm64", sizeBytes: 59_621_264 };
|
|
@@ -155,12 +158,6 @@ function engineAssetForPlatform(): ReleaseAssetSpec | null {
|
|
|
155
158
|
return null;
|
|
156
159
|
}
|
|
157
160
|
|
|
158
|
-
function sidecarFilename(assetName: string): string {
|
|
159
|
-
if (assetName === "say-avspeech-darwin-arm64") return "say-avspeech";
|
|
160
|
-
if (assetName === "kesha-textlang-darwin-arm64") return "kesha-textlang";
|
|
161
|
-
return assetName;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
161
|
function filesCached(cacheRoot: string, files: PlanFile[]): boolean {
|
|
165
162
|
return files.every((file) => {
|
|
166
163
|
const path = join(cacheRoot, file.relPath);
|
|
@@ -226,7 +223,7 @@ export async function renderInstallPlan(options: InstallPlanOptions = {}): Promi
|
|
|
226
223
|
name: `Sidecar ${sidecar.assetName}`,
|
|
227
224
|
source: `GitHub release v${engineVersion}`,
|
|
228
225
|
sizeBytes: sidecar.sizeBytes,
|
|
229
|
-
cached: existsSync(join(engineDir,
|
|
226
|
+
cached: existsSync(join(engineDir, sidecar.fileBasename)),
|
|
230
227
|
refresh: noCache,
|
|
231
228
|
});
|
|
232
229
|
}
|
package/src/mcp/tools.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { errorMessage } from "../error-utils";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { chmodSync, existsSync, readFileSync, statSync } from "fs";
|
|
4
5
|
import { basename, join } from "path";
|
|
@@ -78,7 +79,7 @@ export function registerTools(server: McpServer): void {
|
|
|
78
79
|
structuredContent: { uri, path: outPath, format: fmt, voice: resolvedVoice, bytes },
|
|
79
80
|
};
|
|
80
81
|
} catch (err) {
|
|
81
|
-
return { isError: true, content: [{ type: "text" as const, text:
|
|
82
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
|
|
82
83
|
}
|
|
83
84
|
},
|
|
84
85
|
);
|
|
@@ -126,7 +127,7 @@ export function registerTools(server: McpServer): void {
|
|
|
126
127
|
structuredContent: { text, segments: [] },
|
|
127
128
|
};
|
|
128
129
|
} catch (err) {
|
|
129
|
-
return { isError: true, content: [{ type: "text" as const, text:
|
|
130
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
|
|
130
131
|
}
|
|
131
132
|
},
|
|
132
133
|
);
|
|
@@ -159,7 +160,7 @@ export function registerTools(server: McpServer): void {
|
|
|
159
160
|
structuredContent: { voices },
|
|
160
161
|
};
|
|
161
162
|
} catch (err) {
|
|
162
|
-
return { isError: true, content: [{ type: "text" as const, text: `list_voices failed: ${
|
|
163
|
+
return { isError: true, content: [{ type: "text" as const, text: `list_voices failed: ${errorMessage(err)}` }] };
|
|
163
164
|
}
|
|
164
165
|
},
|
|
165
166
|
);
|
|
@@ -189,12 +190,8 @@ export function registerTools(server: McpServer): void {
|
|
|
189
190
|
structuredContent: { languages },
|
|
190
191
|
};
|
|
191
192
|
} catch (err) {
|
|
192
|
-
return { isError: true, content: [{ type: "text" as const, text: `list_languages failed: ${
|
|
193
|
+
return { isError: true, content: [{ type: "text" as const, text: `list_languages failed: ${errorMessage(err)}` }] };
|
|
193
194
|
}
|
|
194
195
|
},
|
|
195
196
|
);
|
|
196
197
|
}
|
|
197
|
-
|
|
198
|
-
function toToolError(err: unknown): string {
|
|
199
|
-
return err instanceof Error ? err.message : String(err);
|
|
200
|
-
}
|
package/src/stats.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { errorMessage } from "./error-utils";
|
|
3
|
+
import { humanBytes } from "./format";
|
|
2
4
|
import { existsSync, mkdirSync, statSync } from "fs";
|
|
3
5
|
import { homedir } from "os";
|
|
4
6
|
import { dirname, extname, join } from "path";
|
|
@@ -148,7 +150,7 @@ function warnStatsWriteFailed(err: unknown): void {
|
|
|
148
150
|
log.warn("warning: failed to write Kesha Stats; continuing without stats for this event");
|
|
149
151
|
warnedStatsWriteFailed = true;
|
|
150
152
|
}
|
|
151
|
-
const message =
|
|
153
|
+
const message = errorMessage(err);
|
|
152
154
|
log.debug(`stats write failed: ${message}`);
|
|
153
155
|
}
|
|
154
156
|
|
|
@@ -170,23 +172,33 @@ export function createStatsRecorder(command: StatsCommandName): StatsRecorder {
|
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
|
|
173
|
-
|
|
175
|
+
/** Open the stats DB (creating it if needed), run fn, always close. */
|
|
176
|
+
function withStatsDb<T>(fn: (db: Database) => T): T {
|
|
174
177
|
const db = openStatsDatabase(resolveStatsDbPath());
|
|
175
178
|
try {
|
|
176
|
-
|
|
177
|
-
applyStatsRetention(db);
|
|
179
|
+
return fn(db);
|
|
178
180
|
} finally {
|
|
179
181
|
db.close();
|
|
180
182
|
}
|
|
181
183
|
}
|
|
182
184
|
|
|
185
|
+
/** Like withStatsDb, but returns fallback() without creating the DB when none exists. */
|
|
186
|
+
function withExistingStatsDb<T>(fallback: () => T, fn: (db: Database) => T): T {
|
|
187
|
+
if (!existsSync(resolveStatsDbPath())) return fallback();
|
|
188
|
+
return withStatsDb(fn);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function enableStats(): void {
|
|
192
|
+
withStatsDb((db) => {
|
|
193
|
+
setSetting(db, "enabled", "1");
|
|
194
|
+
applyStatsRetention(db);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
183
198
|
export function disableStats(): void {
|
|
184
|
-
|
|
185
|
-
try {
|
|
199
|
+
withStatsDb((db) => {
|
|
186
200
|
setSetting(db, "enabled", "0");
|
|
187
|
-
}
|
|
188
|
-
db.close();
|
|
189
|
-
}
|
|
201
|
+
});
|
|
190
202
|
}
|
|
191
203
|
|
|
192
204
|
export interface StatsStatus {
|
|
@@ -199,27 +211,22 @@ export interface StatsStatus {
|
|
|
199
211
|
|
|
200
212
|
export function getStatsStatus(): StatsStatus {
|
|
201
213
|
const dbPath = resolveStatsDbPath();
|
|
202
|
-
|
|
203
|
-
|
|
214
|
+
return withExistingStatsDb<StatsStatus>(
|
|
215
|
+
() => ({
|
|
204
216
|
enabled: false,
|
|
205
217
|
dbPath,
|
|
206
218
|
runCount: 0,
|
|
207
219
|
exists: false,
|
|
208
220
|
retentionDays: defaultRetentionDays(),
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const db = openStatsDatabase(dbPath);
|
|
212
|
-
try {
|
|
213
|
-
return {
|
|
221
|
+
}),
|
|
222
|
+
(db) => ({
|
|
214
223
|
enabled: getStatsEnabled(db),
|
|
215
224
|
dbPath,
|
|
216
225
|
runCount: Number((db.query("select count(*) as n from runs").get() as { n: number }).n),
|
|
217
226
|
exists: true,
|
|
218
227
|
retentionDays: getStatsRetentionDays(db),
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
db.close();
|
|
222
|
-
}
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
223
230
|
}
|
|
224
231
|
|
|
225
232
|
export interface StatsResetResult {
|
|
@@ -230,31 +237,27 @@ export interface StatsResetResult {
|
|
|
230
237
|
}
|
|
231
238
|
|
|
232
239
|
export function resetStats(): StatsResetResult {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
return { runs, artifacts, stageTimings, errors };
|
|
255
|
-
} finally {
|
|
256
|
-
db.close();
|
|
257
|
-
}
|
|
240
|
+
return withExistingStatsDb(
|
|
241
|
+
() => ({ runs: 0, artifacts: 0, stageTimings: 0, errors: 0 }),
|
|
242
|
+
(db) => {
|
|
243
|
+
let artifacts = 0;
|
|
244
|
+
let stageTimings = 0;
|
|
245
|
+
let errors = 0;
|
|
246
|
+
let runs = 0;
|
|
247
|
+
db.exec("begin");
|
|
248
|
+
try {
|
|
249
|
+
artifacts = runChanges(db, "delete from artifacts");
|
|
250
|
+
stageTimings = runChanges(db, "delete from stage_timings");
|
|
251
|
+
errors = runChanges(db, "delete from errors");
|
|
252
|
+
runs = runChanges(db, "delete from runs");
|
|
253
|
+
db.exec("commit");
|
|
254
|
+
} catch (err) {
|
|
255
|
+
db.exec("rollback");
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
return { runs, artifacts, stageTimings, errors };
|
|
259
|
+
},
|
|
260
|
+
);
|
|
258
261
|
}
|
|
259
262
|
|
|
260
263
|
export interface StatsVacuumResult {
|
|
@@ -273,13 +276,10 @@ export function vacuumStats(): StatsVacuumResult {
|
|
|
273
276
|
afterBytes: 0,
|
|
274
277
|
};
|
|
275
278
|
}
|
|
276
|
-
|
|
277
|
-
try {
|
|
279
|
+
withStatsDb((db) => {
|
|
278
280
|
db.exec("pragma wal_checkpoint(TRUNCATE)");
|
|
279
281
|
db.exec("vacuum");
|
|
280
|
-
}
|
|
281
|
-
db.close();
|
|
282
|
-
}
|
|
282
|
+
});
|
|
283
283
|
return {
|
|
284
284
|
dbPath,
|
|
285
285
|
beforeBytes,
|
|
@@ -291,13 +291,10 @@ export function setStatsRetentionDays(days: number | null): void {
|
|
|
291
291
|
if (days !== null && (!Number.isInteger(days) || days < 1)) {
|
|
292
292
|
throw new Error("Stats retention must be a positive whole number of days, or 'off'");
|
|
293
293
|
}
|
|
294
|
-
|
|
295
|
-
try {
|
|
294
|
+
withStatsDb((db) => {
|
|
296
295
|
setSetting(db, "retention_days", days === null ? "off" : String(days));
|
|
297
296
|
applyStatsRetention(db);
|
|
298
|
-
}
|
|
299
|
-
db.close();
|
|
300
|
-
}
|
|
297
|
+
});
|
|
301
298
|
}
|
|
302
299
|
|
|
303
300
|
export function exportStats(format: StatsExportFormat): string {
|
|
@@ -379,11 +376,8 @@ interface StatsRunTimingRow {
|
|
|
379
376
|
}
|
|
380
377
|
|
|
381
378
|
export function getWeekSummary(now = new Date()): StatsWeekSummary {
|
|
382
|
-
const dbPath = resolveStatsDbPath();
|
|
383
|
-
if (!existsSync(dbPath)) return emptyWeekSummary();
|
|
384
379
|
const since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
385
|
-
|
|
386
|
-
try {
|
|
380
|
+
return withExistingStatsDb(emptyWeekSummary, (db) => {
|
|
387
381
|
const stageRows = db.query(
|
|
388
382
|
`select
|
|
389
383
|
run_id as runId,
|
|
@@ -432,9 +426,7 @@ export function getWeekSummary(now = new Date()): StatsWeekSummary {
|
|
|
432
426
|
hasInputDurationData: inputArtifacts.some((row) => typeof row.durationMs === "number"),
|
|
433
427
|
slowestRuns: summarizeSlowestRuns(runRows, stageRows),
|
|
434
428
|
};
|
|
435
|
-
}
|
|
436
|
-
db.close();
|
|
437
|
-
}
|
|
429
|
+
});
|
|
438
430
|
}
|
|
439
431
|
|
|
440
432
|
export interface StatsRunExportRow {
|
|
@@ -491,11 +483,10 @@ export interface StatsErrorRow {
|
|
|
491
483
|
}
|
|
492
484
|
|
|
493
485
|
export function getRecentErrors(limit = 20): StatsErrorRow[] {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const rows = db.query(
|
|
486
|
+
return withExistingStatsDb(
|
|
487
|
+
() => [],
|
|
488
|
+
(db) => {
|
|
489
|
+
const rows = db.query(
|
|
499
490
|
`select
|
|
500
491
|
e.occurred_at as occurredAt,
|
|
501
492
|
r.command as command,
|
|
@@ -507,11 +498,10 @@ export function getRecentErrors(limit = 20): StatsErrorRow[] {
|
|
|
507
498
|
left join runs r on r.id = e.run_id
|
|
508
499
|
order by e.occurred_at desc
|
|
509
500
|
limit ?`,
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
501
|
+
).all(limit) as StatsErrorRow[];
|
|
502
|
+
return rows;
|
|
503
|
+
},
|
|
504
|
+
);
|
|
515
505
|
}
|
|
516
506
|
|
|
517
507
|
export function renderWeekSummary(summary: StatsWeekSummary): string {
|
|
@@ -779,15 +769,11 @@ function insertRun(db: Database, command: StatsCommandName): number {
|
|
|
779
769
|
}
|
|
780
770
|
|
|
781
771
|
function readStatsExport(): StatsExportData {
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
const db = openStatsDatabase(dbPath);
|
|
788
|
-
try {
|
|
789
|
-
applyStatsRetention(db);
|
|
790
|
-
return {
|
|
772
|
+
return withExistingStatsDb(
|
|
773
|
+
() => emptyStatsExport(defaultRetentionDays()),
|
|
774
|
+
(db) => {
|
|
775
|
+
applyStatsRetention(db);
|
|
776
|
+
return {
|
|
791
777
|
schemaVersion: SCHEMA_VERSION,
|
|
792
778
|
exportedAt: new Date().toISOString(),
|
|
793
779
|
retentionDays: getStatsRetentionDays(db),
|
|
@@ -839,11 +825,10 @@ function readStatsExport(): StatsExportData {
|
|
|
839
825
|
from errors e
|
|
840
826
|
left join runs r on r.id = e.run_id
|
|
841
827
|
order by e.occurred_at asc, e.id asc`,
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
}
|
|
828
|
+
).all() as StatsErrorRow[],
|
|
829
|
+
};
|
|
830
|
+
},
|
|
831
|
+
);
|
|
847
832
|
}
|
|
848
833
|
|
|
849
834
|
function emptyStatsExport(retentionDays: number | null): StatsExportData {
|
|
@@ -1200,18 +1185,6 @@ function orderBuckets(buckets: Map<string, number>, order: string[]): StatsCount
|
|
|
1200
1185
|
.map((label) => ({ label, count: buckets.get(label) ?? 0 }));
|
|
1201
1186
|
}
|
|
1202
1187
|
|
|
1203
|
-
function humanBytes(bytes: number): string {
|
|
1204
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
1205
|
-
const units = ["KB", "MB", "GB", "TB"];
|
|
1206
|
-
let n = bytes / 1024;
|
|
1207
|
-
let i = 0;
|
|
1208
|
-
while (n >= 1024 && i < units.length - 1) {
|
|
1209
|
-
n /= 1024;
|
|
1210
|
-
i++;
|
|
1211
|
-
}
|
|
1212
|
-
return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
1188
|
function humanDuration(ms: number): string {
|
|
1216
1189
|
const seconds = Math.round(ms / 1000);
|
|
1217
1190
|
if (seconds < 1) return `${ms}ms`;
|