@inceptionstack/roundhouse 0.5.4 → 0.5.7
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 +1 -3
- package/architecture.md +37 -19
- package/package.json +2 -1
- package/skills/pr-merge-discipline/SKILL.md +36 -0
- package/skills/roundhouse-cron/SKILL.md +136 -0
- package/src/agents/kiro/kiro-adapter.ts +1 -4
- package/src/agents/pi/pi-adapter.ts +1 -4
- package/src/cli/cli.ts +6 -1
- package/src/cli/doctor/checks/system.ts +1 -1
- package/src/cli/setup/args.ts +8 -9
- package/src/cli/setup/flows.ts +47 -14
- package/src/cli/{setup-logger.ts → setup/logger.ts} +4 -4
- package/src/cli/{setup-prompts.ts → setup/prompts.ts} +23 -2
- package/src/cli/setup/runtime.ts +1 -1
- package/src/cli/setup/steps.ts +5 -5
- package/src/cli/{setup-telegram.ts → setup/telegram.ts} +4 -4
- package/src/cli/setup/types.ts +4 -3
- package/src/cli/setup.ts +8 -8
- package/src/cli/systemd.ts +2 -0
- package/src/cli/update.ts +111 -0
- package/src/cron/runner.ts +2 -1
- package/src/gateway/commands.ts +29 -4
- package/src/{gateway.ts → gateway/gateway.ts} +126 -100
- package/src/gateway/helpers.ts +1 -1
- package/src/gateway/index.ts +2 -5
- package/src/gateway/streaming.ts +1 -1
- package/src/gateway/tools-inject.ts +45 -0
- package/src/gateway/tools.md +54 -0
- package/src/{bundle.ts → provisioning/bundle.ts} +32 -0
- package/src/transports/index.ts +6 -0
- package/src/{telegram-html.ts → transports/telegram/html.ts} +2 -2
- package/src/{pairing.ts → transports/telegram/pairing.ts} +1 -1
- package/src/transports/telegram/telegram-adapter.ts +111 -0
- package/src/transports/types.ts +71 -0
- package/src/voice/providers/whisper.ts +37 -94
- package/src/voice/stt-service.ts +35 -17
- package/src/voice/types.ts +1 -3
- package/src/commands/update.ts +0 -69
- /package/src/{commands.ts → transports/telegram/bot-commands.ts} +0 -0
- /package/src/{telegram-format.ts → transports/telegram/format.ts} +0 -0
- /package/src/{notify/telegram.ts → transports/telegram/notify.ts} +0 -0
- /package/src/{telegram-progress.ts → transports/telegram/progress.ts} +0 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transports/telegram/telegram-adapter.ts — Telegram transport adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements TransportAdapter for Telegram, composing existing
|
|
5
|
+
* utility modules (format, html, progress, notify, bot-commands).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TransportAdapter, ChatThread, IncomingMessage, PairingResult } from "../types";
|
|
9
|
+
import { isTelegramThread, postTelegramHtml } from "./html";
|
|
10
|
+
import { sendTelegramToMany } from "./notify";
|
|
11
|
+
import { BOT_COMMANDS } from "./bot-commands";
|
|
12
|
+
import { readPendingPairing, completePendingPairing, clearPendingPairing, isStartForNonce } from "./pairing";
|
|
13
|
+
|
|
14
|
+
const TELEGRAM_FORMAT_HINT = "[Format your final answer to be telegram-friendly.]";
|
|
15
|
+
|
|
16
|
+
export class TelegramAdapter implements TransportAdapter {
|
|
17
|
+
readonly name = "telegram";
|
|
18
|
+
|
|
19
|
+
enrichPrompt(text: string): string {
|
|
20
|
+
return `${text}\n\n${TELEGRAM_FORMAT_HINT}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async postMessage(thread: ChatThread, text: string): Promise<void> {
|
|
24
|
+
if (!isTelegramThread(thread as any)) {
|
|
25
|
+
throw new Error("TelegramAdapter.postMessage called with non-Telegram thread");
|
|
26
|
+
}
|
|
27
|
+
await postTelegramHtml(thread as any, text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async registerCommands(token: string): Promise<void> {
|
|
31
|
+
if (!token) return;
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/setMyCommands`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
body: JSON.stringify({ commands: BOT_COMMANDS }),
|
|
37
|
+
});
|
|
38
|
+
if (res.ok) {
|
|
39
|
+
console.log(`[roundhouse] registered ${BOT_COMMANDS.length} bot commands with Telegram`);
|
|
40
|
+
} else {
|
|
41
|
+
const body = await res.text().catch(() => "");
|
|
42
|
+
console.warn(`[roundhouse] failed to register bot commands (${res.status}): ${body.slice(0, 200)}`);
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.warn(`[roundhouse] bot command registration error:`, (err as Error).message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ownsThread(thread: ChatThread): boolean {
|
|
50
|
+
return isTelegramThread(thread as any);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async notify(chatIds: number[], text: string): Promise<void> {
|
|
54
|
+
if (!process.env.TELEGRAM_BOT_TOKEN) {
|
|
55
|
+
console.warn("[roundhouse] TELEGRAM_BOT_TOKEN not set — skipping notification");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await sendTelegramToMany(chatIds, text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async isPairingPending(): Promise<boolean> {
|
|
62
|
+
const pending = await readPendingPairing();
|
|
63
|
+
return pending?.status === "pending";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async handlePairing(thread: ChatThread, message: IncomingMessage): Promise<PairingResult | null> {
|
|
67
|
+
const text = (message.text ?? "").trim();
|
|
68
|
+
if (!text) return null;
|
|
69
|
+
|
|
70
|
+
const pending = await readPendingPairing();
|
|
71
|
+
if (!pending || pending.status !== "pending" || !isStartForNonce(text, pending.nonce)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Verify author is allowed
|
|
76
|
+
const authorName = (message.author?.userName ?? message.author?.name ?? "").toLowerCase();
|
|
77
|
+
const originalName = message.author?.userName ?? message.author?.name ?? "";
|
|
78
|
+
const allowed = pending.allowedUsers.map(u => u.toLowerCase());
|
|
79
|
+
if (!authorName || !allowed.includes(authorName)) {
|
|
80
|
+
console.log(`[roundhouse] Pairing nonce from unauthorized user @${originalName}`);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Extract Telegram-specific IDs
|
|
85
|
+
const msg = message as any;
|
|
86
|
+
const chatId = typeof msg.chatId === "number"
|
|
87
|
+
? msg.chatId
|
|
88
|
+
: typeof thread.id === "string" && thread.id.startsWith("telegram:")
|
|
89
|
+
? parseInt(thread.id.split(":")[1], 10)
|
|
90
|
+
: undefined;
|
|
91
|
+
|
|
92
|
+
const rawUserId = msg.author?.userId ?? msg.author?.id ?? msg.raw?.from?.id;
|
|
93
|
+
const userId = typeof rawUserId === "number"
|
|
94
|
+
? rawUserId
|
|
95
|
+
: typeof rawUserId === "string"
|
|
96
|
+
? parseInt(rawUserId, 10)
|
|
97
|
+
: undefined;
|
|
98
|
+
|
|
99
|
+
if (chatId == null || Number.isNaN(chatId) || userId == null || Number.isNaN(userId)) {
|
|
100
|
+
console.error(`[roundhouse] Pairing nonce matched but could not extract IDs: chatId=${chatId} userId=${userId} (raw: msg.chatId=${message.chatId}, thread.id=${thread.id}, author.userId=${message.author?.userId}, author.id=${message.author?.id}, raw.from.id=${message.raw?.from?.id})`);
|
|
101
|
+
await clearPendingPairing();
|
|
102
|
+
await thread.post("⚠️ Pairing failed — could not capture your Telegram IDs. Run: roundhouse setup --telegram");
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Mark pairing complete in transport state
|
|
107
|
+
await completePendingPairing({ chatId, userId, username: originalName });
|
|
108
|
+
|
|
109
|
+
return { threadId: chatId, userId, username: originalName };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transports/types.ts — Transport adapter interface
|
|
3
|
+
*
|
|
4
|
+
* Defines the contract for platform-specific transport adapters.
|
|
5
|
+
* The gateway uses this interface to remain transport-agnostic.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Minimal thread interface (subset of Chat SDK thread) */
|
|
9
|
+
export interface ChatThread {
|
|
10
|
+
id: string;
|
|
11
|
+
post(text: string): Promise<void>;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Minimal incoming message interface */
|
|
16
|
+
export interface IncomingMessage {
|
|
17
|
+
text?: string;
|
|
18
|
+
author?: { userName?: string; name?: string; userId?: string | number; id?: string };
|
|
19
|
+
chatId?: number;
|
|
20
|
+
raw?: { from?: { id?: number } };
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Result of a successful transport pairing */
|
|
25
|
+
export interface PairingResult {
|
|
26
|
+
/** Thread/channel ID for notifications */
|
|
27
|
+
threadId: string | number;
|
|
28
|
+
/** User ID for allowlist */
|
|
29
|
+
userId: string | number;
|
|
30
|
+
/** Display name */
|
|
31
|
+
username: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* TransportAdapter — platform-specific behavior contract.
|
|
36
|
+
*
|
|
37
|
+
* Encapsulates all concerns specific to a messaging platform
|
|
38
|
+
* (Telegram, Slack, Discord, etc.), keeping the gateway transport-agnostic.
|
|
39
|
+
*/
|
|
40
|
+
export interface TransportAdapter {
|
|
41
|
+
/** Transport name (e.g. "telegram") */
|
|
42
|
+
readonly name: string;
|
|
43
|
+
|
|
44
|
+
/** Enrich prompt text before sending to agent (e.g. formatting hints) */
|
|
45
|
+
enrichPrompt(text: string): string;
|
|
46
|
+
|
|
47
|
+
/** Post a message using platform-native formatting */
|
|
48
|
+
postMessage(thread: ChatThread, text: string): Promise<void>;
|
|
49
|
+
|
|
50
|
+
/** Register bot commands with the platform */
|
|
51
|
+
registerCommands(token: string): Promise<void>;
|
|
52
|
+
|
|
53
|
+
/** Check if a thread belongs to this transport */
|
|
54
|
+
ownsThread(thread: ChatThread): boolean;
|
|
55
|
+
|
|
56
|
+
/** Send notifications to configured recipients */
|
|
57
|
+
notify(chatIds: number[], text: string): Promise<void>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if a pairing flow is pending.
|
|
61
|
+
* Gateway uses this to decide whether to attempt pairing on incoming messages.
|
|
62
|
+
*/
|
|
63
|
+
isPairingPending(): Promise<boolean>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Try to handle an incoming message as a pairing attempt.
|
|
67
|
+
* Returns PairingResult on success, null if not a pairing message.
|
|
68
|
+
* Transport manages its own state (nonce files, OAuth tokens, etc.)
|
|
69
|
+
*/
|
|
70
|
+
handlePairing(thread: ChatThread, message: IncomingMessage): Promise<PairingResult | null>;
|
|
71
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* voice/providers/whisper.ts — Local Whisper STT provider
|
|
3
3
|
*
|
|
4
4
|
* Runs the whisper CLI via child_process. Auto-detects language.
|
|
5
|
-
*
|
|
5
|
+
* Reports missing dependencies so the agent can install them.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { execFile } from "node:child_process";
|
|
@@ -21,6 +21,12 @@ const WHISPER_PATHS = [
|
|
|
21
21
|
"/usr/bin/whisper",
|
|
22
22
|
];
|
|
23
23
|
|
|
24
|
+
const FFMPEG_PATHS = [
|
|
25
|
+
join(homedir(), ".local", "bin", "ffmpeg"),
|
|
26
|
+
"/usr/local/bin/ffmpeg",
|
|
27
|
+
"/usr/bin/ffmpeg",
|
|
28
|
+
];
|
|
29
|
+
|
|
24
30
|
let cachedBinaryPath: string | null | undefined; // undefined = not checked yet
|
|
25
31
|
|
|
26
32
|
async function findWhisperBinary(): Promise<string | null> {
|
|
@@ -33,80 +39,22 @@ async function findWhisperBinary(): Promise<string | null> {
|
|
|
33
39
|
return p;
|
|
34
40
|
} catch {}
|
|
35
41
|
}
|
|
36
|
-
|
|
42
|
+
// Don't cache null — allows detection after agent installs whisper
|
|
37
43
|
return null;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
let pipAvailable: boolean | undefined;
|
|
48
|
-
|
|
49
|
-
async function checkPip(): Promise<boolean> {
|
|
50
|
-
if (pipAvailable !== undefined) return pipAvailable;
|
|
51
|
-
return new Promise<boolean>((resolve) => {
|
|
52
|
-
execFile("pip3", ["--version"], { timeout: 5000 }, (err) => {
|
|
53
|
-
pipAvailable = !err;
|
|
54
|
-
resolve(pipAvailable);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Install whisper via pip3 --user. Returns the binary path or null on failure.
|
|
61
|
-
*/
|
|
62
|
-
async function installWhisperWithPip(): Promise<string | null> {
|
|
63
|
-
if (!(await checkPip())) {
|
|
64
|
-
console.warn("[stt/whisper] pip3 not available — cannot auto-install whisper");
|
|
65
|
-
return null;
|
|
46
|
+
async function findFfmpeg(): Promise<string | null> {
|
|
47
|
+
for (const p of FFMPEG_PATHS) {
|
|
48
|
+
try {
|
|
49
|
+
await access(p, constants.X_OK);
|
|
50
|
+
return p;
|
|
51
|
+
} catch {}
|
|
66
52
|
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
67
55
|
|
|
68
|
-
console.log("[stt/whisper] installing openai-whisper via pip3...");
|
|
69
|
-
return new Promise<string | null>((resolve) => {
|
|
70
|
-
execFile(
|
|
71
|
-
"pip3",
|
|
72
|
-
["install", "--user", "openai-whisper"],
|
|
73
|
-
{
|
|
74
|
-
timeout: 300_000, // 5 min for install
|
|
75
|
-
maxBuffer: 10 * 1024 * 1024, // 10MB for pip output
|
|
76
|
-
env: { ...process.env },
|
|
77
|
-
},
|
|
78
|
-
async (err, stdout, stderr) => {
|
|
79
|
-
if (err) {
|
|
80
|
-
console.error("[stt/whisper] pip3 install failed:", err.message);
|
|
81
|
-
if (stderr) console.error("[stt/whisper] stderr:", stderr.slice(0, 500));
|
|
82
|
-
resolve(null);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
console.log("[stt/whisper] pip3 install succeeded");
|
|
86
|
-
|
|
87
|
-
// Re-discover binary
|
|
88
|
-
invalidateCache();
|
|
89
|
-
const binary = await findWhisperBinary();
|
|
90
|
-
if (!binary) {
|
|
91
|
-
console.error("[stt/whisper] installed but binary not found in expected paths");
|
|
92
|
-
resolve(null);
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
56
|
|
|
96
|
-
|
|
97
|
-
execFile(binary, ["--help"], { timeout: 10_000 }, (helpErr) => {
|
|
98
|
-
if (helpErr) {
|
|
99
|
-
console.error("[stt/whisper] binary found but --help failed:", helpErr.message);
|
|
100
|
-
resolve(null);
|
|
101
|
-
} else {
|
|
102
|
-
console.log(`[stt/whisper] validated binary at ${binary}`);
|
|
103
|
-
resolve(binary);
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
},
|
|
107
|
-
);
|
|
108
|
-
});
|
|
109
|
-
}
|
|
57
|
+
// ── Model warmup ─────────────────────────────────────
|
|
110
58
|
|
|
111
59
|
/**
|
|
112
60
|
* Warm the whisper model by running a tiny transcription.
|
|
@@ -171,19 +119,15 @@ async function warmWhisperModel(binary: string, model: string): Promise<boolean>
|
|
|
171
119
|
|
|
172
120
|
// ── Provider ─────────────────────────────────────────
|
|
173
121
|
|
|
174
|
-
/** Extended provider
|
|
122
|
+
/** Extended provider that reports missing dependencies */
|
|
175
123
|
export interface InstallableWhisperProvider extends SttProvider {
|
|
176
124
|
ensureInstalled(): Promise<boolean>;
|
|
125
|
+
getMissingDeps(): Promise<string[]>;
|
|
177
126
|
}
|
|
178
127
|
|
|
179
|
-
// Singleton promises to prevent concurrent installs
|
|
180
|
-
let installPromise: Promise<string | null> | null = null;
|
|
181
|
-
let installFailed = false; // sticky failure to prevent retry spam
|
|
182
|
-
|
|
183
128
|
export function createWhisperProvider(config: SttProviderConfig): InstallableWhisperProvider {
|
|
184
129
|
const model = (config.model as string) ?? "small";
|
|
185
130
|
const timeoutMs = config.timeoutMs ?? 30000;
|
|
186
|
-
const autoInstall = config.autoInstall === true; // explicit opt-in only
|
|
187
131
|
let modelWarmed = false;
|
|
188
132
|
let warmFailed = false; // sticky failure to prevent warmup retry spam
|
|
189
133
|
let warmPromise: Promise<boolean> | null = null;
|
|
@@ -191,24 +135,14 @@ export function createWhisperProvider(config: SttProviderConfig): InstallableWhi
|
|
|
191
135
|
const WHISPER_LANGS = new Set(["af","am","ar","as","az","ba","be","bg","bn","bo","br","bs","ca","cs","cy","da","de","el","en","es","et","eu","fa","fi","fo","fr","gl","gu","ha","haw","he","hi","hr","ht","hu","hy","id","is","it","ja","jw","ka","kk","km","kn","ko","la","lb","ln","lo","lt","lv","mg","mi","mk","ml","mn","mr","ms","mt","my","ne","nl","nn","no","oc","pa","pl","ps","pt","ro","ru","sa","sd","si","sk","sl","sn","so","sq","sr","su","sv","sw","ta","te","tg","th","tk","tl","tr","tt","uk","ur","uz","vi","yi","yo","yue","zh"]);
|
|
192
136
|
|
|
193
137
|
async function getBinary(): Promise<string | null> {
|
|
194
|
-
// Check if already available
|
|
195
138
|
const existing = await findWhisperBinary();
|
|
196
|
-
if (existing) return
|
|
197
|
-
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (!installPromise) {
|
|
204
|
-
installPromise = installWhisperWithPip().then((result) => {
|
|
205
|
-
if (!result) installFailed = true;
|
|
206
|
-
return result;
|
|
207
|
-
}).finally(() => {
|
|
208
|
-
installPromise = null;
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
return installPromise;
|
|
139
|
+
if (!existing) return null;
|
|
140
|
+
|
|
141
|
+
// Also need ffmpeg
|
|
142
|
+
const ffmpeg = await findFfmpeg();
|
|
143
|
+
if (!ffmpeg) return null;
|
|
144
|
+
|
|
145
|
+
return existing;
|
|
212
146
|
}
|
|
213
147
|
|
|
214
148
|
return {
|
|
@@ -218,6 +152,15 @@ export function createWhisperProvider(config: SttProviderConfig): InstallableWhi
|
|
|
218
152
|
return input.mime.startsWith("audio/");
|
|
219
153
|
},
|
|
220
154
|
|
|
155
|
+
async getMissingDeps(): Promise<string[]> {
|
|
156
|
+
const missing: string[] = [];
|
|
157
|
+
const whisper = await findWhisperBinary();
|
|
158
|
+
if (!whisper) missing.push("whisper");
|
|
159
|
+
const ffmpeg = await findFfmpeg();
|
|
160
|
+
if (!ffmpeg) missing.push("ffmpeg");
|
|
161
|
+
return missing;
|
|
162
|
+
},
|
|
163
|
+
|
|
221
164
|
async ensureInstalled(): Promise<boolean> {
|
|
222
165
|
const binary = await getBinary();
|
|
223
166
|
if (!binary) return false;
|
|
@@ -236,7 +179,7 @@ export function createWhisperProvider(config: SttProviderConfig): InstallableWhi
|
|
|
236
179
|
}
|
|
237
180
|
} catch {}
|
|
238
181
|
|
|
239
|
-
// Run warmup
|
|
182
|
+
// Run warmup
|
|
240
183
|
try {
|
|
241
184
|
const ok = await warmWhisperModel(binary, model);
|
|
242
185
|
if (!ok) warmFailed = true;
|
|
@@ -258,7 +201,7 @@ export function createWhisperProvider(config: SttProviderConfig): InstallableWhi
|
|
|
258
201
|
async transcribe(input: SttInput): Promise<TranscriptionResult> {
|
|
259
202
|
const binary = await getBinary();
|
|
260
203
|
if (!binary) {
|
|
261
|
-
throw new Error("whisper not available
|
|
204
|
+
throw new Error("whisper or ffmpeg not available");
|
|
262
205
|
}
|
|
263
206
|
|
|
264
207
|
const outputDir = join(homedir(), ".roundhouse", "whisper-tmp", randomBytes(6).toString("hex"));
|
package/src/voice/stt-service.ts
CHANGED
|
@@ -19,7 +19,6 @@ export class SttService {
|
|
|
19
19
|
private config: SttConfig;
|
|
20
20
|
private initPromise: Promise<void> | null = null;
|
|
21
21
|
private activeStt: Promise<void> = Promise.resolve(); // global concurrency: 1 at a time
|
|
22
|
-
private installNoticeSent = false;
|
|
23
22
|
|
|
24
23
|
constructor(config: SttConfig) {
|
|
25
24
|
this.config = config;
|
|
@@ -52,12 +51,7 @@ export class SttService {
|
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
try {
|
|
55
|
-
|
|
56
|
-
const mergedProviderConfig = {
|
|
57
|
-
...providerConfig,
|
|
58
|
-
autoInstall: providerConfig.autoInstall ?? this.config.autoInstall ?? false,
|
|
59
|
-
};
|
|
60
|
-
this.providers.push(factory(mergedProviderConfig));
|
|
54
|
+
this.providers.push(factory(providerConfig));
|
|
61
55
|
console.log(`[stt] loaded provider: ${providerName} (${type})`);
|
|
62
56
|
} catch (err) {
|
|
63
57
|
console.warn(`[stt] failed to create provider "${providerName}":`, (err as Error).message);
|
|
@@ -97,6 +91,34 @@ export class SttService {
|
|
|
97
91
|
}
|
|
98
92
|
}
|
|
99
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Check which STT dependencies are missing.
|
|
96
|
+
* Returns empty array if everything is installed, or names like ["whisper", "ffmpeg"].
|
|
97
|
+
* Note: returns assumed deps when no providers loaded (safe fallback for default config).
|
|
98
|
+
*/
|
|
99
|
+
async getMissingDeps(): Promise<string[]> {
|
|
100
|
+
try {
|
|
101
|
+
await this.ensureInitialized();
|
|
102
|
+
} catch {
|
|
103
|
+
return ["whisper", "ffmpeg"]; // Can't initialize = assume all missing
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (this.providers.length === 0) {
|
|
107
|
+
// No providers loaded — most likely whisper not installed (default config uses whisper).
|
|
108
|
+
// Config typos are logged during doInit(); agent install prompt is a safe fallback.
|
|
109
|
+
return ["whisper", "ffmpeg"];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Returns deps from first provider that supports getMissingDeps (single-provider today)
|
|
113
|
+
for (const provider of this.providers) {
|
|
114
|
+
const installable = provider as InstallableWhisperProvider;
|
|
115
|
+
if (installable.getMissingDeps && typeof installable.getMissingDeps === "function") {
|
|
116
|
+
return installable.getMissingDeps();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
100
122
|
/** Should this attachment be auto-transcribed? */
|
|
101
123
|
shouldTranscribe(attachment: MessageAttachment): boolean {
|
|
102
124
|
if (!this.config.enabled || this.config.mode === "off") return false;
|
|
@@ -141,7 +163,7 @@ export class SttService {
|
|
|
141
163
|
const duration = await getAudioDuration(attachment.localPath);
|
|
142
164
|
if (duration !== null && duration > maxDuration) {
|
|
143
165
|
console.log(`[stt] skipping ${attachment.name}: duration ${duration.toFixed(1)}s exceeds ${maxDuration}s limit`);
|
|
144
|
-
return
|
|
166
|
+
return { text: "", provider: "none", approximate: true as const, status: "skipped" as const, error: `Duration ${duration.toFixed(0)}s exceeds ${maxDuration}s limit` };
|
|
145
167
|
}
|
|
146
168
|
} catch {}
|
|
147
169
|
}
|
|
@@ -169,18 +191,12 @@ export class SttService {
|
|
|
169
191
|
for (const provider of this.providers) {
|
|
170
192
|
if (!provider.canTranscribe(input)) continue;
|
|
171
193
|
|
|
172
|
-
// Ensure provider is installed
|
|
194
|
+
// Ensure provider is installed
|
|
173
195
|
const installable = provider as InstallableWhisperProvider;
|
|
174
196
|
if (installable.ensureInstalled && typeof installable.ensureInstalled === "function") {
|
|
175
197
|
try {
|
|
176
198
|
const isReady = await installable.ensureInstalled();
|
|
177
|
-
if (!isReady)
|
|
178
|
-
if (!this.installNoticeSent && notify) {
|
|
179
|
-
this.installNoticeSent = true;
|
|
180
|
-
try { await notify("🎤 Voice transcription not available. Whisper install or model download failed."); } catch {}
|
|
181
|
-
}
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
199
|
+
if (!isReady) continue;
|
|
184
200
|
} catch {
|
|
185
201
|
continue;
|
|
186
202
|
}
|
|
@@ -239,6 +255,9 @@ export async function enrichAttachmentsWithTranscripts(
|
|
|
239
255
|
const transcript = await sttService.tryTranscribe(att, undefined, notify);
|
|
240
256
|
if (transcript) {
|
|
241
257
|
att.transcript = transcript;
|
|
258
|
+
} else if (att.mediaType === "audio" && sttService.shouldTranscribe(att)) {
|
|
259
|
+
// Mark as failed so gateway can detect and act
|
|
260
|
+
att.transcript = { text: "", provider: "none", approximate: true, status: "failed", error: "No STT provider available" };
|
|
242
261
|
}
|
|
243
262
|
} catch (err) {
|
|
244
263
|
console.error(`[stt] unexpected error transcribing ${att.name}:`, (err as Error).message);
|
|
@@ -267,7 +286,6 @@ async function getAudioDuration(filePath: string): Promise<number | null> {
|
|
|
267
286
|
export const DEFAULT_STT_CONFIG: SttConfig = {
|
|
268
287
|
enabled: true,
|
|
269
288
|
mode: "on",
|
|
270
|
-
autoInstall: true,
|
|
271
289
|
chain: ["whisper"],
|
|
272
290
|
autoTranscribe: {
|
|
273
291
|
voiceMessages: true,
|
package/src/voice/types.ts
CHANGED
|
@@ -35,7 +35,7 @@ export interface AttachmentTranscript {
|
|
|
35
35
|
language?: string;
|
|
36
36
|
confidence?: number;
|
|
37
37
|
approximate: true;
|
|
38
|
-
status: "completed" | "failed";
|
|
38
|
+
status: "completed" | "failed" | "skipped";
|
|
39
39
|
error?: string;
|
|
40
40
|
durationMs?: number;
|
|
41
41
|
}
|
|
@@ -45,14 +45,12 @@ export interface AttachmentTranscript {
|
|
|
45
45
|
export interface SttProviderConfig {
|
|
46
46
|
type: string;
|
|
47
47
|
timeoutMs?: number;
|
|
48
|
-
autoInstall?: boolean;
|
|
49
48
|
[key: string]: unknown;
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
export interface SttConfig {
|
|
53
52
|
enabled: boolean;
|
|
54
53
|
mode: "on" | "off";
|
|
55
|
-
autoInstall?: boolean;
|
|
56
54
|
chain: string[];
|
|
57
55
|
autoTranscribe: {
|
|
58
56
|
voiceMessages: boolean;
|
package/src/commands/update.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* commands/update.ts — Handle the /update command
|
|
3
|
-
*
|
|
4
|
-
* Transport-agnostic: receives a ProgressReporter interface,
|
|
5
|
-
* not a Telegram-specific thread object.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { homedir } from "node:os";
|
|
9
|
-
import { execSync } from "node:child_process";
|
|
10
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { provisionBundle } from "../bundle";
|
|
12
|
-
|
|
13
|
-
export interface UpdateProgress {
|
|
14
|
-
update(text: string): Promise<void>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface UpdateResult {
|
|
18
|
-
action: "already-latest" | "updated";
|
|
19
|
-
currentVersion: string;
|
|
20
|
-
latestVersion?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Check for updates, install if newer, provision bundle, patch settings.
|
|
25
|
-
* Returns the result — caller decides how to present it and whether to restart.
|
|
26
|
-
*/
|
|
27
|
-
export async function performUpdate(progress: UpdateProgress): Promise<UpdateResult> {
|
|
28
|
-
// Get current version
|
|
29
|
-
const pkg = await import("../../package.json", { with: { type: "json" } });
|
|
30
|
-
const currentVersion = pkg.default?.version ?? "unknown";
|
|
31
|
-
|
|
32
|
-
// Check latest version on npm
|
|
33
|
-
const latestVersion = execSync("npm view @inceptionstack/roundhouse version 2>/dev/null", {
|
|
34
|
-
timeout: 30_000,
|
|
35
|
-
encoding: "utf8",
|
|
36
|
-
}).trim();
|
|
37
|
-
|
|
38
|
-
if (!latestVersion || latestVersion === currentVersion) {
|
|
39
|
-
return { action: "already-latest", currentVersion };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
await progress.update(`📦 Updating v${currentVersion} → v${latestVersion}...`);
|
|
43
|
-
|
|
44
|
-
execSync("npm install -g @inceptionstack/roundhouse@latest 2>&1", {
|
|
45
|
-
timeout: 120_000,
|
|
46
|
-
encoding: "utf8",
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
// Provision bundle (skills sync + CLI tools + config)
|
|
50
|
-
try {
|
|
51
|
-
provisionBundle();
|
|
52
|
-
} catch (e) {
|
|
53
|
-
console.warn("[roundhouse] bundle provisioning failed:", e instanceof Error ? e.message : e);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Ensure settings.json includes roundhouse package (for pre-bundle upgrades)
|
|
57
|
-
try {
|
|
58
|
-
const settingsPath = `${homedir()}/.pi/agent/settings.json`;
|
|
59
|
-
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
60
|
-
const selfPkg = "npm:@inceptionstack/roundhouse";
|
|
61
|
-
if (!Array.isArray(settings.packages)) settings.packages = [];
|
|
62
|
-
if (!settings.packages.includes(selfPkg)) {
|
|
63
|
-
settings.packages.push(selfPkg);
|
|
64
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
65
|
-
}
|
|
66
|
-
} catch { /* settings.json may not exist yet — fine, setup will create it */ }
|
|
67
|
-
|
|
68
|
-
return { action: "updated", currentVersion, latestVersion };
|
|
69
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|