@drakulavich/kesha-voice-kit 1.30.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/SKILL.md +1 -1
- package/completions/kesha.bash +1 -1
- package/completions/kesha.fish +0 -1
- package/completions/kesha.zsh +3 -1
- package/docs/diagnostic-logs.md +36 -3
- package/docs/product-positioning.md +3 -3
- package/model-plan.json +130 -0
- package/package.json +21 -7
- package/src/cache-layout.ts +78 -8
- package/src/cli/action-result.ts +2 -1
- package/src/cli/command-session.ts +14 -3
- package/src/cli/completions.ts +4 -2
- package/src/cli/dispatch.ts +37 -5
- package/src/cli/init.ts +4 -4
- package/src/cli/install.ts +9 -6
- package/src/cli/main.ts +61 -46
- package/src/cli/mcp.ts +2 -0
- package/src/cli/options.ts +79 -0
- package/src/cli/record.ts +2 -1
- package/src/cli/say.ts +92 -11
- package/src/cli/stats.ts +9 -4
- package/src/cli/support-bundle.ts +3 -3
- package/src/cli.ts +1 -1
- package/src/diagnostic-log.ts +2 -9
- package/src/doctor.ts +62 -11
- package/src/engine/events.ts +20 -1
- package/src/engine/spawn.ts +114 -0
- package/src/engine-health.ts +25 -56
- package/src/engine-install.ts +230 -109
- package/src/engine-targets.ts +53 -5
- package/src/engine.ts +87 -102
- package/src/fluid-roots.ts +5 -0
- package/src/format.ts +11 -5
- package/src/install-lock.ts +45 -11
- package/src/install-plan.ts +96 -48
- package/src/kokoro-ane.ts +67 -4
- package/src/language-routing.ts +51 -0
- package/src/lib.ts +96 -61
- package/src/log.ts +2 -0
- package/src/mcp/audio-output.ts +2 -2
- package/src/mcp/tools.ts +10 -14
- package/src/package-info.ts +28 -2
- package/src/paths.ts +2 -2
- package/src/process-tree.ts +70 -18
- package/src/semver.mjs +7 -10
- package/src/shell-artifacts.ts +4 -3
- package/src/state-paths.ts +84 -0
- package/src/stats.ts +2 -10
- package/src/status.ts +39 -42
- package/src/synth.ts +58 -79
- package/src/transcribe.ts +18 -3
- package/src/voice-inventory.ts +51 -0
- package/src/voice-routing.ts +97 -6
- package/tsconfig.json +1 -1
package/src/mcp/tools.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
|
+
import { installHint } from "../install-hint";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { chmodSync, existsSync, readFileSync, statSync } from "fs";
|
|
5
6
|
import { basename, isAbsolute, join } from "path";
|
|
6
|
-
import {
|
|
7
|
+
import { assertAudioFileArgument, transcribeWithSegments } from "../transcribe";
|
|
7
8
|
import { listVoices, aggregateLanguages } from "./voices";
|
|
8
9
|
import { say, type SayFormat } from "../synth";
|
|
9
10
|
import { DEFAULT_VOICE_ID, resolveSayVoice } from "../voice-routing";
|
|
@@ -66,8 +67,8 @@ export function registerTools(server: McpServer): void {
|
|
|
66
67
|
const outPath = allocAudioPath(fmt);
|
|
67
68
|
try {
|
|
68
69
|
// An MCP caller has no stderr to read: the voice it is told is its only record (#942).
|
|
69
|
-
const resolvedVoice = (await resolveSayVoice(voice, undefined, text)) ?? DEFAULT_VOICE_ID;
|
|
70
|
-
await say({ text, voice: resolvedVoice, rate, format: fmt, out: outPath });
|
|
70
|
+
const resolvedVoice = (await resolveSayVoice(voice, undefined, text, { signal: extra.signal })) ?? DEFAULT_VOICE_ID;
|
|
71
|
+
await say({ text, voice: resolvedVoice, rate, format: fmt, out: outPath, signal: extra.signal });
|
|
71
72
|
chmodSync(outPath, 0o600);
|
|
72
73
|
const bytes = statSync(outPath).size;
|
|
73
74
|
const file = basename(outPath);
|
|
@@ -128,17 +129,11 @@ export function registerTools(server: McpServer): void {
|
|
|
128
129
|
return { isError: true, content: [{ type: "text" as const, text: `File not found: ${path}${hint}` }] };
|
|
129
130
|
}
|
|
130
131
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return {
|
|
134
|
-
content: [{ type: "text" as const, text: out.text }],
|
|
135
|
-
structuredContent: { text: out.text, segments: out.segments ?? [] },
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
const text = await transcribe(path);
|
|
132
|
+
assertAudioFileArgument(path);
|
|
133
|
+
const out = await transcribeWithSegments(path, { signal: extra.signal, timestamps });
|
|
139
134
|
return {
|
|
140
|
-
content: [{ type: "text" as const, text: text }],
|
|
141
|
-
structuredContent: { text, segments: [] },
|
|
135
|
+
content: [{ type: "text" as const, text: out.text }],
|
|
136
|
+
structuredContent: { text: out.text, segments: timestamps ? out.segments : [] },
|
|
142
137
|
};
|
|
143
138
|
} catch (err) {
|
|
144
139
|
return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
|
|
@@ -169,8 +164,9 @@ export function registerTools(server: McpServer): void {
|
|
|
169
164
|
async () => {
|
|
170
165
|
try {
|
|
171
166
|
const voices = await listVoices();
|
|
167
|
+
const text = voices.length === 0 ? `0 voices installed. Run: ${installHint("--tts")}` : `${voices.length} voices installed.`;
|
|
172
168
|
return {
|
|
173
|
-
content: [{ type: "text" as const, text
|
|
169
|
+
content: [{ type: "text" as const, text }],
|
|
174
170
|
structuredContent: { voices },
|
|
175
171
|
};
|
|
176
172
|
} catch (err) {
|
package/src/package-info.ts
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
import pkg from "../package.json" with { type: "json" };
|
|
2
|
+
import type { AssetPins } from "./engine-install";
|
|
3
|
+
|
|
4
|
+
type PackageFields = { version?: unknown; kesha?: { engine?: unknown } };
|
|
5
|
+
|
|
6
|
+
function isAssetPins(value: unknown): value is AssetPins {
|
|
7
|
+
const pins = value as { version?: unknown; sha256?: unknown; size?: unknown } | null;
|
|
8
|
+
return (
|
|
9
|
+
typeof pins?.version === "string" &&
|
|
10
|
+
typeof pins.sha256 === "object" &&
|
|
11
|
+
pins.sha256 !== null &&
|
|
12
|
+
Object.values(pins.sha256).every((hash) => typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)) &&
|
|
13
|
+
typeof pins.size === "object" &&
|
|
14
|
+
pins.size !== null &&
|
|
15
|
+
Object.values(pins.size).every((bytes) => Number.isSafeInteger(bytes) && (bytes as number) > 0)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The Engine this CLI installs: the pin `release.yml` injects at publish, else the CLI's own version. */
|
|
20
|
+
export function resolveEngine(fields: PackageFields): { version: string; pins?: AssetPins } {
|
|
21
|
+
const injected = fields.kesha?.engine;
|
|
22
|
+
if (isAssetPins(injected)) return { version: injected.version, pins: injected };
|
|
23
|
+
return { version: typeof fields.version === "string" ? fields.version : "unknown" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const engine = resolveEngine(pkg as PackageFields);
|
|
2
27
|
|
|
3
28
|
export const packageName = typeof pkg.name === "string" ? pkg.name : "unknown";
|
|
4
29
|
export const packageVersion = typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
5
|
-
export const engineVersion =
|
|
6
|
-
|
|
30
|
+
export const engineVersion = engine.version;
|
|
31
|
+
/** Undefined in a source checkout; the installer then verifies against each release's own SHA256SUMS. */
|
|
32
|
+
export const injectedEnginePins = engine.pins;
|
package/src/paths.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { homedir } from "os";
|
|
2
1
|
import { join } from "path";
|
|
2
|
+
import { resolveStatePaths } from "./state-paths";
|
|
3
3
|
|
|
4
4
|
export function keshaCacheDir(): string {
|
|
5
|
-
return
|
|
5
|
+
return resolveStatePaths().cacheDir.path;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
/** The `.exe` suffix is a platform fact, not a per-target one: an extensionless PE is not reliably spawnable. */
|
package/src/process-tree.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { KeshaError } from "./engine/events";
|
|
2
|
+
|
|
1
3
|
type ManagedSignal = "SIGINT" | "SIGTERM" | "SIGKILL";
|
|
4
|
+
type ReceivedSignal = "SIGINT" | "SIGTERM" | "SIGHUP";
|
|
2
5
|
|
|
3
6
|
interface KillableProcess {
|
|
4
7
|
pid: number;
|
|
@@ -16,13 +19,19 @@ const activeProcesses = new Set<ActiveProcess>();
|
|
|
16
19
|
let signalHandlersInstalled = false;
|
|
17
20
|
let pendingSignalCleanup:
|
|
18
21
|
| {
|
|
22
|
+
signal: ReceivedSignal;
|
|
19
23
|
exitCode: number;
|
|
20
24
|
done: Promise<void>;
|
|
25
|
+
settle: () => void;
|
|
21
26
|
}
|
|
22
27
|
| null = null;
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
/** The rejection for a cancelled run; `name` stays `AbortError` so signal-driven callers can keep matching on it. */
|
|
30
|
+
export function engineAbortError(): KeshaError {
|
|
31
|
+
const err = new KeshaError("E_INTERRUPTED", "kesha-engine process aborted", {
|
|
32
|
+
exitCode: 130,
|
|
33
|
+
hint: "the caller's AbortSignal was aborted; the engine subprocess was terminated",
|
|
34
|
+
});
|
|
26
35
|
err.name = "AbortError";
|
|
27
36
|
return err;
|
|
28
37
|
}
|
|
@@ -41,12 +50,34 @@ export function registerProcessTree(proc: KillableProcess): {
|
|
|
41
50
|
return {
|
|
42
51
|
dispose: () => {
|
|
43
52
|
activeProcesses.delete(active);
|
|
53
|
+
if (activeProcesses.size === 0) pendingSignalCleanup?.settle();
|
|
44
54
|
},
|
|
45
55
|
terminate: (signal: ManagedSignal = "SIGTERM") => active.kill(signal),
|
|
46
56
|
forceKillAfterGrace: () => scheduleForceKill(active),
|
|
47
57
|
};
|
|
48
58
|
}
|
|
49
59
|
|
|
60
|
+
/** Terminates `tree` when `signal` fires and arms the force kill; `dispose` detaches the listener once the run is over. */
|
|
61
|
+
export function abortOnSignal(
|
|
62
|
+
tree: { terminate: (signal?: ManagedSignal) => void; forceKillAfterGrace: () => Timer },
|
|
63
|
+
signal: AbortSignal | undefined,
|
|
64
|
+
): { readonly aborted: boolean; dispose: () => void } {
|
|
65
|
+
let aborted = false;
|
|
66
|
+
let forceKillTimer: Timer | undefined;
|
|
67
|
+
const abort = () => {
|
|
68
|
+
aborted = true;
|
|
69
|
+
tree.terminate("SIGTERM");
|
|
70
|
+
forceKillTimer ??= tree.forceKillAfterGrace();
|
|
71
|
+
};
|
|
72
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
73
|
+
return {
|
|
74
|
+
get aborted() {
|
|
75
|
+
return aborted;
|
|
76
|
+
},
|
|
77
|
+
dispose: () => signal?.removeEventListener("abort", abort),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
50
81
|
export function getPendingSignalExitCode(): number | null {
|
|
51
82
|
return pendingSignalCleanup?.exitCode ?? null;
|
|
52
83
|
}
|
|
@@ -57,6 +88,18 @@ export async function waitForPendingSignalCleanup(): Promise<number | null> {
|
|
|
57
88
|
return pendingSignalCleanup.exitCode;
|
|
58
89
|
}
|
|
59
90
|
|
|
91
|
+
/** The `E_INTERRUPTED` failure every run refused after the CLI received a signal carries; null while none has arrived. */
|
|
92
|
+
export function pendingInterruption(): KeshaError | null {
|
|
93
|
+
if (!pendingSignalCleanup) return null;
|
|
94
|
+
const { signal, exitCode } = pendingSignalCleanup;
|
|
95
|
+
return new KeshaError("E_INTERRUPTED", `interrupted (${signal})`, { exitCode });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The `E_INTERRUPTED` failure of a run the CLI's own signal cut short, named after that signal rather than whatever the engine died of; null while no signal has arrived, and null for a run that still exited 0, whose output is whole. */
|
|
99
|
+
export function interruptedRun(exitCode: number): KeshaError | null {
|
|
100
|
+
return exitCode === 0 ? null : pendingInterruption();
|
|
101
|
+
}
|
|
102
|
+
|
|
60
103
|
export function terminateProcessTree(proc: KillableProcess, signal: ManagedSignal = "SIGTERM"): void {
|
|
61
104
|
if (!Number.isFinite(proc.pid) || proc.pid <= 0) {
|
|
62
105
|
safeKillDirect(proc, signal);
|
|
@@ -94,40 +137,49 @@ function safeKillDirect(proc: KillableProcess, signal: ManagedSignal): void {
|
|
|
94
137
|
}
|
|
95
138
|
}
|
|
96
139
|
|
|
97
|
-
function scheduleForceKill(proc: ActiveProcess
|
|
140
|
+
function scheduleForceKill(proc: ActiveProcess): Timer {
|
|
98
141
|
const timer = setTimeout(() => proc.kill("SIGKILL"), FORCE_KILL_GRACE_MS);
|
|
99
|
-
|
|
142
|
+
timer.unref?.();
|
|
100
143
|
return timer;
|
|
101
144
|
}
|
|
102
145
|
|
|
146
|
+
/** The CLI's own signal: SIGKILL after the grace, then give up on a tree that outlives even that, so the exit stays bounded. */
|
|
147
|
+
function scheduleSignalForceKill(proc: ActiveProcess): void {
|
|
148
|
+
setTimeout(() => {
|
|
149
|
+
proc.kill("SIGKILL");
|
|
150
|
+
setTimeout(() => pendingSignalCleanup?.settle(), FORCE_KILL_GRACE_MS);
|
|
151
|
+
}, FORCE_KILL_GRACE_MS);
|
|
152
|
+
}
|
|
153
|
+
|
|
103
154
|
function ensureSignalHandlers(): void {
|
|
104
155
|
if (signalHandlersInstalled) return;
|
|
105
156
|
signalHandlersInstalled = true;
|
|
106
|
-
process.on("SIGINT", () => terminateActiveProcessTrees("SIGINT", 130));
|
|
107
|
-
process.on("SIGTERM", () => terminateActiveProcessTrees("SIGTERM", 143));
|
|
157
|
+
process.on("SIGINT", () => terminateActiveProcessTrees("SIGINT", "SIGINT", 130));
|
|
158
|
+
process.on("SIGTERM", () => terminateActiveProcessTrees("SIGTERM", "SIGTERM", 143));
|
|
159
|
+
// The engine runs detached in its own group, so a terminal hangup only reaches it forwarded; Windows has no hangup to forward.
|
|
160
|
+
if (process.platform !== "win32") process.on("SIGHUP", () => terminateActiveProcessTrees("SIGHUP", "SIGTERM", 129));
|
|
108
161
|
}
|
|
109
162
|
|
|
110
|
-
function terminateActiveProcessTrees(signal: ManagedSignal, exitCode: number): void {
|
|
163
|
+
function terminateActiveProcessTrees(signal: ReceivedSignal, forward: ManagedSignal, exitCode: number): void {
|
|
111
164
|
const processes = [...activeProcesses];
|
|
112
|
-
process.exitCode = exitCode;
|
|
113
165
|
|
|
114
166
|
for (const proc of processes) {
|
|
115
|
-
proc.kill(
|
|
116
|
-
|
|
167
|
+
proc.kill(forward);
|
|
168
|
+
scheduleSignalForceKill(proc);
|
|
117
169
|
}
|
|
118
170
|
|
|
171
|
+
// The first signal names the run's outcome; a repeat only re-signals what is still running.
|
|
119
172
|
if (pendingSignalCleanup) {
|
|
120
173
|
return;
|
|
121
174
|
}
|
|
175
|
+
process.exitCode = exitCode;
|
|
122
176
|
|
|
123
|
-
|
|
124
|
-
? FORCE_KILL_GRACE_MS + SIGNAL_EXIT_BUFFER_MS
|
|
125
|
-
: SIGNAL_EXIT_BUFFER_MS;
|
|
126
|
-
let resolveDone!: () => void;
|
|
177
|
+
let settle!: () => void;
|
|
127
178
|
const done = new Promise<void>((resolve) => {
|
|
128
|
-
|
|
179
|
+
settle = resolve;
|
|
129
180
|
});
|
|
130
|
-
pendingSignalCleanup = { exitCode, done };
|
|
131
|
-
|
|
132
|
-
done.then(() => process.exit(exitCode));
|
|
181
|
+
pendingSignalCleanup = { signal, exitCode, done, settle };
|
|
182
|
+
// Armed from the drain, not the signal: a fixed deadline raced the force kill under load and exited before the report (#1305).
|
|
183
|
+
void done.then(() => setTimeout(() => process.exit(exitCode), SIGNAL_EXIT_BUFFER_MS));
|
|
184
|
+
if (processes.length === 0) settle();
|
|
133
185
|
}
|
package/src/semver.mjs
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SemVer 2.0 — the one place in this repo that knows the grammar.
|
|
3
3
|
*
|
|
4
|
-
* `kesha install --engine-version`, the version drift gate, the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* accepting what another refuses is a release that passes derivation and fails the gate
|
|
9
|
-
* (#685, #738).
|
|
4
|
+
* `kesha install --engine-version`, the version drift gate, the release plan, the
|
|
5
|
+
* package.json writer and the Linux package names all decide "is this a version" or "which
|
|
6
|
+
* is newer". Separate copies disagree at the edges: one parser accepting what another
|
|
7
|
+
* refuses is a release that passes planning and fails the gate (#685, #738).
|
|
10
8
|
*
|
|
11
|
-
* Stays `.mjs`:
|
|
12
|
-
*
|
|
9
|
+
* Stays `.mjs`: release.yml runs some of its callers under node, which cannot import a
|
|
10
|
+
* `.ts` module. Types live in `semver.d.mts`.
|
|
13
11
|
*
|
|
14
|
-
* The release *tag* grammar is deliberately not here
|
|
15
|
-
* keeps it as a POSIX ERE string so bash `=~` and JavaScript consume identical text.
|
|
12
|
+
* The release *tag* grammar is deliberately not here: it lives in `.github/scripts/release-tags.mjs`.
|
|
16
13
|
*/
|
|
17
14
|
|
|
18
15
|
/** The pattern published with the SemVer 2.0 spec; a looser one accepts `1.0.0-01` and `01.2.3`. */
|
package/src/shell-artifacts.ts
CHANGED
|
@@ -160,7 +160,7 @@ ${cases}
|
|
|
160
160
|
fi
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
complete -F _kesha_completion kesha
|
|
163
|
+
complete -o default -F _kesha_completion kesha
|
|
164
164
|
`;
|
|
165
165
|
}
|
|
166
166
|
|
|
@@ -203,6 +203,7 @@ ${commands}
|
|
|
203
203
|
_arguments ${rootOptions}
|
|
204
204
|
else
|
|
205
205
|
_describe -t commands 'kesha command' commands
|
|
206
|
+
_files
|
|
206
207
|
fi
|
|
207
208
|
return
|
|
208
209
|
fi
|
|
@@ -210,7 +211,8 @@ ${commands}
|
|
|
210
211
|
case "$words[2]" in
|
|
211
212
|
${cases}
|
|
212
213
|
*)
|
|
213
|
-
_arguments ${rootOptions}
|
|
214
|
+
_arguments ${rootOptions} \\
|
|
215
|
+
'*:audio file:_files'
|
|
214
216
|
;;
|
|
215
217
|
esac
|
|
216
218
|
}
|
|
@@ -233,7 +235,6 @@ function renderFishOption(bin: string, condition: string, option: CliOption): st
|
|
|
233
235
|
function renderFish(model: ArtifactModel): string {
|
|
234
236
|
const lines = ["# fish completion for kesha."];
|
|
235
237
|
|
|
236
|
-
lines.push("complete -c kesha -f");
|
|
237
238
|
for (const command of model.commands) {
|
|
238
239
|
lines.push(
|
|
239
240
|
`complete -c kesha -n '__fish_use_subcommand' -a '${command.name}' -d '${fishEscape(command.description)}'`,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { homedir, tmpdir } from "os";
|
|
2
|
+
import { posix, win32 } from "path";
|
|
3
|
+
|
|
4
|
+
export type StatePathSource = "default" | "KESHA_HOME" | "KESHA_CACHE_DIR" | "KESHA_LOG_DIR" | "KESHA_STATS_DB";
|
|
5
|
+
|
|
6
|
+
export interface StatePath {
|
|
7
|
+
path: string;
|
|
8
|
+
source: StatePathSource;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The four places Kesha writes, each with the rule that decided it (openspec `state-directories`). */
|
|
12
|
+
export interface StatePaths {
|
|
13
|
+
cacheDir: StatePath;
|
|
14
|
+
logDir: StatePath;
|
|
15
|
+
statsDbPath: StatePath;
|
|
16
|
+
mcpAudioDir: StatePath;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Env = Record<string, string | undefined>;
|
|
20
|
+
|
|
21
|
+
// Anchored once: a core-API caller that chdir()s later must keep one root for the whole process.
|
|
22
|
+
const STARTUP_CWD = process.cwd();
|
|
23
|
+
|
|
24
|
+
function setting(env: Env, name: string): string | undefined {
|
|
25
|
+
const value = env[name];
|
|
26
|
+
return value === undefined || value === "" ? undefined : value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Precedence per location: its own variable, then `KESHA_HOME`, then the platform default. */
|
|
30
|
+
export function resolveStatePaths(
|
|
31
|
+
env: Env = process.env,
|
|
32
|
+
platform: string = process.platform,
|
|
33
|
+
homeDir: string = homedir(),
|
|
34
|
+
tmpDir: string = tmpdir(),
|
|
35
|
+
cwd: string = STARTUP_CWD,
|
|
36
|
+
): StatePaths {
|
|
37
|
+
const p = platform === "win32" ? win32 : posix;
|
|
38
|
+
// Absolute values stay verbatim: on win32 `resolve` would prepend a drive to a drive-less `\tmp\x`.
|
|
39
|
+
const anchor = (value: string): string => (p.isAbsolute(value) ? value : p.resolve(cwd, value));
|
|
40
|
+
const home = setting(env, "KESHA_HOME");
|
|
41
|
+
const homeRoot = home === undefined ? undefined : anchor(home);
|
|
42
|
+
|
|
43
|
+
const pick = (specific: "KESHA_CACHE_DIR" | "KESHA_LOG_DIR" | "KESHA_STATS_DB" | null, under: string, fallback: string): StatePath => {
|
|
44
|
+
const own = specific === null ? undefined : setting(env, specific);
|
|
45
|
+
if (own !== undefined && specific !== null) return { path: anchor(own), source: specific };
|
|
46
|
+
if (homeRoot !== undefined) return { path: p.join(homeRoot, under), source: "KESHA_HOME" };
|
|
47
|
+
return { path: fallback, source: "default" };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const defaults = platformDefaults(p, platform, env, homeDir, tmpDir);
|
|
51
|
+
return {
|
|
52
|
+
cacheDir: pick("KESHA_CACHE_DIR", "cache", defaults.cache),
|
|
53
|
+
logDir: pick("KESHA_LOG_DIR", "logs", defaults.logs),
|
|
54
|
+
statsDbPath: pick("KESHA_STATS_DB", "stats.sqlite", defaults.stats),
|
|
55
|
+
mcpAudioDir: pick(null, "mcp-audio", defaults.mcp),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function platformDefaults(p: typeof posix | typeof win32, platform: string, env: Env, homeDir: string, tmpDir: string) {
|
|
60
|
+
const cache = p.join(homeDir, ".cache", "kesha");
|
|
61
|
+
const mcp = p.join(tmpDir, "kesha-mcp");
|
|
62
|
+
if (platform === "darwin") {
|
|
63
|
+
return {
|
|
64
|
+
cache,
|
|
65
|
+
mcp,
|
|
66
|
+
logs: p.join(homeDir, "Library", "Logs", "kesha"),
|
|
67
|
+
stats: p.join(homeDir, "Library", "Application Support", "kesha", "stats.sqlite"),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (platform === "win32") {
|
|
71
|
+
return {
|
|
72
|
+
cache,
|
|
73
|
+
mcp,
|
|
74
|
+
logs: p.join(setting(env, "LOCALAPPDATA") ?? p.join(homeDir, "AppData", "Local"), "kesha", "logs"),
|
|
75
|
+
stats: p.join(setting(env, "APPDATA") ?? p.join(homeDir, "AppData", "Roaming"), "kesha", "stats.sqlite"),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
cache,
|
|
80
|
+
mcp,
|
|
81
|
+
logs: p.join(setting(env, "XDG_STATE_HOME") ?? p.join(homeDir, ".local", "state"), "kesha", "logs"),
|
|
82
|
+
stats: p.join(setting(env, "XDG_DATA_HOME") ?? p.join(homeDir, ".local", "share"), "kesha", "stats.sqlite"),
|
|
83
|
+
};
|
|
84
|
+
}
|
package/src/stats.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { homedir } from "os";
|
|
|
6
6
|
import { dirname, extname, join } from "path";
|
|
7
7
|
import { log } from "./log";
|
|
8
8
|
import { packageVersion } from "./package-info";
|
|
9
|
+
import { resolveStatePaths } from "./state-paths";
|
|
9
10
|
|
|
10
11
|
const SCHEMA_VERSION = 1;
|
|
11
12
|
const MAX_ERROR_MESSAGE_CHARS = 300;
|
|
@@ -577,16 +578,7 @@ export function renderErrors(rows: StatsErrorRow[]): string {
|
|
|
577
578
|
}
|
|
578
579
|
|
|
579
580
|
export function resolveStatsDbPath(): string {
|
|
580
|
-
|
|
581
|
-
if (process.platform === "darwin") {
|
|
582
|
-
return join(homedir(), "Library", "Application Support", "kesha", "stats.sqlite");
|
|
583
|
-
}
|
|
584
|
-
if (process.platform === "win32") {
|
|
585
|
-
const base = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
|
586
|
-
return join(base, "kesha", "stats.sqlite");
|
|
587
|
-
}
|
|
588
|
-
const base = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
|
|
589
|
-
return join(base, "kesha", "stats.sqlite");
|
|
581
|
+
return resolveStatePaths().statsDbPath.path;
|
|
590
582
|
}
|
|
591
583
|
|
|
592
584
|
export function artifactFromFile(
|
package/src/status.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { readdirSync, statSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import modelPlan from "../model-plan.json" with { type: "json" };
|
|
4
1
|
import {
|
|
5
2
|
isEngineInstalled,
|
|
6
3
|
getEngineBinPath,
|
|
@@ -12,7 +9,7 @@ import {
|
|
|
12
9
|
NOT_FUNCTIONAL_STATE,
|
|
13
10
|
type EngineFunctionalHealth,
|
|
14
11
|
} from "./engine-health";
|
|
15
|
-
import {
|
|
12
|
+
import { cacheComponents, cacheTotalBytes } from "./cache-layout";
|
|
16
13
|
import { humanBytes } from "./format";
|
|
17
14
|
import { installHint } from "./install-hint";
|
|
18
15
|
import { log } from "./log";
|
|
@@ -24,9 +21,23 @@ import {
|
|
|
24
21
|
fluidExternalTotalBytes,
|
|
25
22
|
type FluidExternalRoot,
|
|
26
23
|
} from "./fluid-roots";
|
|
27
|
-
import {
|
|
24
|
+
import { resolveStatePaths, type StatePath } from "./state-paths";
|
|
25
|
+
import { installedVoiceIds } from "./voice-inventory";
|
|
28
26
|
import pc from "picocolors";
|
|
29
27
|
|
|
28
|
+
/** Where this process writes, each with the rule that decided it (openspec `state-directories`). */
|
|
29
|
+
export interface StatusPaths {
|
|
30
|
+
cache: StatePath;
|
|
31
|
+
logs: StatePath;
|
|
32
|
+
stats: StatePath;
|
|
33
|
+
mcpAudio: StatePath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function collectStatusPaths(): StatusPaths {
|
|
37
|
+
const p = resolveStatePaths();
|
|
38
|
+
return { cache: p.cacheDir, logs: p.logDir, stats: p.statsDbPath, mcpAudio: p.mcpAudioDir };
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
export function formatStatusLine(
|
|
31
42
|
label: string,
|
|
32
43
|
path: string | null,
|
|
@@ -78,6 +89,7 @@ export interface StatusReport {
|
|
|
78
89
|
voices: string[];
|
|
79
90
|
runtime: { bun: string; platform: string; arch: string };
|
|
80
91
|
modelMirror: string | null;
|
|
92
|
+
paths: StatusPaths;
|
|
81
93
|
hint: string | null;
|
|
82
94
|
disk: StatusDiskUsage | null;
|
|
83
95
|
}
|
|
@@ -91,9 +103,10 @@ export async function collectStatus(options: ShowStatusOptions = {}): Promise<St
|
|
|
91
103
|
return {
|
|
92
104
|
cliVersion: packageVersion,
|
|
93
105
|
engine: { installed, path, capabilities },
|
|
94
|
-
voices:
|
|
106
|
+
voices: await installedVoiceIds(),
|
|
95
107
|
runtime: { bun: Bun.version, platform: process.platform, arch: process.arch },
|
|
96
108
|
modelMirror: activeModelMirror(),
|
|
109
|
+
paths: collectStatusPaths(),
|
|
97
110
|
hint: engineHint(path, health),
|
|
98
111
|
// Absent engine means no disk walk, matching the human path (#647).
|
|
99
112
|
disk:
|
|
@@ -156,6 +169,9 @@ export function renderStatus(report: StatusReport): void {
|
|
|
156
169
|
if (report.modelMirror) {
|
|
157
170
|
log.info(formatStatusLine("Mirror", report.modelMirror, true));
|
|
158
171
|
}
|
|
172
|
+
for (const [label, entry] of Object.entries(report.paths)) {
|
|
173
|
+
if (entry.source !== "default") log.info(formatStatusLine(pathLabel(label), `${entry.path} (${entry.source})`, true));
|
|
174
|
+
}
|
|
159
175
|
log.info("");
|
|
160
176
|
|
|
161
177
|
if (installed) {
|
|
@@ -169,6 +185,19 @@ export function renderStatus(report: StatusReport): void {
|
|
|
169
185
|
}
|
|
170
186
|
|
|
171
187
|
|
|
188
|
+
function pathLabel(key: string): string {
|
|
189
|
+
switch (key) {
|
|
190
|
+
case "cache":
|
|
191
|
+
return "Cache";
|
|
192
|
+
case "logs":
|
|
193
|
+
return "Logs";
|
|
194
|
+
case "stats":
|
|
195
|
+
return "Stats DB";
|
|
196
|
+
default:
|
|
197
|
+
return "MCP audio";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
172
201
|
function logDiskRows(rows: StatusDiskComponent[], total: number, componentTotal: number): void {
|
|
173
202
|
const labelWidth = Math.max(...rows.map((r) => r.label.length), "Total".length);
|
|
174
203
|
for (const r of rows) {
|
|
@@ -201,19 +230,10 @@ function logExternalRoots(disk: StatusDiskUsage): void {
|
|
|
201
230
|
|
|
202
231
|
function collectDiskUsage(binPath: string, backend?: string, homeDir?: string): StatusDiskUsage {
|
|
203
232
|
const cache = keshaCacheDir();
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
for (const c of cacheComponentPaths(cache, engineDir, isCoremlBackend(backend))) {
|
|
209
|
-
const sizeBytes = dirSizeBytes(c.path);
|
|
210
|
-
if (sizeBytes > 0) components.push({ label: c.label, sizeBytes });
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Sum cache root + engine dir separately so `KESHA_ENGINE_BIN` overrides outside the cache are still counted.
|
|
214
|
-
const cacheTotal = dirSizeBytes(cache);
|
|
215
|
-
const engineOutsideCache = isInsideDir(engineDir, cache) ? 0 : dirSizeBytes(engineDir);
|
|
216
|
-
const totalBytes = cacheTotal + engineOutsideCache;
|
|
233
|
+
const components: StatusDiskComponent[] = cacheComponents(cache, binPath, isCoremlBackend(backend))
|
|
234
|
+
.filter((c) => c.sizeBytes > 0)
|
|
235
|
+
.map((c) => ({ label: c.label, sizeBytes: c.sizeBytes }));
|
|
236
|
+
const totalBytes = cacheTotalBytes(cache, binPath);
|
|
217
237
|
const externalRoots = fluidExternalRoots({ homeDir, cacheRoot: cache });
|
|
218
238
|
const externalTotalBytes = fluidExternalTotalBytes(externalRoots);
|
|
219
239
|
|
|
@@ -247,26 +267,3 @@ export function activeModelMirror(): string | null {
|
|
|
247
267
|
const trimmed = raw.trim().replace(/\/+$/, "");
|
|
248
268
|
return trimmed.length > 0 ? trimmed : null;
|
|
249
269
|
}
|
|
250
|
-
|
|
251
|
-
function listInstalledVoices(): string[] {
|
|
252
|
-
const cache = keshaCacheDir();
|
|
253
|
-
const voices: string[] = [];
|
|
254
|
-
try {
|
|
255
|
-
const kokoro = readdirSync(join(cache, "models", "kokoro-82m", "voices"));
|
|
256
|
-
for (const f of kokoro) {
|
|
257
|
-
if (f.endsWith(".bin")) voices.push(`en-${f.replace(/\.bin$/, "")}`);
|
|
258
|
-
}
|
|
259
|
-
} catch {
|
|
260
|
-
/* Kokoro not installed */
|
|
261
|
-
}
|
|
262
|
-
try {
|
|
263
|
-
// Joined to `models/manifest.rs::VOSK_RU_FILES` through the plan, so a sixth entry needs no edit here (#1132).
|
|
264
|
-
for (const { relPath } of modelPlan.voskRu) statSync(join(cache, relPath));
|
|
265
|
-
for (const id of ["f01", "f02", "f03", "m01", "m02"]) {
|
|
266
|
-
voices.push(`ru-vosk-${id}`);
|
|
267
|
-
}
|
|
268
|
-
} catch {
|
|
269
|
-
/* Vosk not installed */
|
|
270
|
-
}
|
|
271
|
-
return voices.sort();
|
|
272
|
-
}
|