@drakulavich/kesha-voice-kit 1.17.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -26,6 +26,39 @@ Local voice toolkit: transcribe voice messages to text, synthesize speech, detec
26
26
  - **Need to send a voice note (Telegram, WhatsApp, Signal, Discord)**: synthesize directly into the messenger-native format with `kesha say "<text>" --format ogg-opus --out reply.ogg`. Default is mono 24 kHz @ 32 kbps — what Telegram `sendVoice` expects. No `ffmpeg` round-trip needed.
27
27
  - **Need to detect what language a file is in** before choosing a pipeline: `kesha --json audio.ogg` returns both audio-based and text-based language detection with confidence scores.
28
28
 
29
+ ## OpenClaw plugin setup
30
+
31
+ Install the plugin, then explicitly route OpenClaw audio understanding through the CLI model entry. The plugin registration makes Kesha discoverable, but real voice-message transcription uses `tools.media.audio.models` with a `type: "cli"` entry.
32
+
33
+ ```bash
34
+ bun add -g @drakulavich/kesha-voice-kit
35
+ kesha install
36
+ openclaw plugins install @drakulavich/kesha-voice-kit
37
+ openclaw config set tools.media.audio.enabled true
38
+ openclaw config set tools.media.audio.models \
39
+ '[{"type":"cli","command":"kesha","args":["--format","transcript","{{MediaPath}}"],"timeoutSeconds":15}]'
40
+ ```
41
+
42
+ Use `--format transcript` for OpenClaw's normal voice-message path: stdout is compact text plus language metadata, while progress and errors stay off the transcript payload.
43
+
44
+ For agents that need timestamped segments, switch the model entry to JSON output and allow a longer timeout:
45
+
46
+ ```bash
47
+ openclaw config set tools.media.audio.models \
48
+ '[{"type":"cli","command":"kesha","args":["--json","--timestamps","{{MediaPath}}"],"timeoutSeconds":30}]'
49
+ ```
50
+
51
+ Verification checklist:
52
+
53
+ ```bash
54
+ which kesha
55
+ kesha status
56
+ openclaw plugins list
57
+ openclaw config get tools.media.audio.models
58
+ ```
59
+
60
+ Do not rely on `openclaw.plugin.json` to patch `tools.media.audio.models`; OpenClaw ignores non-schema fields such as `configPatch`. Keep the CLI route in user config.
61
+
29
62
  ## STT: transcribe audio
30
63
 
31
64
  ```bash
@@ -6,7 +6,7 @@
6
6
  // 25 languages, no cloud, ~19x faster than Whisper.
7
7
  //
8
8
  // The same `kesha` CLI also produces messenger-ready voice notes via
9
- // `kesha say --text "..." --format ogg-opus --out reply.ogg` (Telegram /
9
+ // `kesha say "..." --format ogg-opus --out reply.ogg` (Telegram /
10
10
  // Discord ingest OGG/Opus directly — no transcoding needed). Not wired into
11
11
  // OpenClaw's media-understanding capability here; invoke it from a hook or
12
12
  // agent tool when you want Kesha to speak.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.17.0",
3
+ "version": "1.17.1",
4
4
  "description": "Open-source voice toolkit. Speech-to-text (25 langs, ~19x faster than Whisper on Apple Silicon, ONNX fallback on CPU), text-to-speech (Kokoro + Vosk-TTS + ~180 macOS system voices, SSML), voice-activity detection, language detection (107 langs). Rust engine, OpenClaw skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,10 +4,11 @@ import { log } from "../log";
4
4
  import { suggestCommand } from "../suggest-command";
5
5
  import { installCommand } from "./install";
6
6
  import { sayCommand } from "./say";
7
+ import { statsCommand } from "./stats";
7
8
  import { statusCommand } from "./status";
8
9
  import { mainCommand } from "./main";
9
10
 
10
- const SUBCOMMANDS = ["install", "status", "say"];
11
+ const SUBCOMMANDS = ["install", "status", "say", "stats"];
11
12
 
12
13
  function isPathLike(arg: string): boolean {
13
14
  return arg.includes(".") || arg.includes("/") || existsSync(arg);
@@ -31,6 +32,11 @@ export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
31
32
  return;
32
33
  }
33
34
 
35
+ if (firstArg === "stats") {
36
+ await runMain(statsCommand, { rawArgs: restArgs });
37
+ return;
38
+ }
39
+
34
40
  // Check for unknown subcommands (non-flag, non-file-path args).
35
41
  // Extensionless existing files remain valid transcription inputs; missing
36
42
  // bare tokens are more likely command typos and should not start the engine.
package/src/cli/main.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  formatVerboseOutput,
14
14
  } from "../format";
15
15
  import { formatToonOutput } from "../toon";
16
+ import { artifactFromFile, createStatsRecorder } from "../stats";
16
17
 
17
18
  const pkg = await Bun.file(new URL("../../package.json", import.meta.url)).json();
18
19
 
@@ -109,8 +110,12 @@ export const mainCommand = defineCommand({
109
110
  version: pkg.version,
110
111
  description:
111
112
  "Kesha Voice Kit — open-source voice toolkit for Apple Silicon.\n" +
112
- " Run 'kesha install [--no-cache]' to download engine and models.\n" +
113
- " Run 'kesha status' to inspect installed backend.",
113
+ "\n" +
114
+ "Commands:\n" +
115
+ " install Download engine and models.\n" +
116
+ " status Inspect installed backend.\n" +
117
+ " say Synthesize speech from text.\n" +
118
+ " stats Manage local anonymous performance stats.",
114
119
  },
115
120
  args: {
116
121
  json: {
@@ -197,28 +202,46 @@ export const mainCommand = defineCommand({
197
202
  const vadMode = args.vad ? "on" : args["no-vad"] ? "off" : "auto";
198
203
 
199
204
  if (files.length === 0) {
200
- log.info("Usage: kesha <audio_file> [audio_file ...]\n kesha install [--no-cache]\n kesha status");
205
+ log.info(
206
+ "Usage: kesha <audio_file> [audio_file ...]\n" +
207
+ " kesha install [--no-cache]\n" +
208
+ " kesha status\n" +
209
+ " kesha say <text>\n" +
210
+ " kesha stats [enable|disable|status|week|errors]",
211
+ );
201
212
  process.exit(1);
202
213
  }
203
214
 
204
215
  let hasError = false;
205
216
  const results: TranscribeResult[] = [];
217
+ const stats = createStatsRecorder("transcribe");
206
218
 
207
219
  const wantsLangId = !!(args.lang || args.verbose || wantsJson || wantsToon || wantsTranscript);
220
+ const reportProgress = process.stdout.isTTY !== true;
208
221
 
209
222
  for (const file of files) {
210
223
  if (!existsSync(file)) {
211
224
  hasError = true;
225
+ stats.recordError("input", new Error("File not found"), "file_not_found");
212
226
  log.error(`${file}: File not found`);
213
227
  continue;
214
228
  }
229
+ const inputArtifact = artifactFromFile(file, "input_audio");
230
+ if (inputArtifact) stats.recordArtifact(inputArtifact);
215
231
 
216
232
  const startedAt = performance.now();
217
233
  try {
234
+ if (reportProgress) {
235
+ console.error(`Transcribing ${file}...`);
236
+ }
218
237
  // Run audio lang-id and transcription concurrently.
219
238
  const [audioResult, transcript] = await Promise.all([
220
- wantsLangId ? detectAudioLanguageEngine(file) : Promise.resolve(null),
221
- transcribeWithSegments(file, { vad: vadMode, timestamps: args.timestamps, speakers: args.speakers }),
239
+ wantsLangId
240
+ ? stats.timeStage("lang_id_audio", () => detectAudioLanguageEngine(file))
241
+ : Promise.resolve(null),
242
+ stats.timeStage("transcribe", () =>
243
+ transcribeWithSegments(file, { vad: vadMode, timestamps: args.timestamps, speakers: args.speakers })
244
+ ),
222
245
  ]);
223
246
  const { text, segments } = transcript;
224
247
 
@@ -236,7 +259,7 @@ export const mainCommand = defineCommand({
236
259
  let textLanguage: LangDetectResult | undefined;
237
260
 
238
261
  if (wantsLangId) {
239
- const engineTextResult = await detectTextLanguageEngine(text);
262
+ const engineTextResult = await stats.timeStage("lang_id_text", () => detectTextLanguageEngine(text));
240
263
  if (engineTextResult && engineTextResult.code) {
241
264
  textLanguage = engineTextResult;
242
265
  }
@@ -247,20 +270,25 @@ export const mainCommand = defineCommand({
247
270
  const mismatchWarning = checkLanguageMismatch(args.lang, lang);
248
271
  if (mismatchWarning) log.warn(`${file}: ${mismatchWarning}`);
249
272
 
273
+ const sttTimeMs = Math.round(performance.now() - startedAt);
250
274
  const result: TranscribeResult = {
251
275
  file,
252
276
  text,
253
277
  lang,
254
278
  audioLanguage,
255
279
  textLanguage: textLanguage ?? (tinyldLang ? { code: tinyldLang, confidence: 0 } : undefined),
256
- sttTimeMs: Math.round(performance.now() - startedAt),
280
+ sttTimeMs,
257
281
  };
258
- if (args.timestamps) {
282
+ if (args.timestamps || args.speakers) {
259
283
  result.segments = segments;
260
284
  }
261
285
  results.push(result);
286
+ if (reportProgress) {
287
+ console.error(`Transcribed ${file} (${sttTimeMs}ms)`);
288
+ }
262
289
  } catch (err: unknown) {
263
290
  hasError = true;
291
+ stats.recordError("transcribe", err);
264
292
  const message = err instanceof Error ? err.message : String(err);
265
293
  log.error(`${file}: ${message}`);
266
294
  }
@@ -278,6 +306,8 @@ export const mainCommand = defineCommand({
278
306
  process.stdout.write(formatTextOutput(results));
279
307
  }
280
308
 
309
+ stats.finish(hasError ? "failed" : "success", files.length);
310
+
281
311
  if (hasError) process.exit(1);
282
312
  },
283
313
  });
package/src/cli/say.ts CHANGED
@@ -2,6 +2,7 @@ import { defineCommand } from "citty";
2
2
  import { detectTextLanguageEngine, getEngineBinPath } from "../engine";
3
3
  import { log } from "../log";
4
4
  import { say, SayError, type SayFormat } from "../synth";
5
+ import { artifactFromBytes, artifactFromFile, createStatsRecorder } from "../stats";
5
6
  import { pickVoiceForLang } from "../voice-routing";
6
7
 
7
8
  /** Run NLLanguageRecognizer (via engine) on the text and pick a default voice. */
@@ -133,19 +134,36 @@ export const sayCommand = defineCommand({
133
134
  sampleRate: args["sample-rate"] ? Number(args["sample-rate"]) : undefined,
134
135
  noExpandAbbrev: Boolean(args["no-expand-abbrev"]),
135
136
  };
137
+ const stats = createStatsRecorder("say");
136
138
 
137
139
  try {
138
140
  const startedAt = performance.now();
139
- const audio = await say(opts);
141
+ if (opts.out) {
142
+ const voiceLabel = opts.voice ?? "default voice";
143
+ console.error(`Synthesizing ${voiceLabel} -> ${opts.out}...`);
144
+ }
145
+ const audio = await stats.timeStage("tts", () => say(opts));
140
146
  const ttsTimeMs = Math.round(performance.now() - startedAt);
141
- if (args.verbose) {
147
+ if (opts.out) {
148
+ console.error(`Saved ${opts.out} (${ttsTimeMs}ms)`);
149
+ }
150
+ if (args.verbose && !opts.out) {
142
151
  // stderr — stdout may carry raw audio bytes when --out is omitted.
143
152
  console.error(`TTS time: ${ttsTimeMs}ms`);
144
153
  }
154
+ if (opts.out) {
155
+ const outputArtifact = artifactFromFile(opts.out, "output_audio");
156
+ if (outputArtifact) stats.recordArtifact(outputArtifact);
157
+ } else {
158
+ stats.recordArtifact(artifactFromBytes(audio.byteLength, "output_audio", opts.format ?? "wav"));
159
+ }
145
160
  if (!opts.out) {
146
161
  process.stdout.write(audio);
147
162
  }
163
+ stats.finish("success", 1);
148
164
  } catch (err) {
165
+ stats.recordError("tts", err);
166
+ stats.finish("failed", 1);
149
167
  if (err instanceof SayError) {
150
168
  log.error(err.stderr.trim() || err.message);
151
169
  process.exit(err.exitCode);
@@ -0,0 +1,67 @@
1
+ import { defineCommand } from "citty";
2
+ import { log } from "../log";
3
+ import {
4
+ disableStats,
5
+ enableStats,
6
+ getRecentErrors,
7
+ getStatsStatus,
8
+ getWeekSummary,
9
+ renderErrors,
10
+ renderWeekSummary,
11
+ } from "../stats";
12
+
13
+ interface StatsCommandArgs {
14
+ action?: string;
15
+ }
16
+
17
+ export const statsCommand = defineCommand({
18
+ meta: {
19
+ name: "stats",
20
+ description: "Manage local anonymous Kesha Stats",
21
+ },
22
+ args: {
23
+ action: {
24
+ type: "positional",
25
+ required: false,
26
+ description: "Action: enable | disable | status | week | errors",
27
+ },
28
+ },
29
+ run({ args }: { args: StatsCommandArgs }) {
30
+ const action = args.action ?? "status";
31
+ switch (action) {
32
+ case "enable": {
33
+ enableStats();
34
+ const status = getStatsStatus();
35
+ log.success("Kesha Stats enabled");
36
+ log.info(`Database: ${status.dbPath}`);
37
+ return;
38
+ }
39
+ case "disable": {
40
+ disableStats();
41
+ const status = getStatsStatus();
42
+ log.info("Kesha Stats disabled");
43
+ log.info(`Database: ${status.dbPath}`);
44
+ return;
45
+ }
46
+ case "status": {
47
+ const status = getStatsStatus();
48
+ log.info(`Kesha Stats: ${status.enabled ? "enabled" : "disabled"}`);
49
+ log.info(`Database: ${status.dbPath}`);
50
+ log.info(`Runs: ${status.runCount}`);
51
+ return;
52
+ }
53
+ case "week": {
54
+ log.info(renderWeekSummary(getWeekSummary()));
55
+ return;
56
+ }
57
+ case "errors": {
58
+ log.info(renderErrors(getRecentErrors()));
59
+ return;
60
+ }
61
+ default:
62
+ log.error(`unknown stats action '${action}'`);
63
+ log.warn("supported: enable, disable, status, week, errors");
64
+ process.exit(2);
65
+ }
66
+ },
67
+ });
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@ export { installCommand } from "./cli/install";
7
7
  export { sayCommand } from "./cli/say";
8
8
  export { pickVoiceForLang } from "./voice-routing";
9
9
  export { statusCommand } from "./cli/status";
10
+ export { statsCommand } from "./cli/stats";
10
11
  export {
11
12
  mainCommand,
12
13
  detectLanguage,
package/src/stats.ts ADDED
@@ -0,0 +1,580 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { existsSync, mkdirSync, readFileSync, statSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { dirname, extname, join } from "path";
5
+ import { log } from "./log";
6
+
7
+ const SCHEMA_VERSION = 1;
8
+ const MAX_ERROR_MESSAGE_CHARS = 300;
9
+ const APP_VERSION = readPackageVersion();
10
+
11
+ export type StatsCommandName = "transcribe" | "say";
12
+ export type StatsStageStatus = "success" | "failed";
13
+ export type StatsRunStatus = "success" | "failed";
14
+
15
+ export interface StatsArtifactInput {
16
+ kind: "input_audio" | "output_audio";
17
+ format?: string | null;
18
+ sizeBytes?: number | null;
19
+ durationMs?: number | null;
20
+ sampleRate?: number | null;
21
+ channels?: number | null;
22
+ }
23
+
24
+ export interface StatsRecorder {
25
+ readonly enabled: boolean;
26
+ recordArtifact(input: StatsArtifactInput): void;
27
+ timeStage<T>(stage: string, fn: () => Promise<T>): Promise<T>;
28
+ recordError(stage: string, err: unknown, errorCode?: string): void;
29
+ finish(status: StatsRunStatus, itemCount?: number): void;
30
+ }
31
+
32
+ class NoopStatsRecorder implements StatsRecorder {
33
+ readonly enabled = false;
34
+ recordArtifact(): void {}
35
+ async timeStage<T>(_stage: string, fn: () => Promise<T>): Promise<T> {
36
+ return fn();
37
+ }
38
+ recordError(): void {}
39
+ finish(): void {}
40
+ }
41
+
42
+ class SqliteStatsRecorder implements StatsRecorder {
43
+ readonly enabled = true;
44
+ private closed = false;
45
+
46
+ constructor(
47
+ private readonly db: Database,
48
+ private readonly runId: number,
49
+ ) {}
50
+
51
+ recordArtifact(input: StatsArtifactInput): void {
52
+ this.safeWrite(() => {
53
+ this.db.query(
54
+ `insert into artifacts
55
+ (run_id, kind, format, size_bytes, duration_ms, sample_rate, channels)
56
+ values (?, ?, ?, ?, ?, ?, ?)`,
57
+ ).run(
58
+ this.runId,
59
+ input.kind,
60
+ normalizeFormat(input.format),
61
+ input.sizeBytes ?? null,
62
+ input.durationMs ?? null,
63
+ input.sampleRate ?? null,
64
+ input.channels ?? null,
65
+ );
66
+ });
67
+ }
68
+
69
+ async timeStage<T>(stage: string, fn: () => Promise<T>): Promise<T> {
70
+ const startedAt = new Date().toISOString();
71
+ const t0 = performance.now();
72
+ try {
73
+ const result = await fn();
74
+ this.recordStage(stage, startedAt, t0, "success");
75
+ return result;
76
+ } catch (err) {
77
+ this.recordStage(stage, startedAt, t0, "failed");
78
+ throw err;
79
+ }
80
+ }
81
+
82
+ recordError(stage: string, err: unknown, errorCode?: string): void {
83
+ const { errorClass, message } = sanitizeStatsError(err);
84
+ this.safeWrite(() => {
85
+ this.db.query(
86
+ `insert into errors
87
+ (run_id, stage, error_class, error_code, sanitized_message, occurred_at)
88
+ values (?, ?, ?, ?, ?, ?)`,
89
+ ).run(
90
+ this.runId,
91
+ stage,
92
+ errorClass,
93
+ errorCode ?? null,
94
+ message,
95
+ new Date().toISOString(),
96
+ );
97
+ });
98
+ }
99
+
100
+ finish(status: StatsRunStatus, itemCount = 0): void {
101
+ if (this.closed) return;
102
+ this.safeWrite(() => {
103
+ this.db.query(
104
+ "update runs set finished_at = ?, status = ?, item_count = ? where id = ?",
105
+ ).run(new Date().toISOString(), status, itemCount, this.runId);
106
+ });
107
+ this.closed = true;
108
+ this.db.close();
109
+ }
110
+
111
+ private recordStage(
112
+ stage: string,
113
+ startedAt: string,
114
+ startedMs: number,
115
+ status: StatsStageStatus,
116
+ ): void {
117
+ this.safeWrite(() => {
118
+ this.db.query(
119
+ `insert into stage_timings
120
+ (run_id, stage, started_at, duration_ms, status)
121
+ values (?, ?, ?, ?, ?)`,
122
+ ).run(
123
+ this.runId,
124
+ stage,
125
+ startedAt,
126
+ Math.max(0, Math.round(performance.now() - startedMs)),
127
+ status,
128
+ );
129
+ });
130
+ }
131
+
132
+ private safeWrite(fn: () => void): void {
133
+ try {
134
+ fn();
135
+ } catch (err) {
136
+ warnStatsWriteFailed(err);
137
+ }
138
+ }
139
+ }
140
+
141
+ let warnedStatsWriteFailed = false;
142
+
143
+ function warnStatsWriteFailed(err: unknown): void {
144
+ if (!warnedStatsWriteFailed) {
145
+ log.warn("warning: failed to write Kesha Stats; continuing without stats for this event");
146
+ warnedStatsWriteFailed = true;
147
+ }
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ log.debug(`stats write failed: ${message}`);
150
+ }
151
+
152
+ export function createStatsRecorder(command: StatsCommandName): StatsRecorder {
153
+ const dbPath = resolveStatsDbPath();
154
+ if (!existsSync(dbPath)) return new NoopStatsRecorder();
155
+
156
+ try {
157
+ const db = openStatsDatabase(dbPath);
158
+ if (!getStatsEnabled(db)) {
159
+ db.close();
160
+ return new NoopStatsRecorder();
161
+ }
162
+ const runId = insertRun(db, command);
163
+ return new SqliteStatsRecorder(db, runId);
164
+ } catch (err) {
165
+ warnStatsWriteFailed(err);
166
+ return new NoopStatsRecorder();
167
+ }
168
+ }
169
+
170
+ export function enableStats(): void {
171
+ const db = openStatsDatabase(resolveStatsDbPath());
172
+ try {
173
+ setSetting(db, "enabled", "1");
174
+ } finally {
175
+ db.close();
176
+ }
177
+ }
178
+
179
+ export function disableStats(): void {
180
+ const db = openStatsDatabase(resolveStatsDbPath());
181
+ try {
182
+ setSetting(db, "enabled", "0");
183
+ } finally {
184
+ db.close();
185
+ }
186
+ }
187
+
188
+ export interface StatsStatus {
189
+ enabled: boolean;
190
+ dbPath: string;
191
+ runCount: number;
192
+ exists: boolean;
193
+ }
194
+
195
+ export function getStatsStatus(): StatsStatus {
196
+ const dbPath = resolveStatsDbPath();
197
+ if (!existsSync(dbPath)) {
198
+ return { enabled: false, dbPath, runCount: 0, exists: false };
199
+ }
200
+ const db = openStatsDatabase(dbPath);
201
+ try {
202
+ return {
203
+ enabled: getStatsEnabled(db),
204
+ dbPath,
205
+ runCount: Number((db.query("select count(*) as n from runs").get() as { n: number }).n),
206
+ exists: true,
207
+ };
208
+ } finally {
209
+ db.close();
210
+ }
211
+ }
212
+
213
+ export interface StatsWeekSummary {
214
+ runs: number;
215
+ successes: number;
216
+ failures: number;
217
+ inputFiles: number;
218
+ inputBytes: number;
219
+ inputDurationMs: number;
220
+ sttTimeMs: number;
221
+ ttsTimeMs: number;
222
+ }
223
+
224
+ export function getWeekSummary(now = new Date()): StatsWeekSummary {
225
+ const dbPath = resolveStatsDbPath();
226
+ if (!existsSync(dbPath)) return emptyWeekSummary();
227
+ const since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
228
+ const db = openStatsDatabase(dbPath);
229
+ try {
230
+ const runs = db.query(
231
+ `select
232
+ count(*) as runs,
233
+ sum(case when status = 'success' then 1 else 0 end) as successes,
234
+ sum(case when status = 'failed' then 1 else 0 end) as failures
235
+ from runs
236
+ where started_at >= ?`,
237
+ ).get(since) as { runs: number; successes: number | null; failures: number | null };
238
+
239
+ const artifacts = db.query(
240
+ `select
241
+ count(*) as inputFiles,
242
+ coalesce(sum(size_bytes), 0) as inputBytes,
243
+ coalesce(sum(duration_ms), 0) as inputDurationMs
244
+ from artifacts
245
+ where kind = 'input_audio'
246
+ and run_id in (select id from runs where started_at >= ?)`,
247
+ ).get(since) as { inputFiles: number; inputBytes: number; inputDurationMs: number };
248
+
249
+ const stages = db.query(
250
+ `select
251
+ coalesce(sum(case when stage = 'transcribe' then duration_ms else 0 end), 0) as sttTimeMs,
252
+ coalesce(sum(case when stage = 'tts' then duration_ms else 0 end), 0) as ttsTimeMs
253
+ from stage_timings
254
+ where run_id in (select id from runs where started_at >= ?)`,
255
+ ).get(since) as { sttTimeMs: number; ttsTimeMs: number };
256
+
257
+ return {
258
+ runs: Number(runs.runs ?? 0),
259
+ successes: Number(runs.successes ?? 0),
260
+ failures: Number(runs.failures ?? 0),
261
+ inputFiles: Number(artifacts.inputFiles ?? 0),
262
+ inputBytes: Number(artifacts.inputBytes ?? 0),
263
+ inputDurationMs: Number(artifacts.inputDurationMs ?? 0),
264
+ sttTimeMs: Number(stages.sttTimeMs ?? 0),
265
+ ttsTimeMs: Number(stages.ttsTimeMs ?? 0),
266
+ };
267
+ } finally {
268
+ db.close();
269
+ }
270
+ }
271
+
272
+ export interface StatsErrorRow {
273
+ occurredAt: string;
274
+ command: string | null;
275
+ stage: string | null;
276
+ errorClass: string | null;
277
+ errorCode: string | null;
278
+ message: string;
279
+ }
280
+
281
+ export function getRecentErrors(limit = 20): StatsErrorRow[] {
282
+ const dbPath = resolveStatsDbPath();
283
+ if (!existsSync(dbPath)) return [];
284
+ const db = openStatsDatabase(dbPath);
285
+ try {
286
+ const rows = db.query(
287
+ `select
288
+ e.occurred_at as occurredAt,
289
+ r.command as command,
290
+ e.stage as stage,
291
+ e.error_class as errorClass,
292
+ e.error_code as errorCode,
293
+ e.sanitized_message as message
294
+ from errors e
295
+ left join runs r on r.id = e.run_id
296
+ order by e.occurred_at desc
297
+ limit ?`,
298
+ ).all(limit) as StatsErrorRow[];
299
+ return rows;
300
+ } finally {
301
+ db.close();
302
+ }
303
+ }
304
+
305
+ export function renderWeekSummary(summary: StatsWeekSummary): string {
306
+ const realtime = summary.inputDurationMs > 0 && summary.sttTimeMs > 0
307
+ ? `${(summary.inputDurationMs / summary.sttTimeMs).toFixed(1)}x`
308
+ : "n/a";
309
+ return [
310
+ "Kesha Stats - last 7 days",
311
+ `Runs: ${summary.runs} (${summary.successes} success, ${summary.failures} failed)`,
312
+ `Input files: ${summary.inputFiles}`,
313
+ `Input audio: ${humanDuration(summary.inputDurationMs)}`,
314
+ `Input size: ${humanBytes(summary.inputBytes)}`,
315
+ `STT time: ${humanDuration(summary.sttTimeMs)} (${realtime} realtime)`,
316
+ `TTS time: ${humanDuration(summary.ttsTimeMs)}`,
317
+ ].join("\n");
318
+ }
319
+
320
+ export function renderErrors(rows: StatsErrorRow[]): string {
321
+ if (rows.length === 0) return "Kesha Stats - no recorded errors";
322
+ const lines = ["Kesha Stats - recent errors"];
323
+ for (const row of rows) {
324
+ const parts = [
325
+ row.occurredAt,
326
+ row.command ?? "unknown",
327
+ row.stage ?? "unknown",
328
+ row.errorCode ?? row.errorClass ?? "error",
329
+ ];
330
+ lines.push(`${parts.join(" | ")} | ${row.message}`);
331
+ }
332
+ return lines.join("\n");
333
+ }
334
+
335
+ export function resolveStatsDbPath(): string {
336
+ if (process.env.KESHA_STATS_DB) return process.env.KESHA_STATS_DB;
337
+ if (process.platform === "darwin") {
338
+ return join(homedir(), "Library", "Application Support", "kesha", "stats.sqlite");
339
+ }
340
+ if (process.platform === "win32") {
341
+ const base = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
342
+ return join(base, "kesha", "stats.sqlite");
343
+ }
344
+ const base = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
345
+ return join(base, "kesha", "stats.sqlite");
346
+ }
347
+
348
+ export function artifactFromFile(
349
+ path: string,
350
+ kind: StatsArtifactInput["kind"],
351
+ ): StatsArtifactInput | null {
352
+ try {
353
+ const st = statSync(path);
354
+ if (!st.isFile()) return null;
355
+ return {
356
+ kind,
357
+ format: extname(path),
358
+ sizeBytes: st.size,
359
+ };
360
+ } catch {
361
+ return null;
362
+ }
363
+ }
364
+
365
+ export function artifactFromBytes(
366
+ bytes: number,
367
+ kind: StatsArtifactInput["kind"],
368
+ format?: string,
369
+ ): StatsArtifactInput {
370
+ return {
371
+ kind,
372
+ format,
373
+ sizeBytes: bytes,
374
+ };
375
+ }
376
+
377
+ export function sanitizeStatsError(err: unknown): { errorClass: string; message: string } {
378
+ const errorClass = err instanceof Error ? err.name : typeof err;
379
+ const raw = err instanceof Error ? err.message || err.name : String(err);
380
+ let message = raw
381
+ .split(/\r?\n/)
382
+ .filter((line) => !/^\s*at\s+/.test(line))
383
+ .join(" ")
384
+ .replace(/\s+/g, " ")
385
+ .trim();
386
+
387
+ const home = homedir();
388
+ const cwd = process.cwd();
389
+ for (const path of [home, cwd]) {
390
+ if (path) {
391
+ message = message.replace(new RegExp(escapeRegExp(path), "g"), "<path>");
392
+ }
393
+ }
394
+
395
+ message = message
396
+ .replace(/(https?:\/\/[^\s?]+)\?[^\s]+/g, "$1?<redacted>")
397
+ .replace(/("text"\s*:\s*)"[^"]*"/gi, '$1"<redacted>"')
398
+ .replace(/("transcript"\s*:\s*)"[^"]*"/gi, '$1"<redacted>"')
399
+ .replace(/("stdout"\s*:\s*)"[^"]*"/gi, '$1"<redacted>"')
400
+ .replace(/("stderr"\s*:\s*)"[^"]*"/gi, '$1"<redacted>"')
401
+ .replace(/\/(?:Users|home|tmp|private\/tmp|var\/folders)\/[^\s)"']+/g, "<path>")
402
+ .replace(/[A-Za-z]:\\[^\s)"']+/g, "<path>");
403
+
404
+ if (message.length === 0) message = errorClass || "unknown error";
405
+ if (message.length > MAX_ERROR_MESSAGE_CHARS) {
406
+ message = `${message.slice(0, MAX_ERROR_MESSAGE_CHARS - 3)}...`;
407
+ }
408
+ return { errorClass, message };
409
+ }
410
+
411
+ function openStatsDatabase(dbPath: string): Database {
412
+ mkdirSync(dirname(dbPath), { recursive: true });
413
+ const db = new Database(dbPath);
414
+ try {
415
+ db.exec("pragma journal_mode = WAL");
416
+ db.exec("pragma busy_timeout = 1000");
417
+ migrateStatsDatabase(db);
418
+ return db;
419
+ } catch (err) {
420
+ db.close();
421
+ throw err;
422
+ }
423
+ }
424
+
425
+ function migrateStatsDatabase(db: Database): void {
426
+ db.exec(`
427
+ create table if not exists schema_migrations (
428
+ version integer primary key,
429
+ applied_at text not null
430
+ );
431
+ `);
432
+
433
+ const currentVersion = currentSchemaVersion(db);
434
+ if (currentVersion >= SCHEMA_VERSION) return;
435
+
436
+ if (currentVersion < 1) {
437
+ db.exec(`
438
+ create table if not exists settings (
439
+ key text primary key,
440
+ value text not null
441
+ );
442
+
443
+ create table if not exists runs (
444
+ id integer primary key autoincrement,
445
+ command text not null,
446
+ started_at text not null,
447
+ finished_at text,
448
+ status text not null,
449
+ app_version text not null,
450
+ item_count integer not null default 0
451
+ );
452
+
453
+ create table if not exists artifacts (
454
+ id integer primary key autoincrement,
455
+ run_id integer not null references runs(id),
456
+ kind text not null,
457
+ format text,
458
+ size_bytes integer,
459
+ duration_ms integer,
460
+ sample_rate integer,
461
+ channels integer
462
+ );
463
+
464
+ create table if not exists stage_timings (
465
+ id integer primary key autoincrement,
466
+ run_id integer not null references runs(id),
467
+ stage text not null,
468
+ started_at text not null,
469
+ duration_ms integer not null,
470
+ status text not null
471
+ );
472
+
473
+ create table if not exists errors (
474
+ id integer primary key autoincrement,
475
+ run_id integer references runs(id),
476
+ stage text,
477
+ error_class text,
478
+ error_code text,
479
+ sanitized_message text not null,
480
+ occurred_at text not null
481
+ );
482
+
483
+ create index if not exists runs_started_at_idx on runs(started_at);
484
+ create index if not exists stage_timings_run_id_idx on stage_timings(run_id);
485
+ create index if not exists artifacts_run_id_idx on artifacts(run_id);
486
+ create index if not exists errors_occurred_at_idx on errors(occurred_at);
487
+ `);
488
+
489
+ recordSchemaVersion(db, 1);
490
+ }
491
+ }
492
+
493
+ function currentSchemaVersion(db: Database): number {
494
+ const row = db.query("select coalesce(max(version), 0) as version from schema_migrations").get() as {
495
+ version: number;
496
+ };
497
+ return Number(row.version ?? 0);
498
+ }
499
+
500
+ function recordSchemaVersion(db: Database, version: number): void {
501
+ db.query(
502
+ "insert or ignore into schema_migrations (version, applied_at) values (?, ?)",
503
+ ).run(version, new Date().toISOString());
504
+ }
505
+
506
+ function getStatsEnabled(db: Database): boolean {
507
+ const row = db.query("select value from settings where key = 'enabled'").get() as
508
+ | { value: string }
509
+ | null;
510
+ return row?.value === "1";
511
+ }
512
+
513
+ function setSetting(db: Database, key: string, value: string): void {
514
+ db.query(
515
+ "insert into settings (key, value) values (?, ?) on conflict(key) do update set value = excluded.value",
516
+ ).run(key, value);
517
+ }
518
+
519
+ function insertRun(db: Database, command: StatsCommandName): number {
520
+ const result = db.query(
521
+ "insert into runs (command, started_at, status, app_version) values (?, ?, 'failed', ?) returning id",
522
+ ).get(command, new Date().toISOString(), APP_VERSION) as { id: number };
523
+ return Number(result.id);
524
+ }
525
+
526
+ function normalizeFormat(format?: string | null): string | null {
527
+ if (!format) return null;
528
+ return format.replace(/^\./, "").toLowerCase();
529
+ }
530
+
531
+ function emptyWeekSummary(): StatsWeekSummary {
532
+ return {
533
+ runs: 0,
534
+ successes: 0,
535
+ failures: 0,
536
+ inputFiles: 0,
537
+ inputBytes: 0,
538
+ inputDurationMs: 0,
539
+ sttTimeMs: 0,
540
+ ttsTimeMs: 0,
541
+ };
542
+ }
543
+
544
+ function humanBytes(bytes: number): string {
545
+ if (bytes < 1024) return `${bytes} B`;
546
+ const units = ["KB", "MB", "GB", "TB"];
547
+ let n = bytes / 1024;
548
+ let i = 0;
549
+ while (n >= 1024 && i < units.length - 1) {
550
+ n /= 1024;
551
+ i++;
552
+ }
553
+ return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
554
+ }
555
+
556
+ function humanDuration(ms: number): string {
557
+ const seconds = Math.round(ms / 1000);
558
+ if (seconds < 1) return `${ms}ms`;
559
+ const h = Math.floor(seconds / 3600);
560
+ const m = Math.floor((seconds % 3600) / 60);
561
+ const s = seconds % 60;
562
+ if (h > 0) return `${h}h ${m}m ${s}s`;
563
+ if (m > 0) return `${m}m ${s}s`;
564
+ return `${s}s`;
565
+ }
566
+
567
+ function escapeRegExp(input: string): string {
568
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
569
+ }
570
+
571
+ function readPackageVersion(): string {
572
+ try {
573
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
574
+ version?: unknown;
575
+ };
576
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
577
+ } catch {
578
+ return "unknown";
579
+ }
580
+ }