@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.
Files changed (55) hide show
  1. package/README.md +5 -5
  2. package/SKILL.md +1 -1
  3. package/completions/kesha.bash +1 -1
  4. package/completions/kesha.fish +0 -1
  5. package/completions/kesha.zsh +3 -1
  6. package/docs/diagnostic-logs.md +36 -3
  7. package/docs/product-positioning.md +3 -3
  8. package/model-plan.json +130 -0
  9. package/package.json +21 -7
  10. package/src/cache-layout.ts +78 -8
  11. package/src/cli/action-result.ts +2 -1
  12. package/src/cli/command-session.ts +14 -3
  13. package/src/cli/completions.ts +4 -2
  14. package/src/cli/dispatch.ts +37 -5
  15. package/src/cli/init.ts +4 -4
  16. package/src/cli/install.ts +9 -6
  17. package/src/cli/main.ts +61 -46
  18. package/src/cli/mcp.ts +2 -0
  19. package/src/cli/options.ts +79 -0
  20. package/src/cli/record.ts +2 -1
  21. package/src/cli/say.ts +92 -11
  22. package/src/cli/stats.ts +9 -4
  23. package/src/cli/support-bundle.ts +3 -3
  24. package/src/cli.ts +1 -1
  25. package/src/diagnostic-log.ts +2 -9
  26. package/src/doctor.ts +62 -11
  27. package/src/engine/events.ts +20 -1
  28. package/src/engine/spawn.ts +114 -0
  29. package/src/engine-health.ts +25 -56
  30. package/src/engine-install.ts +230 -109
  31. package/src/engine-targets.ts +53 -5
  32. package/src/engine.ts +87 -102
  33. package/src/fluid-roots.ts +5 -0
  34. package/src/format.ts +11 -5
  35. package/src/install-lock.ts +45 -11
  36. package/src/install-plan.ts +96 -48
  37. package/src/kokoro-ane.ts +67 -4
  38. package/src/language-routing.ts +51 -0
  39. package/src/lib.ts +96 -61
  40. package/src/log.ts +2 -0
  41. package/src/mcp/audio-output.ts +2 -2
  42. package/src/mcp/tools.ts +10 -14
  43. package/src/package-info.ts +28 -2
  44. package/src/paths.ts +2 -2
  45. package/src/process-tree.ts +70 -18
  46. package/src/semver.mjs +7 -10
  47. package/src/shell-artifacts.ts +4 -3
  48. package/src/state-paths.ts +84 -0
  49. package/src/stats.ts +2 -10
  50. package/src/status.ts +39 -42
  51. package/src/synth.ts +58 -79
  52. package/src/transcribe.ts +18 -3
  53. package/src/voice-inventory.ts +51 -0
  54. package/src/voice-routing.ts +97 -6
  55. 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 { transcribe, transcribeWithTimestamps } from "../lib";
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
- if (timestamps) {
132
- const out = await transcribeWithTimestamps(path);
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: `${voices.length} voices installed.` }],
169
+ content: [{ type: "text" as const, text }],
174
170
  structuredContent: { voices },
175
171
  };
176
172
  } catch (err) {
@@ -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
- typeof pkg.keshaEngine?.version === "string" ? pkg.keshaEngine.version : packageVersion;
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 process.env.KESHA_CACHE_DIR ?? join(homedir(), ".cache", "kesha");
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. */
@@ -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
- export function engineAbortError(): Error {
25
- const err = new Error("kesha-engine process aborted");
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, opts: { ref?: boolean } = {}): Timer {
140
+ function scheduleForceKill(proc: ActiveProcess): Timer {
98
141
  const timer = setTimeout(() => proc.kill("SIGKILL"), FORCE_KILL_GRACE_MS);
99
- if (opts.ref !== true) timer.unref?.();
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(signal);
116
- scheduleForceKill(proc, { ref: true });
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
- const delayMs = processes.length > 0
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
- resolveDone = resolve;
179
+ settle = resolve;
129
180
  });
130
- pendingSignalCleanup = { exitCode, done };
131
- setTimeout(resolveDone, delayMs);
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 alpha derivation, the npm
5
- * dist-tag resolver, the package.json writer and the Linux package names all decide "is this
6
- * a version" or "which is newer". Separate copies disagree at the edges: `derive-alpha-version`
7
- * asserts its output sorts above the pin that `check-versions` later validates, so one parser
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`: npm-publish.yml, release-npm-publish.yml and homebrew-tap.yml run their
12
- * scripts under node, which cannot import a `.ts` module. Types live in `semver.d.mts`.
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 — `.github/scripts/release-tags.mjs`
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`. */
@@ -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
- if (process.env.KESHA_STATS_DB) return process.env.KESHA_STATS_DB;
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 { cacheComponentPaths, isInsideDir } from "./cache-layout";
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 { dirSizeBytes } from "./diagnostic-paths";
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: installed ? listInstalledVoices() : [],
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
- // Two levels up from the binary (`<cache>/engine/bin/`) so future engine-root siblings are counted.
205
- const engineDir = join(binPath, "..", "..");
206
-
207
- const components: StatusDiskComponent[] = [];
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
- }