@rubytech/taskmaster 1.2.1 → 1.3.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/dist/agents/auth-profiles/profiles.js +37 -0
- package/dist/agents/auth-profiles.js +1 -1
- package/dist/agents/pi-tools.policy.js +4 -0
- package/dist/agents/taskmaster-tools.js +14 -0
- package/dist/agents/tool-policy.js +5 -2
- package/dist/agents/tools/apikeys-tool.js +16 -5
- package/dist/agents/tools/contact-create-tool.js +59 -0
- package/dist/agents/tools/contact-delete-tool.js +48 -0
- package/dist/agents/tools/contact-update-tool.js +17 -2
- package/dist/agents/tools/file-delete-tool.js +137 -0
- package/dist/agents/tools/file-list-tool.js +127 -0
- package/dist/auto-reply/reply/commands-tts.js +7 -2
- package/dist/build-info.json +3 -3
- package/dist/cli/provision-seed.js +1 -2
- package/dist/commands/doctor-config-flow.js +13 -0
- package/dist/config/agent-tools-reconcile.js +53 -0
- package/dist/config/defaults.js +10 -1
- package/dist/config/legacy.migrations.part-3.js +26 -0
- package/dist/config/zod-schema.core.js +9 -1
- package/dist/config/zod-schema.js +1 -0
- package/dist/control-ui/assets/index-CPawOl_z.css +1 -0
- package/dist/control-ui/assets/{index-N8du4fwV.js → index-DQ1kxYd4.js} +692 -598
- package/dist/control-ui/assets/index-DQ1kxYd4.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/gateway/config-reload.js +1 -0
- package/dist/gateway/media-http.js +28 -0
- package/dist/gateway/server-methods/apikeys.js +56 -4
- package/dist/gateway/server-methods/tts.js +11 -2
- package/dist/gateway/server.impl.js +15 -0
- package/dist/media-understanding/apply.js +35 -0
- package/dist/media-understanding/providers/deepgram/audio.js +1 -1
- package/dist/media-understanding/providers/google/audio.js +1 -1
- package/dist/media-understanding/providers/google/video.js +1 -1
- package/dist/media-understanding/providers/index.js +2 -0
- package/dist/media-understanding/providers/openai/audio.js +1 -1
- package/dist/media-understanding/providers/sherpa-onnx/index.js +10 -0
- package/dist/media-understanding/runner.js +61 -72
- package/dist/media-understanding/sherpa-onnx-local.js +223 -0
- package/dist/records/records-manager.js +10 -0
- package/dist/tts/tts.js +98 -10
- package/dist/web/auto-reply/monitor/process-message.js +1 -0
- package/dist/web/inbound/monitor.js +9 -1
- package/extensions/googlechat/node_modules/.bin/taskmaster +2 -2
- package/extensions/googlechat/package.json +2 -2
- package/extensions/line/node_modules/.bin/taskmaster +2 -2
- package/extensions/line/package.json +1 -1
- package/extensions/matrix/node_modules/.bin/taskmaster +2 -2
- package/extensions/matrix/package.json +1 -1
- package/extensions/msteams/node_modules/.bin/taskmaster +2 -2
- package/extensions/msteams/package.json +1 -1
- package/extensions/nostr/node_modules/.bin/taskmaster +2 -2
- package/extensions/nostr/package.json +1 -1
- package/extensions/zalo/node_modules/.bin/taskmaster +2 -2
- package/extensions/zalo/package.json +1 -1
- package/extensions/zalouser/node_modules/.bin/taskmaster +2 -2
- package/extensions/zalouser/package.json +1 -1
- package/package.json +3 -2
- package/scripts/postinstall.js +76 -0
- package/skills/business-assistant/references/crm.md +32 -8
- package/taskmaster-docs/USER-GUIDE.md +84 -5
- package/templates/beagle/agents/admin/AGENTS.md +4 -2
- package/templates/taskmaster/agents/admin/AGENTS.md +1 -0
- package/dist/control-ui/assets/index-DtQHRIVD.css +0 -1
- package/dist/control-ui/assets/index-N8du4fwV.js.map +0 -1
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local speech-to-text using sherpa-onnx-node (optional dependency).
|
|
3
|
+
*
|
|
4
|
+
* Model: NeMo CTC English Conformer Small (int8)
|
|
5
|
+
* Storage: ~/.taskmaster/lib/sherpa-onnx/nemo-ctc-en-conformer-small/
|
|
6
|
+
*
|
|
7
|
+
* Requires: sherpa-onnx-node (optional dep), ffmpeg (for OGG→WAV conversion)
|
|
8
|
+
*/
|
|
9
|
+
import { execFile as execFileCb, spawnSync } from "node:child_process";
|
|
10
|
+
import fs from "node:fs/promises";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
const execFile = promisify(execFileCb);
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Constants
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const LIB_DIR = path.join(os.homedir(), ".taskmaster", "lib", "sherpa-onnx");
|
|
19
|
+
const MODEL_DIR_NAME = "nemo-ctc-en-conformer-small";
|
|
20
|
+
const MODEL_DIR = path.join(LIB_DIR, MODEL_DIR_NAME);
|
|
21
|
+
const MODEL_FILE = "model.int8.onnx";
|
|
22
|
+
const TOKENS_FILE = "tokens.txt";
|
|
23
|
+
const MODEL_ARCHIVE_URL = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-ctc-en-conformer-small.tar.bz2";
|
|
24
|
+
const ARCHIVE_INNER_DIR = "sherpa-onnx-nemo-ctc-en-conformer-small";
|
|
25
|
+
export const MODEL_LABEL = "sherpa-onnx/nemo-ctc-en-conformer-small-int8";
|
|
26
|
+
let sherpaModule = null;
|
|
27
|
+
let sherpaModuleError = null;
|
|
28
|
+
async function importSherpaOnnx() {
|
|
29
|
+
if (sherpaModule)
|
|
30
|
+
return sherpaModule;
|
|
31
|
+
if (sherpaModuleError)
|
|
32
|
+
throw sherpaModuleError;
|
|
33
|
+
try {
|
|
34
|
+
const mod = await import("sherpa-onnx-node");
|
|
35
|
+
// ESM dynamic import puts named exports under .default
|
|
36
|
+
sherpaModule = (mod.default ?? mod);
|
|
37
|
+
return sherpaModule;
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
sherpaModuleError = err instanceof Error ? err : new Error(String(err));
|
|
41
|
+
throw sherpaModuleError;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Availability checks
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
let ffmpegAvailable = null;
|
|
48
|
+
function checkFfmpeg() {
|
|
49
|
+
if (ffmpegAvailable !== null)
|
|
50
|
+
return ffmpegAvailable;
|
|
51
|
+
const result = spawnSync("ffmpeg", ["-version"], { stdio: "ignore", timeout: 5_000 });
|
|
52
|
+
ffmpegAvailable = result.status === 0;
|
|
53
|
+
return ffmpegAvailable;
|
|
54
|
+
}
|
|
55
|
+
async function fileExists(filePath) {
|
|
56
|
+
try {
|
|
57
|
+
await fs.stat(filePath);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function isModelDownloaded() {
|
|
65
|
+
return ((await fileExists(path.join(MODEL_DIR, MODEL_FILE))) &&
|
|
66
|
+
(await fileExists(path.join(MODEL_DIR, TOKENS_FILE))));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Returns true when both the npm package and ffmpeg are available.
|
|
70
|
+
* Does NOT check whether the model is downloaded (use isReady for that).
|
|
71
|
+
*/
|
|
72
|
+
export async function isAvailable() {
|
|
73
|
+
try {
|
|
74
|
+
await importSherpaOnnx();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return checkFfmpeg();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Returns true when everything is ready for immediate transcription:
|
|
83
|
+
* npm package + ffmpeg + model files on disk.
|
|
84
|
+
*/
|
|
85
|
+
export async function isReady() {
|
|
86
|
+
return (await isAvailable()) && (await isModelDownloaded());
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Model download
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
let downloadInFlight = null;
|
|
92
|
+
/**
|
|
93
|
+
* Download and extract the model if not already present.
|
|
94
|
+
* Deduplicates concurrent calls.
|
|
95
|
+
*/
|
|
96
|
+
export async function ensureModel() {
|
|
97
|
+
if (await isModelDownloaded())
|
|
98
|
+
return MODEL_DIR;
|
|
99
|
+
if (!downloadInFlight) {
|
|
100
|
+
downloadInFlight = downloadModelOnce().finally(() => {
|
|
101
|
+
downloadInFlight = null;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
await downloadInFlight;
|
|
105
|
+
return MODEL_DIR;
|
|
106
|
+
}
|
|
107
|
+
async function downloadModelOnce() {
|
|
108
|
+
console.log("[sherpa-onnx] Downloading speech recognition model…");
|
|
109
|
+
await fs.mkdir(LIB_DIR, { recursive: true });
|
|
110
|
+
const archivePath = path.join(LIB_DIR, `${MODEL_DIR_NAME}.tar.bz2`);
|
|
111
|
+
try {
|
|
112
|
+
// Download archive using curl (available on macOS and Linux)
|
|
113
|
+
await execFile("curl", ["-fsSL", "-o", archivePath, MODEL_ARCHIVE_URL], {
|
|
114
|
+
timeout: 300_000,
|
|
115
|
+
});
|
|
116
|
+
// Extract — archive contains sherpa-onnx-nemo-ctc-en-conformer-small/{files}
|
|
117
|
+
await execFile("tar", ["-xjf", archivePath, "-C", LIB_DIR], {
|
|
118
|
+
timeout: 120_000,
|
|
119
|
+
});
|
|
120
|
+
// Rename extracted directory to our standard name (remove sherpa-onnx- prefix)
|
|
121
|
+
const extractedDir = path.join(LIB_DIR, ARCHIVE_INNER_DIR);
|
|
122
|
+
if ((await fileExists(extractedDir)) && extractedDir !== MODEL_DIR) {
|
|
123
|
+
// If target already exists (partial state), remove it first
|
|
124
|
+
if (await fileExists(MODEL_DIR)) {
|
|
125
|
+
await fs.rm(MODEL_DIR, { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
await fs.rename(extractedDir, MODEL_DIR);
|
|
128
|
+
}
|
|
129
|
+
// Clean up: remove archive and unnecessary files to save disk space
|
|
130
|
+
await fs.unlink(archivePath).catch(() => { });
|
|
131
|
+
// Remove full-precision model (keep only int8 — saves ~37MB)
|
|
132
|
+
await fs.unlink(path.join(MODEL_DIR, "model.onnx")).catch(() => { });
|
|
133
|
+
// Remove test wav files
|
|
134
|
+
await fs
|
|
135
|
+
.rm(path.join(MODEL_DIR, "test_wavs"), { recursive: true, force: true })
|
|
136
|
+
.catch(() => { });
|
|
137
|
+
console.log("[sherpa-onnx] Model installed");
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
// Clean up partial download
|
|
141
|
+
await fs.unlink(archivePath).catch(() => { });
|
|
142
|
+
await fs.rm(MODEL_DIR, { recursive: true, force: true }).catch(() => { });
|
|
143
|
+
throw new Error(`sherpa-onnx model download failed: ${String(err)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Audio conversion (OGG/Opus → WAV 16kHz mono)
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
async function convertToWav(inputPath, outputPath) {
|
|
150
|
+
await execFile("ffmpeg", [
|
|
151
|
+
"-i",
|
|
152
|
+
inputPath,
|
|
153
|
+
"-ar",
|
|
154
|
+
"16000",
|
|
155
|
+
"-ac",
|
|
156
|
+
"1",
|
|
157
|
+
"-sample_fmt",
|
|
158
|
+
"s16",
|
|
159
|
+
"-f",
|
|
160
|
+
"wav",
|
|
161
|
+
"-y",
|
|
162
|
+
outputPath,
|
|
163
|
+
], { timeout: 30_000 });
|
|
164
|
+
}
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Recognizer (cached singleton)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
let recognizer = null;
|
|
169
|
+
async function getRecognizer() {
|
|
170
|
+
if (recognizer)
|
|
171
|
+
return recognizer;
|
|
172
|
+
const sherpa = await importSherpaOnnx();
|
|
173
|
+
const modelDir = await ensureModel();
|
|
174
|
+
const config = {
|
|
175
|
+
featConfig: { sampleRate: 16000, featureDim: 80 },
|
|
176
|
+
modelConfig: {
|
|
177
|
+
nemoCtc: {
|
|
178
|
+
model: path.join(modelDir, MODEL_FILE),
|
|
179
|
+
},
|
|
180
|
+
tokens: path.join(modelDir, TOKENS_FILE),
|
|
181
|
+
numThreads: 2,
|
|
182
|
+
provider: "cpu",
|
|
183
|
+
debug: 0,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
recognizer = new sherpa.OfflineRecognizer(config);
|
|
187
|
+
return recognizer;
|
|
188
|
+
}
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Public transcription API
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
/**
|
|
193
|
+
* Transcribe an audio buffer (any format ffmpeg can read) to text.
|
|
194
|
+
* Returns the transcription text, or throws on failure.
|
|
195
|
+
*/
|
|
196
|
+
export async function transcribeLocal(buffer, fileName) {
|
|
197
|
+
const sherpa = await importSherpaOnnx();
|
|
198
|
+
const rec = await getRecognizer();
|
|
199
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sherpa-stt-"));
|
|
200
|
+
try {
|
|
201
|
+
const inputPath = path.join(tmpDir, fileName || "audio.ogg");
|
|
202
|
+
await fs.writeFile(inputPath, buffer);
|
|
203
|
+
const wavPath = path.join(tmpDir, "audio.wav");
|
|
204
|
+
await convertToWav(inputPath, wavPath);
|
|
205
|
+
const wave = sherpa.readWave(wavPath);
|
|
206
|
+
const stream = rec.createStream();
|
|
207
|
+
stream.acceptWaveform({ sampleRate: wave.sampleRate, samples: wave.samples });
|
|
208
|
+
rec.decode(stream);
|
|
209
|
+
const result = rec.getResult(stream);
|
|
210
|
+
const text = result?.text?.trim() ?? "";
|
|
211
|
+
if (!text) {
|
|
212
|
+
throw new Error("sherpa-onnx returned empty transcription");
|
|
213
|
+
}
|
|
214
|
+
return { text, model: MODEL_LABEL };
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Exported paths (for postinstall and diagnostics)
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
export { LIB_DIR, MODEL_DIR, MODEL_DIR_NAME, MODEL_FILE, TOKENS_FILE, MODEL_ARCHIVE_URL, ARCHIVE_INNER_DIR, };
|
|
@@ -90,3 +90,13 @@ export function deleteRecordField(id, key) {
|
|
|
90
90
|
writeFile(data);
|
|
91
91
|
return record;
|
|
92
92
|
}
|
|
93
|
+
export function setRecordName(id, name) {
|
|
94
|
+
const data = readFile();
|
|
95
|
+
const record = data.records[id];
|
|
96
|
+
if (!record)
|
|
97
|
+
return null;
|
|
98
|
+
record.name = name;
|
|
99
|
+
record.updatedAt = new Date().toISOString();
|
|
100
|
+
writeFile(data);
|
|
101
|
+
return record;
|
|
102
|
+
}
|
package/dist/tts/tts.js
CHANGED
|
@@ -5,11 +5,13 @@ import { completeSimple } from "@mariozechner/pi-ai";
|
|
|
5
5
|
import { EdgeTTS } from "node-edge-tts";
|
|
6
6
|
import { normalizeChannelId } from "../channels/plugins/index.js";
|
|
7
7
|
import { logVerbose } from "../globals.js";
|
|
8
|
+
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
8
9
|
import { isVoiceCompatibleAudio } from "../media/audio.js";
|
|
9
10
|
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
|
10
|
-
import { getApiKeyForModel, requireApiKey } from "../agents/model-auth.js";
|
|
11
|
+
import { getApiKeyForModel, requireApiKey, resolveApiKeyForProvider, } from "../agents/model-auth.js";
|
|
11
12
|
import { buildModelAliasIndex, resolveDefaultModelForAgent, resolveModelRefFromString, } from "../agents/model-selection.js";
|
|
12
13
|
import { resolveModel } from "../agents/pi-embedded-runner/model.js";
|
|
14
|
+
const log = createSubsystemLogger("gateway/tts");
|
|
13
15
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
14
16
|
const DEFAULT_TTS_MAX_LENGTH = 1500;
|
|
15
17
|
const DEFAULT_TTS_SUMMARIZE = true;
|
|
@@ -133,6 +135,11 @@ export function resolveTtsConfig(cfg) {
|
|
|
133
135
|
proxy: raw.edge?.proxy?.trim() || undefined,
|
|
134
136
|
timeoutMs: raw.edge?.timeoutMs,
|
|
135
137
|
},
|
|
138
|
+
hume: {
|
|
139
|
+
apiKey: raw.hume?.apiKey,
|
|
140
|
+
voice: raw.hume?.voice?.trim() || "KORA",
|
|
141
|
+
description: raw.hume?.description?.trim() || undefined,
|
|
142
|
+
},
|
|
136
143
|
prefsPath: raw.prefsPath,
|
|
137
144
|
maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
|
|
138
145
|
timeoutMs: raw.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
@@ -173,15 +180,15 @@ export function buildTtsSystemPromptHint(cfg) {
|
|
|
173
180
|
const maxLength = getTtsMaxLength(prefsPath);
|
|
174
181
|
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
|
|
175
182
|
const autoHint = autoMode === "inbound"
|
|
176
|
-
? "
|
|
183
|
+
? "Your replies are automatically converted to voice notes when the user sends audio/voice."
|
|
177
184
|
: autoMode === "tagged"
|
|
178
|
-
? "
|
|
179
|
-
:
|
|
185
|
+
? "Your replies are converted to voice notes when you include [[tts]] or [[tts:text]] tags."
|
|
186
|
+
: "Your replies are automatically converted to voice notes and sent as audio messages.";
|
|
180
187
|
return [
|
|
181
|
-
"Voice
|
|
188
|
+
"Voice replies are enabled — you CAN send voice notes.",
|
|
182
189
|
autoHint,
|
|
190
|
+
"Write naturally as if speaking. Do not tell the user you cannot send voice messages — you can.",
|
|
183
191
|
`Keep spoken text ≤${maxLength} chars to avoid auto-summary (summary ${summarize}).`,
|
|
184
|
-
"Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.",
|
|
185
192
|
]
|
|
186
193
|
.filter(Boolean)
|
|
187
194
|
.join("\n");
|
|
@@ -291,15 +298,20 @@ export function resolveTtsApiKey(config, provider) {
|
|
|
291
298
|
if (provider === "openai") {
|
|
292
299
|
return config.openai.apiKey || process.env.OPENAI_API_KEY;
|
|
293
300
|
}
|
|
301
|
+
if (provider === "hume") {
|
|
302
|
+
return config.hume.apiKey || process.env.HUME_API_KEY;
|
|
303
|
+
}
|
|
294
304
|
return undefined;
|
|
295
305
|
}
|
|
296
|
-
export const TTS_PROVIDERS = ["openai", "elevenlabs", "edge"];
|
|
306
|
+
export const TTS_PROVIDERS = ["openai", "elevenlabs", "hume", "edge"];
|
|
297
307
|
export function resolveTtsProviderOrder(primary) {
|
|
298
308
|
return [primary, ...TTS_PROVIDERS.filter((provider) => provider !== primary)];
|
|
299
309
|
}
|
|
300
310
|
export function isTtsProviderConfigured(config, provider) {
|
|
301
311
|
if (provider === "edge")
|
|
302
312
|
return config.edge.enabled;
|
|
313
|
+
if (provider === "hume")
|
|
314
|
+
return Boolean(resolveTtsApiKey(config, provider));
|
|
303
315
|
return Boolean(resolveTtsApiKey(config, provider));
|
|
304
316
|
}
|
|
305
317
|
function isValidVoiceId(voiceId) {
|
|
@@ -350,6 +362,14 @@ function normalizeSeed(seed) {
|
|
|
350
362
|
}
|
|
351
363
|
return next;
|
|
352
364
|
}
|
|
365
|
+
/** Strip emoji characters so TTS engines don't read them aloud by name. */
|
|
366
|
+
function stripEmoji(text) {
|
|
367
|
+
// Unicode emoji ranges: emoticons, dingbats, symbols, flags, components
|
|
368
|
+
return text
|
|
369
|
+
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, "")
|
|
370
|
+
.replace(/ +/g, " ")
|
|
371
|
+
.trim();
|
|
372
|
+
}
|
|
353
373
|
function parseBooleanValue(value) {
|
|
354
374
|
const normalized = value.trim().toLowerCase();
|
|
355
375
|
if (["true", "1", "yes", "on"].includes(normalized))
|
|
@@ -777,6 +797,50 @@ async function openaiTTS(params) {
|
|
|
777
797
|
clearTimeout(timeout);
|
|
778
798
|
}
|
|
779
799
|
}
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
// Hume Octave TTS — REST API
|
|
802
|
+
// POST https://api.hume.ai/v0/tts
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
const HUME_TTS_BASE_URL = "https://api.hume.ai/v0/tts";
|
|
805
|
+
async function humeTTS(params) {
|
|
806
|
+
const { text, apiKey, voice, description, timeoutMs } = params;
|
|
807
|
+
const controller = new AbortController();
|
|
808
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
809
|
+
try {
|
|
810
|
+
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(voice);
|
|
811
|
+
const voiceSpec = isUuid ? { id: voice } : { name: voice, provider: "HUME_AI" };
|
|
812
|
+
const utterance = { text, voice: voiceSpec };
|
|
813
|
+
if (description) {
|
|
814
|
+
utterance.description = description;
|
|
815
|
+
}
|
|
816
|
+
const response = await fetch(HUME_TTS_BASE_URL, {
|
|
817
|
+
method: "POST",
|
|
818
|
+
headers: {
|
|
819
|
+
"X-Hume-Api-Key": apiKey,
|
|
820
|
+
"Content-Type": "application/json",
|
|
821
|
+
},
|
|
822
|
+
body: JSON.stringify({
|
|
823
|
+
utterances: [utterance],
|
|
824
|
+
format: { type: "mp3" },
|
|
825
|
+
strip_headers: true,
|
|
826
|
+
}),
|
|
827
|
+
signal: controller.signal,
|
|
828
|
+
});
|
|
829
|
+
if (!response.ok) {
|
|
830
|
+
const body = await response.text().catch(() => "");
|
|
831
|
+
throw new Error(`Hume TTS API error (${response.status}): ${body.slice(0, 200)}`);
|
|
832
|
+
}
|
|
833
|
+
const json = (await response.json());
|
|
834
|
+
const audio64 = json.generations?.[0]?.audio;
|
|
835
|
+
if (!audio64) {
|
|
836
|
+
throw new Error("Hume TTS: no audio in response");
|
|
837
|
+
}
|
|
838
|
+
return Buffer.from(audio64, "base64");
|
|
839
|
+
}
|
|
840
|
+
finally {
|
|
841
|
+
clearTimeout(timeout);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
780
844
|
function inferEdgeExtension(outputFormat) {
|
|
781
845
|
const normalized = outputFormat.toLowerCase();
|
|
782
846
|
if (normalized.includes("webm"))
|
|
@@ -888,7 +952,17 @@ export async function textToSpeech(params) {
|
|
|
888
952
|
voiceCompatible,
|
|
889
953
|
};
|
|
890
954
|
}
|
|
891
|
-
|
|
955
|
+
let apiKey = resolveTtsApiKey(config, provider);
|
|
956
|
+
// Hume keys are stored in auth profiles (API Keys UI). Fall back to async lookup.
|
|
957
|
+
if (!apiKey && provider === "hume") {
|
|
958
|
+
try {
|
|
959
|
+
const auth = await resolveApiKeyForProvider({ provider: "hume", cfg: params.cfg });
|
|
960
|
+
apiKey = auth.apiKey?.trim() || undefined;
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
// No auth profile key available
|
|
964
|
+
}
|
|
965
|
+
}
|
|
892
966
|
if (!apiKey) {
|
|
893
967
|
lastError = `No API key for ${provider}`;
|
|
894
968
|
continue;
|
|
@@ -918,6 +992,15 @@ export async function textToSpeech(params) {
|
|
|
918
992
|
timeoutMs: config.timeoutMs,
|
|
919
993
|
});
|
|
920
994
|
}
|
|
995
|
+
else if (provider === "hume") {
|
|
996
|
+
audioBuffer = await humeTTS({
|
|
997
|
+
text: params.text,
|
|
998
|
+
apiKey,
|
|
999
|
+
voice: config.hume.voice,
|
|
1000
|
+
description: config.hume.description,
|
|
1001
|
+
timeoutMs: config.timeoutMs,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
921
1004
|
else {
|
|
922
1005
|
const openaiModelOverride = params.overrides?.openai?.model;
|
|
923
1006
|
const openaiVoiceOverride = params.overrides?.openai?.voice;
|
|
@@ -931,6 +1014,7 @@ export async function textToSpeech(params) {
|
|
|
931
1014
|
});
|
|
932
1015
|
}
|
|
933
1016
|
const latencyMs = Date.now() - providerStart;
|
|
1017
|
+
log.info(`success via ${provider} (${latencyMs}ms, ${audioBuffer.length} bytes)`);
|
|
934
1018
|
const tempDir = mkdtempSync(path.join(tmpdir(), "tts-"));
|
|
935
1019
|
const audioPath = path.join(tempDir, `voice-${Date.now()}${output.extension}`);
|
|
936
1020
|
writeFileSync(audioPath, audioBuffer);
|
|
@@ -940,7 +1024,7 @@ export async function textToSpeech(params) {
|
|
|
940
1024
|
audioPath,
|
|
941
1025
|
latencyMs,
|
|
942
1026
|
provider,
|
|
943
|
-
outputFormat: provider === "openai" ? output.openai : output.elevenlabs,
|
|
1027
|
+
outputFormat: provider === "openai" ? output.openai : provider === "hume" ? "mp3" : output.elevenlabs,
|
|
944
1028
|
voiceCompatible: output.voiceCompatible,
|
|
945
1029
|
};
|
|
946
1030
|
}
|
|
@@ -952,8 +1036,10 @@ export async function textToSpeech(params) {
|
|
|
952
1036
|
else {
|
|
953
1037
|
lastError = `${provider}: ${error.message}`;
|
|
954
1038
|
}
|
|
1039
|
+
log.error(`${provider} failed: ${lastError}`);
|
|
955
1040
|
}
|
|
956
1041
|
}
|
|
1042
|
+
log.error(`all providers failed: ${lastError || "none available"}`);
|
|
957
1043
|
return {
|
|
958
1044
|
success: false,
|
|
959
1045
|
error: `TTS conversion failed: ${lastError || "no providers available"}`,
|
|
@@ -1033,8 +1119,10 @@ export async function textToSpeechTelephony(params) {
|
|
|
1033
1119
|
else {
|
|
1034
1120
|
lastError = `${provider}: ${error.message}`;
|
|
1035
1121
|
}
|
|
1122
|
+
log.error(`telephony: ${provider} failed: ${lastError}`);
|
|
1036
1123
|
}
|
|
1037
1124
|
}
|
|
1125
|
+
log.error(`telephony: all providers failed: ${lastError || "none available"}`);
|
|
1038
1126
|
return {
|
|
1039
1127
|
success: false,
|
|
1040
1128
|
error: `TTS conversion failed: ${lastError || "no providers available"}`,
|
|
@@ -1081,7 +1169,7 @@ export async function maybeApplyTtsToPayload(params) {
|
|
|
1081
1169
|
if (ttsText.trim().length < 10)
|
|
1082
1170
|
return nextPayload;
|
|
1083
1171
|
const maxLength = getTtsMaxLength(prefsPath);
|
|
1084
|
-
let textForAudio = ttsText.trim();
|
|
1172
|
+
let textForAudio = stripEmoji(ttsText.trim());
|
|
1085
1173
|
let wasSummarized = false;
|
|
1086
1174
|
if (textForAudio.length > maxLength) {
|
|
1087
1175
|
if (!isSummarizationEnabled(prefsPath)) {
|
|
@@ -233,6 +233,7 @@ export async function processMessage(params) {
|
|
|
233
233
|
MediaPath: params.msg.mediaPath,
|
|
234
234
|
MediaUrl: params.msg.mediaUrl,
|
|
235
235
|
MediaType: params.msg.mediaType,
|
|
236
|
+
MediaDownloadFailed: params.msg.mediaDownloadFailed,
|
|
236
237
|
ChatType: params.msg.chatType,
|
|
237
238
|
ConversationLabel: params.msg.chatType === "group" ? conversationId : params.msg.from,
|
|
238
239
|
GroupSubject: params.msg.groupSubject,
|
|
@@ -230,6 +230,7 @@ export async function monitorWebInbox(options) {
|
|
|
230
230
|
const replyContext = describeReplyContext(msg.message);
|
|
231
231
|
let mediaPath;
|
|
232
232
|
let mediaType;
|
|
233
|
+
let mediaDownloadFailed = false;
|
|
233
234
|
try {
|
|
234
235
|
const inboundMedia = await downloadInboundMedia(msg, sock);
|
|
235
236
|
if (inboundMedia) {
|
|
@@ -241,9 +242,15 @@ export async function monitorWebInbox(options) {
|
|
|
241
242
|
mediaPath = saved.path;
|
|
242
243
|
mediaType = inboundMedia.mimetype;
|
|
243
244
|
}
|
|
245
|
+
else if (body.includes("<media:")) {
|
|
246
|
+
// downloadInboundMedia returned undefined — media was expected but unavailable
|
|
247
|
+
mediaDownloadFailed = true;
|
|
248
|
+
inboundLogger.warn({ id, from, body }, "media download returned empty");
|
|
249
|
+
}
|
|
244
250
|
}
|
|
245
251
|
catch (err) {
|
|
246
|
-
|
|
252
|
+
mediaDownloadFailed = body.includes("<media:");
|
|
253
|
+
inboundLogger.warn({ id, from, error: String(err) }, "media download failed");
|
|
247
254
|
}
|
|
248
255
|
const chatJid = remoteJid;
|
|
249
256
|
const sendComposing = async () => {
|
|
@@ -294,6 +301,7 @@ export async function monitorWebInbox(options) {
|
|
|
294
301
|
sendMedia,
|
|
295
302
|
mediaPath,
|
|
296
303
|
mediaType,
|
|
304
|
+
mediaDownloadFailed,
|
|
297
305
|
};
|
|
298
306
|
try {
|
|
299
307
|
const task = Promise.resolve(debouncer.enqueue(inboundMessage));
|
|
@@ -15,7 +15,7 @@ else
|
|
|
15
15
|
export NODE_PATH="/Users/neo/taskmaster-dev/dist/node_modules:/Users/neo/taskmaster-dev/node_modules:/Users/neo/node_modules:/Users/node_modules:/node_modules:/Users/neo/taskmaster-dev/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
16
16
|
fi
|
|
17
17
|
if [ -x "$basedir/node" ]; then
|
|
18
|
-
exec "$basedir/node" "$basedir
|
|
18
|
+
exec "$basedir/node" "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
19
19
|
else
|
|
20
|
-
exec node "$basedir
|
|
20
|
+
exec node "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
21
21
|
fi
|
|
@@ -15,7 +15,7 @@ else
|
|
|
15
15
|
export NODE_PATH="/Users/neo/taskmaster-dev/dist/node_modules:/Users/neo/taskmaster-dev/node_modules:/Users/neo/node_modules:/Users/node_modules:/node_modules:/Users/neo/taskmaster-dev/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
16
16
|
fi
|
|
17
17
|
if [ -x "$basedir/node" ]; then
|
|
18
|
-
exec "$basedir/node" "$basedir
|
|
18
|
+
exec "$basedir/node" "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
19
19
|
else
|
|
20
|
-
exec node "$basedir
|
|
20
|
+
exec node "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
21
21
|
fi
|
|
@@ -15,7 +15,7 @@ else
|
|
|
15
15
|
export NODE_PATH="/Users/neo/taskmaster-dev/dist/node_modules:/Users/neo/taskmaster-dev/node_modules:/Users/neo/node_modules:/Users/node_modules:/node_modules:/Users/neo/taskmaster-dev/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
16
16
|
fi
|
|
17
17
|
if [ -x "$basedir/node" ]; then
|
|
18
|
-
exec "$basedir/node" "$basedir
|
|
18
|
+
exec "$basedir/node" "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
19
19
|
else
|
|
20
|
-
exec node "$basedir
|
|
20
|
+
exec node "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
21
21
|
fi
|
|
@@ -15,7 +15,7 @@ else
|
|
|
15
15
|
export NODE_PATH="/Users/neo/taskmaster-dev/dist/node_modules:/Users/neo/taskmaster-dev/node_modules:/Users/neo/node_modules:/Users/node_modules:/node_modules:/Users/neo/taskmaster-dev/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
16
16
|
fi
|
|
17
17
|
if [ -x "$basedir/node" ]; then
|
|
18
|
-
exec "$basedir/node" "$basedir
|
|
18
|
+
exec "$basedir/node" "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
19
19
|
else
|
|
20
|
-
exec node "$basedir
|
|
20
|
+
exec node "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
21
21
|
fi
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"@microsoft/agents-hosting": "^1.2.2",
|
|
30
30
|
"@microsoft/agents-hosting-express": "^1.2.2",
|
|
31
31
|
"@microsoft/agents-hosting-extensions-teams": "^1.2.2",
|
|
32
|
-
"taskmaster": "workspace:*",
|
|
32
|
+
"@rubytech/taskmaster": "workspace:*",
|
|
33
33
|
"express": "^5.2.1",
|
|
34
34
|
"proper-lockfile": "^4.1.2"
|
|
35
35
|
}
|
|
@@ -15,7 +15,7 @@ else
|
|
|
15
15
|
export NODE_PATH="/Users/neo/taskmaster-dev/dist/node_modules:/Users/neo/taskmaster-dev/node_modules:/Users/neo/node_modules:/Users/node_modules:/node_modules:/Users/neo/taskmaster-dev/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
16
16
|
fi
|
|
17
17
|
if [ -x "$basedir/node" ]; then
|
|
18
|
-
exec "$basedir/node" "$basedir
|
|
18
|
+
exec "$basedir/node" "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
19
19
|
else
|
|
20
|
-
exec node "$basedir
|
|
20
|
+
exec node "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
21
21
|
fi
|
|
@@ -15,7 +15,7 @@ else
|
|
|
15
15
|
export NODE_PATH="/Users/neo/taskmaster-dev/dist/node_modules:/Users/neo/taskmaster-dev/node_modules:/Users/neo/node_modules:/Users/node_modules:/node_modules:/Users/neo/taskmaster-dev/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
16
16
|
fi
|
|
17
17
|
if [ -x "$basedir/node" ]; then
|
|
18
|
-
exec "$basedir/node" "$basedir
|
|
18
|
+
exec "$basedir/node" "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
19
19
|
else
|
|
20
|
-
exec node "$basedir
|
|
20
|
+
exec node "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
21
21
|
fi
|
|
@@ -15,7 +15,7 @@ else
|
|
|
15
15
|
export NODE_PATH="/Users/neo/taskmaster-dev/dist/node_modules:/Users/neo/taskmaster-dev/node_modules:/Users/neo/node_modules:/Users/node_modules:/node_modules:/Users/neo/taskmaster-dev/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
16
16
|
fi
|
|
17
17
|
if [ -x "$basedir/node" ]; then
|
|
18
|
-
exec "$basedir/node" "$basedir
|
|
18
|
+
exec "$basedir/node" "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
19
19
|
else
|
|
20
|
-
exec node "$basedir
|
|
20
|
+
exec node "$basedir/../@rubytech/taskmaster/dist/entry.js" "$@"
|
|
21
21
|
fi
|