@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
|
@@ -7,29 +7,31 @@
|
|
|
7
7
|
|
|
8
8
|
import { Chat } from "chat";
|
|
9
9
|
import { createMemoryState } from "@chat-adapter/state-memory";
|
|
10
|
-
import type { AgentAdapter, AgentMessage, AgentRouter, AgentStreamEvent, GatewayConfig } from "
|
|
11
|
-
import { splitMessage, isAllowed, startTypingLoop } from "
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
import
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
10
|
+
import type { AgentAdapter, AgentMessage, AgentRouter, AgentStreamEvent, GatewayConfig } from "../types";
|
|
11
|
+
import { splitMessage, isAllowed, startTypingLoop } from "../util";
|
|
12
|
+
import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from "../voice/stt-service";
|
|
13
|
+
import { runDoctor, formatDoctorTelegram, createDoctorContext } from "../cli/doctor/runner";
|
|
14
|
+
import { ROUNDHOUSE_DIR, ROUNDHOUSE_VERSION } from "../config";
|
|
15
|
+
import { CronSchedulerService } from "../cron/scheduler";
|
|
16
|
+
import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact } from "../memory/lifecycle";
|
|
17
|
+
import { maxPressure } from "../memory/policy";
|
|
18
|
+
import type { PressureLevel } from "../memory/types";
|
|
19
|
+
// TODO: move progress into TransportAdapter when multi-transport lands
|
|
20
|
+
import { createProgressMessage } from "../transports/telegram/progress";
|
|
21
|
+
import { isCommand as _isCmd, isCommandWithArgs as _isCmdArgs, resolveAgentThreadId as _resolveThread, getSystemResources as _getSysRes } from "./helpers";
|
|
22
|
+
import { saveAttachments as _saveAttachments, type AttachmentResult } from "./attachments";
|
|
23
|
+
import { handleStreaming as _handleStream } from "./streaming";
|
|
24
|
+
import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, handleStop, handleVerbose, handleDoctor, handleCrons, type CommandContext } from "./commands";
|
|
25
|
+
import { TelegramAdapter } from "../transports";
|
|
26
|
+
import type { TransportAdapter } from "../transports";
|
|
27
|
+
import { hostname } from "node:os";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { injectToolsSection } from "./tools-inject";
|
|
30
|
+
|
|
30
31
|
/** Bot username for command suffix validation (set during gateway init) */
|
|
31
32
|
let _botUsername = "";
|
|
32
33
|
|
|
34
|
+
/** Match a bot command, handling optional @botname suffix */
|
|
33
35
|
function isCommand(text: string, cmd: string): boolean {
|
|
34
36
|
return _isCmd(text, cmd, _botUsername);
|
|
35
37
|
}
|
|
@@ -38,12 +40,10 @@ function isCommand(text: string, cmd: string): boolean {
|
|
|
38
40
|
function isCommandWithArgs(text: string, cmd: string): boolean {
|
|
39
41
|
return _isCmdArgs(text, cmd, _botUsername);
|
|
40
42
|
}
|
|
41
|
-
import { hostname } from "node:os";
|
|
42
43
|
|
|
43
44
|
function getSystemResources() {
|
|
44
45
|
return _getSysRes();
|
|
45
46
|
}
|
|
46
|
-
import { join } from "node:path";
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
function resolveAgentThreadId(thread: any, message: any): string {
|
|
@@ -68,11 +68,6 @@ async function buildChatAdapters(
|
|
|
68
68
|
return adapters;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
function toolIcon(name: string): string {
|
|
72
|
-
return _toolIcon(name);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
71
|
async function saveAttachments(threadId: string, attachments: any[]): Promise<AttachmentResult> {
|
|
77
72
|
return _saveAttachments(threadId, attachments);
|
|
78
73
|
}
|
|
@@ -83,6 +78,7 @@ export class Gateway {
|
|
|
83
78
|
private chat!: Chat;
|
|
84
79
|
private router: AgentRouter;
|
|
85
80
|
private config: GatewayConfig;
|
|
81
|
+
private transport: TransportAdapter;
|
|
86
82
|
private pairingComplete = false;
|
|
87
83
|
private sttService: SttService | null = null;
|
|
88
84
|
private cronScheduler: CronSchedulerService | null = null;
|
|
@@ -90,62 +86,41 @@ export class Gateway {
|
|
|
90
86
|
constructor(router: AgentRouter, config: GatewayConfig) {
|
|
91
87
|
this.router = router;
|
|
92
88
|
this.config = config;
|
|
89
|
+
this.transport = new TelegramAdapter();
|
|
93
90
|
_botUsername = config.chat.botUsername || "";
|
|
94
91
|
}
|
|
95
92
|
|
|
96
|
-
/** Handle pending
|
|
93
|
+
/** Handle pending pairing via transport adapter. Returns true if handled. */
|
|
97
94
|
private async handlePendingPairing(
|
|
98
|
-
text: string,
|
|
99
95
|
message: any,
|
|
100
96
|
thread: any,
|
|
101
|
-
authorName: string,
|
|
102
97
|
): Promise<boolean> {
|
|
103
98
|
try {
|
|
104
|
-
const
|
|
105
|
-
if (!
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
99
|
+
const result = await this.transport.handlePairing(thread, message);
|
|
100
|
+
if (!result) return false;
|
|
108
101
|
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
102
|
+
const { threadId: rawThreadId, userId: rawUserId, username } = result;
|
|
103
|
+
// Config arrays are currently number[] — coerce with guard.
|
|
104
|
+
// When a string-ID transport (Slack/Discord) arrives, widen config types too.
|
|
105
|
+
const threadId = typeof rawThreadId === "string" ? Number(rawThreadId) : rawThreadId;
|
|
106
|
+
const userId = typeof rawUserId === "string" ? Number(rawUserId) : rawUserId;
|
|
115
107
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
: typeof thread.id === "string" && thread.id.startsWith("telegram:")
|
|
120
|
-
? parseInt(thread.id.split(":")[1], 10)
|
|
121
|
-
: undefined;
|
|
122
|
-
// Chat SDK Telegram adapter provides userId (not id)
|
|
123
|
-
const rawUserId = message.author?.userId ?? message.author?.id ?? message.raw?.from?.id;
|
|
124
|
-
const userId = typeof rawUserId === "number"
|
|
125
|
-
? rawUserId
|
|
126
|
-
: typeof rawUserId === "string"
|
|
127
|
-
? parseInt(rawUserId, 10)
|
|
128
|
-
: undefined;
|
|
129
|
-
|
|
130
|
-
if (chatId == null || Number.isNaN(chatId) || userId == null || Number.isNaN(userId)) {
|
|
131
|
-
console.error(`[roundhouse] Pairing nonce matched but could not extract IDs: chatId=${chatId} userId=${userId}. Pairing left pending.`);
|
|
132
|
-
await thread.post("⚠️ Pairing nonce accepted but could not capture your Telegram IDs. Try sending /start again, or run: roundhouse pair");
|
|
133
|
-
return true;
|
|
108
|
+
if (!Number.isFinite(threadId) || !Number.isFinite(userId)) {
|
|
109
|
+
console.error(`[roundhouse] Pairing returned non-numeric IDs: threadId=${rawThreadId} userId=${rawUserId}`);
|
|
110
|
+
return false;
|
|
134
111
|
}
|
|
135
112
|
|
|
136
|
-
await completePendingPairing({ chatId, userId, username: authorName });
|
|
137
|
-
|
|
138
113
|
// Update in-memory config
|
|
139
114
|
if (!this.config.chat.allowedUserIds) this.config.chat.allowedUserIds = [];
|
|
140
115
|
if (!this.config.chat.allowedUserIds.includes(userId)) {
|
|
141
116
|
this.config.chat.allowedUserIds.push(userId);
|
|
142
117
|
}
|
|
143
118
|
if (!this.config.chat.notifyChatIds) this.config.chat.notifyChatIds = [];
|
|
144
|
-
if (!this.config.chat.notifyChatIds.includes(
|
|
145
|
-
this.config.chat.notifyChatIds.push(
|
|
119
|
+
if (!this.config.chat.notifyChatIds.includes(threadId)) {
|
|
120
|
+
this.config.chat.notifyChatIds.push(threadId);
|
|
146
121
|
}
|
|
147
122
|
|
|
148
|
-
//
|
|
123
|
+
// Persist config atomically
|
|
149
124
|
try {
|
|
150
125
|
const { readFile: rf, rename: mvf, writeFile: wf, unlink: ulf } = await import("node:fs/promises");
|
|
151
126
|
const { randomBytes: rb } = await import("node:crypto");
|
|
@@ -155,7 +130,7 @@ export class Gateway {
|
|
|
155
130
|
if (!configRaw.chat.allowedUserIds) configRaw.chat.allowedUserIds = [];
|
|
156
131
|
if (!configRaw.chat.allowedUserIds.includes(userId)) configRaw.chat.allowedUserIds.push(userId);
|
|
157
132
|
if (!configRaw.chat.notifyChatIds) configRaw.chat.notifyChatIds = [];
|
|
158
|
-
if (!configRaw.chat.notifyChatIds.includes(
|
|
133
|
+
if (!configRaw.chat.notifyChatIds.includes(threadId)) configRaw.chat.notifyChatIds.push(threadId);
|
|
159
134
|
const tmp = `${cfgPath}.tmp.${rb(4).toString("hex")}`;
|
|
160
135
|
await wf(tmp, JSON.stringify(configRaw, null, 2) + "\n");
|
|
161
136
|
await mvf(tmp, cfgPath).catch(async (e) => { try { await ulf(tmp); } catch {} throw e; });
|
|
@@ -163,7 +138,7 @@ export class Gateway {
|
|
|
163
138
|
console.error("[roundhouse] failed to update config after pairing:", cfgErr);
|
|
164
139
|
}
|
|
165
140
|
|
|
166
|
-
console.log(`[roundhouse]
|
|
141
|
+
console.log(`[roundhouse] Pairing complete: @${username} threadId=${threadId} userId=${userId}`);
|
|
167
142
|
this.pairingComplete = true;
|
|
168
143
|
await thread.post("✅ Roundhouse paired successfully!\n\nSend /status to verify everything is working.");
|
|
169
144
|
return true;
|
|
@@ -192,7 +167,7 @@ export class Gateway {
|
|
|
192
167
|
};
|
|
193
168
|
if (sttConfig.enabled && sttConfig.mode !== "off") {
|
|
194
169
|
this.sttService = new SttService(sttConfig);
|
|
195
|
-
console.log(`[roundhouse] STT enabled (chain: ${sttConfig.chain.join(" -> ")}
|
|
170
|
+
console.log(`[roundhouse] STT enabled (chain: ${sttConfig.chain.join(" -> ")})`);
|
|
196
171
|
// Prepare providers in background (install + warm model if needed)
|
|
197
172
|
void this.sttService.prepareInBackground();
|
|
198
173
|
}
|
|
@@ -243,9 +218,9 @@ export class Gateway {
|
|
|
243
218
|
`[roundhouse] ${thread.id} -> ${agentThreadId} @${authorName}: "${userText.slice(0, 120)}"${rawAttachments.length ? ` +${rawAttachments.length} attachment(s)` : ""}`
|
|
244
219
|
);
|
|
245
220
|
|
|
246
|
-
// Check for pending
|
|
247
|
-
if (
|
|
248
|
-
const handled = await this.handlePendingPairing(
|
|
221
|
+
// Check for pending pairing via transport adapter
|
|
222
|
+
if (!this.pairingComplete && await this.transport.isPairingPending()) {
|
|
223
|
+
const handled = await this.handlePendingPairing(message, thread);
|
|
249
224
|
if (handled) return;
|
|
250
225
|
}
|
|
251
226
|
|
|
@@ -385,14 +360,28 @@ export class Gateway {
|
|
|
385
360
|
try {
|
|
386
361
|
console.log(`[roundhouse] → ${agent.name} | thread=${agentThreadId}`);
|
|
387
362
|
|
|
388
|
-
// Enrich audio attachments with transcripts (STT)
|
|
389
|
-
|
|
363
|
+
// Enrich audio attachments with transcripts (STT) — show typing while processing
|
|
364
|
+
if (agentMessage.attachments?.some((a: any) => a.mediaType === "audio")) {
|
|
365
|
+
const sttTyping = startTypingLoop(thread);
|
|
366
|
+
try {
|
|
367
|
+
await this.enrichWithStt(thread, agentMessage);
|
|
368
|
+
} finally {
|
|
369
|
+
sttTyping();
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
await this.enrichWithStt(thread, agentMessage);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Inject tools section (after STT enrichment so voice-only messages get it too)
|
|
376
|
+
if (agentMessage.text) {
|
|
377
|
+
agentMessage.text = injectToolsSection(agentMessage.text);
|
|
378
|
+
}
|
|
390
379
|
|
|
391
380
|
// Let the agent adapter apply platform-specific message transforms
|
|
392
381
|
if (agent.prepareMessage) {
|
|
393
382
|
try {
|
|
394
383
|
agentMessage = agent.prepareMessage(agentThreadId, agentMessage, {
|
|
395
|
-
platform:
|
|
384
|
+
platform: this.transport.name,
|
|
396
385
|
hasAttachments: !!(agentMessage.attachments?.length),
|
|
397
386
|
});
|
|
398
387
|
} catch (err) {
|
|
@@ -472,19 +461,42 @@ export class Gateway {
|
|
|
472
461
|
/**
|
|
473
462
|
* Enrich audio attachments with speech-to-text transcripts.
|
|
474
463
|
* Updates agentMessage.text for voice-only messages.
|
|
464
|
+
* If STT deps are missing, injects an install-prompt for the agent.
|
|
475
465
|
*/
|
|
476
466
|
private async enrichWithStt(thread: any, agentMessage: AgentMessage): Promise<void> {
|
|
477
467
|
if (!this.sttService || !agentMessage.attachments?.length) return;
|
|
478
468
|
try {
|
|
479
469
|
await enrichAttachmentsWithTranscripts(agentMessage.attachments, this.sttService, (text) => thread.post(text));
|
|
470
|
+
|
|
471
|
+
// Check if any audio attachments failed transcription
|
|
472
|
+
const hasFailedAudio = agentMessage.attachments.some(
|
|
473
|
+
(a) => a.mediaType === "audio" && a.transcript?.status === "failed",
|
|
474
|
+
);
|
|
475
|
+
|
|
480
476
|
if (!agentMessage.text) {
|
|
481
477
|
const transcripts = agentMessage.attachments
|
|
482
478
|
.filter((a) => a.transcript?.status === "completed" && a.transcript.text)
|
|
483
479
|
.map((a) => a.transcript!.text);
|
|
484
480
|
if (transcripts.length > 0) {
|
|
485
481
|
agentMessage.text = `Voice message transcript: ${transcripts.join(" ")}`;
|
|
486
|
-
} else if (
|
|
487
|
-
|
|
482
|
+
} else if (hasFailedAudio) {
|
|
483
|
+
// STT failed — check if deps are missing and inject agent prompt
|
|
484
|
+
const missing = await this.sttService.getMissingDeps();
|
|
485
|
+
if (missing.length > 0) {
|
|
486
|
+
await thread.post(`🎤 Voice transcription not available yet (missing: ${missing.join(", ")}). Asking agent to install...`);
|
|
487
|
+
agentMessage.text = this.buildSttInstallPrompt(missing, agentMessage.attachments);
|
|
488
|
+
} else {
|
|
489
|
+
agentMessage.text = "Voice message attached, but automatic transcription failed.";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
} else if (hasFailedAudio) {
|
|
493
|
+
// User sent text + audio, but audio transcription failed
|
|
494
|
+
const missing = await this.sttService.getMissingDeps();
|
|
495
|
+
if (missing.length > 0) {
|
|
496
|
+
await thread.post(`🎤 Voice transcription not available yet (missing: ${missing.join(", ")}). Asking agent to install...`);
|
|
497
|
+
agentMessage.text += "\n\n" + this.buildSttInstallPrompt(missing, agentMessage.attachments);
|
|
498
|
+
} else {
|
|
499
|
+
agentMessage.text += "\n\n(Note: a voice message was also attached but automatic transcription failed.)";
|
|
488
500
|
}
|
|
489
501
|
}
|
|
490
502
|
} catch (err) {
|
|
@@ -492,6 +504,37 @@ export class Gateway {
|
|
|
492
504
|
}
|
|
493
505
|
}
|
|
494
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Build a prompt instructing the agent to install missing STT dependencies.
|
|
509
|
+
*/
|
|
510
|
+
private buildSttInstallPrompt(missing: string[], attachments: any[]): string {
|
|
511
|
+
const audioFile = attachments.find((a: any) => a.mediaType === "audio");
|
|
512
|
+
const audioPath = audioFile?.localPath ?? "(audio file path from attachment)";
|
|
513
|
+
|
|
514
|
+
const parts: string[] = [
|
|
515
|
+
"The user sent a voice message but speech-to-text transcription failed because dependencies are missing.",
|
|
516
|
+
"",
|
|
517
|
+
`Missing: ${missing.join(", ")}`,
|
|
518
|
+
"",
|
|
519
|
+
"Please install the missing dependencies:",
|
|
520
|
+
];
|
|
521
|
+
|
|
522
|
+
if (missing.includes("ffmpeg")) {
|
|
523
|
+
parts.push("- ffmpeg: Install to ~/.local/bin/ffmpeg (try: curl static binary from johnvansickle.com for Linux, or `brew install ffmpeg` on macOS)");
|
|
524
|
+
}
|
|
525
|
+
if (missing.includes("whisper")) {
|
|
526
|
+
parts.push("- whisper: Install via `pip3 install --user openai-whisper` or `uv tool install openai-whisper`");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
parts.push("");
|
|
530
|
+
parts.push("After installing, verify with `whisper --help` and `ffmpeg -version`, then transcribe the voice message:");
|
|
531
|
+
parts.push(` whisper ${JSON.stringify(audioPath)} --model small --language en --output_format txt --output_dir /tmp`);
|
|
532
|
+
parts.push("");
|
|
533
|
+
parts.push("Send the transcription text back to the user. If installation fails, let the user know what went wrong.");
|
|
534
|
+
|
|
535
|
+
return parts.join("\n");
|
|
536
|
+
}
|
|
537
|
+
|
|
495
538
|
/**
|
|
496
539
|
* Save attachments, notify skipped, and build the AgentMessage.
|
|
497
540
|
* Returns null if there's nothing to send (empty text + failed attachments).
|
|
@@ -528,6 +571,11 @@ export class Gateway {
|
|
|
528
571
|
return null;
|
|
529
572
|
}
|
|
530
573
|
|
|
574
|
+
// Enrich prompt via transport adapter
|
|
575
|
+
if (agentMessage.text) {
|
|
576
|
+
agentMessage.text = this.transport.enrichPrompt(agentMessage.text);
|
|
577
|
+
}
|
|
578
|
+
|
|
531
579
|
return agentMessage;
|
|
532
580
|
}
|
|
533
581
|
|
|
@@ -612,9 +660,8 @@ export class Gateway {
|
|
|
612
660
|
|
|
613
661
|
/** Post text with markdown, falling back to plain text */
|
|
614
662
|
private async postWithFallback(thread: any, text: string) {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
await postTelegramHtml(thread, text);
|
|
663
|
+
if (this.transport.ownsThread(thread)) {
|
|
664
|
+
await this.transport.postMessage(thread, text);
|
|
618
665
|
return;
|
|
619
666
|
}
|
|
620
667
|
for (const chunk of splitMessage(text, 4000)) {
|
|
@@ -636,25 +683,9 @@ export class Gateway {
|
|
|
636
683
|
*/
|
|
637
684
|
private async registerBotCommands() {
|
|
638
685
|
if (!this.config.chat.adapters.telegram) return;
|
|
639
|
-
|
|
640
686
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
641
687
|
if (!token) return;
|
|
642
|
-
|
|
643
|
-
try {
|
|
644
|
-
const res = await fetch(`https://api.telegram.org/bot${token}/setMyCommands`, {
|
|
645
|
-
method: "POST",
|
|
646
|
-
headers: { "Content-Type": "application/json" },
|
|
647
|
-
body: JSON.stringify({ commands: BOT_COMMANDS }),
|
|
648
|
-
});
|
|
649
|
-
if (res.ok) {
|
|
650
|
-
console.log(`[roundhouse] registered ${BOT_COMMANDS.length} bot commands with Telegram`);
|
|
651
|
-
} else {
|
|
652
|
-
const body = await res.text().catch(() => "");
|
|
653
|
-
console.warn(`[roundhouse] failed to register bot commands (${res.status}): ${body.slice(0, 200)}`);
|
|
654
|
-
}
|
|
655
|
-
} catch (err) {
|
|
656
|
-
console.warn(`[roundhouse] failed to register bot commands:`, (err as Error).message);
|
|
657
|
-
}
|
|
688
|
+
await this.transport.registerCommands(token);
|
|
658
689
|
}
|
|
659
690
|
|
|
660
691
|
/**
|
|
@@ -666,11 +697,6 @@ export class Gateway {
|
|
|
666
697
|
const chatIds = this.config.chat.notifyChatIds;
|
|
667
698
|
if (!chatIds?.length) return;
|
|
668
699
|
|
|
669
|
-
if (!process.env.TELEGRAM_BOT_TOKEN) {
|
|
670
|
-
console.warn("[roundhouse] notifyChatIds configured but TELEGRAM_BOT_TOKEN not set — skipping startup notification");
|
|
671
|
-
return;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
700
|
const bootTime = process.uptime();
|
|
675
701
|
const host = hostname();
|
|
676
702
|
const agentName = this.config.agent.type;
|
|
@@ -715,7 +741,7 @@ export class Gateway {
|
|
|
715
741
|
` Process: ${memMB} MB RSS`,
|
|
716
742
|
].filter(line => line != null).join("\n");
|
|
717
743
|
|
|
718
|
-
await
|
|
744
|
+
await this.transport.notify([chatId], perChatText);
|
|
719
745
|
}
|
|
720
746
|
}
|
|
721
747
|
|
package/src/gateway/helpers.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { hostname, loadavg, totalmem, freemem, cpus } from "node:os";
|
|
|
10
10
|
// ── Command Matching ─────────────────────────────────
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Match a
|
|
13
|
+
* Match a bot command, handling optional @botname suffix.
|
|
14
14
|
*/
|
|
15
15
|
export function isCommand(text: string, cmd: string, botUsername: string): boolean {
|
|
16
16
|
if (text === cmd) return true;
|
package/src/gateway/index.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* gateway/index.ts — Barrel export for gateway
|
|
3
|
-
*
|
|
4
|
-
* Re-exports helpers, attachments, streaming, and commands for
|
|
5
|
-
* external consumers. The Gateway class itself lives at src/gateway.ts
|
|
6
|
-
* and is imported directly (not through this barrel).
|
|
2
|
+
* gateway/index.ts — Barrel export for gateway module
|
|
7
3
|
*/
|
|
8
4
|
|
|
5
|
+
export { Gateway } from "./gateway";
|
|
9
6
|
export { isCommand, isCommandWithArgs, resolveAgentThreadId, getSystemResources, toolIcon } from "./helpers";
|
|
10
7
|
export { saveAttachments } from "./attachments";
|
|
11
8
|
export { handleStreaming } from "./streaming";
|
package/src/gateway/streaming.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { AgentStreamEvent } from "../types";
|
|
12
12
|
import { READ_ONLY_TOOLS } from "../memory/types";
|
|
13
|
-
import { isTelegramThread, handleTelegramHtmlStream } from "../telegram
|
|
13
|
+
import { isTelegramThread, handleTelegramHtmlStream } from "../transports/telegram/html";
|
|
14
14
|
import { DEBUG_STREAM } from "../util";
|
|
15
15
|
import { toolIcon } from "./helpers";
|
|
16
16
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gateway/tools-inject.ts — Inject <tools> section into agent prompts
|
|
3
|
+
*
|
|
4
|
+
* Reads tools.md (bundled or user-customized) and appends it as a
|
|
5
|
+
* structured section so the agent knows what shell tools are available.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { ROUNDHOUSE_DIR } from "../config";
|
|
12
|
+
|
|
13
|
+
let cachedToolsContent: string | null = null;
|
|
14
|
+
|
|
15
|
+
function loadToolsContent(): string {
|
|
16
|
+
if (cachedToolsContent !== null) return cachedToolsContent;
|
|
17
|
+
|
|
18
|
+
// Try user-customized tools.md first, then bundled
|
|
19
|
+
const userPath = join(ROUNDHOUSE_DIR, "tools.md");
|
|
20
|
+
const bundledPath = join(dirname(fileURLToPath(import.meta.url)), "tools.md");
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
cachedToolsContent = readFileSync(userPath, "utf8");
|
|
24
|
+
} catch {
|
|
25
|
+
try {
|
|
26
|
+
cachedToolsContent = readFileSync(bundledPath, "utf8");
|
|
27
|
+
} catch {
|
|
28
|
+
// Don't cache failure — retry next call
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return cachedToolsContent;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Append a <tools> section to the prompt text.
|
|
37
|
+
* Only injects if tools.md has content.
|
|
38
|
+
*/
|
|
39
|
+
export function injectToolsSection(text: string): string {
|
|
40
|
+
const tools = loadToolsContent();
|
|
41
|
+
if (!tools) return text;
|
|
42
|
+
// Escape any tags that could break the XML structure
|
|
43
|
+
const sanitized = tools.trim().replace(/<\/?tools>/gi, (m) => m.replace(/</g, "<").replace(/>/g, ">"));
|
|
44
|
+
return `${text}\n\n<tools>\n${sanitized}\n</tools>`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Tools
|
|
2
|
+
|
|
3
|
+
Available tools that can be invoked via shell commands during agent turns.
|
|
4
|
+
|
|
5
|
+
## roundhouse cron add
|
|
6
|
+
|
|
7
|
+
Schedule recurring or one-shot jobs. The user may ask you to "remind me", "check every X", "do Y later", or "schedule Z".
|
|
8
|
+
|
|
9
|
+
**Usage:**
|
|
10
|
+
```bash
|
|
11
|
+
roundhouse cron add <job-id> --prompt "..." --every "6h"
|
|
12
|
+
roundhouse cron add <job-id> --prompt "..." --cron "0 8 * * *" --tz "America/New_York"
|
|
13
|
+
roundhouse cron add <job-id> --prompt "..." --at "30m"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**Flags:**
|
|
17
|
+
- `--prompt "..."` — What the agent should do when the job fires (required)
|
|
18
|
+
- `--cron "..."` — Cron expression (e.g. "0 9 * * 1-5" = weekdays at 9am)
|
|
19
|
+
- `--every "..."` — Interval (e.g. "6h", "30m", "1d")
|
|
20
|
+
- `--at "..."` — One-shot timer (e.g. "30m", "2h", or ISO datetime)
|
|
21
|
+
- `--tz "..."` — Timezone (default: UTC)
|
|
22
|
+
- `--telegram "..."` — Telegram chat IDs to notify (comma-separated)
|
|
23
|
+
- `--description "..."` — Human-readable description
|
|
24
|
+
- `--timeout "..."` — Max runtime (e.g. "5m", default: 10m)
|
|
25
|
+
|
|
26
|
+
**Examples:**
|
|
27
|
+
```bash
|
|
28
|
+
# Remind user every morning
|
|
29
|
+
roundhouse cron add morning-checkin --prompt "Good morning! Here's a summary of yesterday's work and today's plan." --cron "0 8 * * *" --tz "Asia/Jerusalem"
|
|
30
|
+
|
|
31
|
+
# Check something every 6 hours
|
|
32
|
+
roundhouse cron add monitor-deploy --prompt "Check if the deployment at https://example.com is healthy. Report any issues." --every "6h"
|
|
33
|
+
|
|
34
|
+
# One-shot reminder in 30 minutes
|
|
35
|
+
roundhouse cron add reminder-123 --prompt "Remind the user: 'Call the dentist'" --at "30m"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Management:**
|
|
39
|
+
```bash
|
|
40
|
+
roundhouse cron list # Show all jobs
|
|
41
|
+
roundhouse cron pause <id> # Disable a job
|
|
42
|
+
roundhouse cron resume <id> # Re-enable a job
|
|
43
|
+
roundhouse cron delete <id> # Remove a job
|
|
44
|
+
roundhouse cron trigger <id> # Run immediately
|
|
45
|
+
roundhouse cron runs <id> # Show run history
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## roundhouse cron (via /crons chat command)
|
|
49
|
+
|
|
50
|
+
Users can also manage jobs via Telegram:
|
|
51
|
+
- `/crons` — list all jobs
|
|
52
|
+
- `/crons trigger <id>` — run now
|
|
53
|
+
- `/crons pause <id>` — disable
|
|
54
|
+
- `/crons resume <id>` — enable
|
|
@@ -182,12 +182,44 @@ export function provisionMcporterConfig(opts: ProvisionOpts = {}): void {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Sync bundled skills that ship with roundhouse (additive, overwrites on each provision).
|
|
187
|
+
*/
|
|
188
|
+
export function syncBundledSkills(opts: ProvisionOpts = {}): void {
|
|
189
|
+
const log = opts.log ?? consoleLog;
|
|
190
|
+
// Resolves to <package-root>/skills/ (two levels up from src/provisioning/)
|
|
191
|
+
const bundledDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
|
|
192
|
+
if (!existsSync(bundledDir)) return;
|
|
193
|
+
|
|
194
|
+
const entries = readdirSync(bundledDir, { withFileTypes: true })
|
|
195
|
+
.filter(e => e.isDirectory() && !e.name.startsWith("."));
|
|
196
|
+
if (entries.length === 0) return;
|
|
197
|
+
|
|
198
|
+
mkdirSync(SKILLS_DIR, { recursive: true });
|
|
199
|
+
let count = 0;
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
const src = resolve(bundledDir, entry.name);
|
|
202
|
+
const dest = resolve(SKILLS_DIR, entry.name);
|
|
203
|
+
if (!dest.startsWith(SKILLS_DIR + "/")) continue;
|
|
204
|
+
try {
|
|
205
|
+
execFileSync("rm", ["-rf", dest], { stdio: "pipe", timeout: 10_000 });
|
|
206
|
+
execFileSync("cp", ["-r", src, dest], { stdio: "pipe", timeout: 30_000 });
|
|
207
|
+
count++;
|
|
208
|
+
} catch (e: any) {
|
|
209
|
+
log.warn(`Failed to copy bundled skill '${entry.name}': ${e.message}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (count > 0) log.ok(`${count} bundled skill(s) synced`);
|
|
213
|
+
}
|
|
214
|
+
|
|
185
215
|
/**
|
|
186
216
|
* Provision all bundle dependencies (skills + CLI tools + config + extensions).
|
|
187
217
|
* Non-fatal — logs warnings on failure but never throws.
|
|
188
218
|
*/
|
|
189
219
|
export function provisionBundle(opts: ProvisionOpts = {}): void {
|
|
190
220
|
syncSkillsFromRepo(opts);
|
|
221
|
+
// Must run after syncSkillsFromRepo so bundled skills take precedence on name collision
|
|
222
|
+
syncBundledSkills(opts);
|
|
191
223
|
provisionMcporter(opts);
|
|
192
224
|
provisionPlaywright(opts);
|
|
193
225
|
provisionUvx(opts);
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* typing indicators, command handling, authorization, message history.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { markdownToTelegramHtml, truncateHtmlSafe } from "./
|
|
12
|
-
import { splitMessage } from "
|
|
11
|
+
import { markdownToTelegramHtml, truncateHtmlSafe } from "./format";
|
|
12
|
+
import { splitMessage } from "../../util";
|
|
13
13
|
|
|
14
14
|
/** Max Telegram message length */
|
|
15
15
|
const TELEGRAM_LIMIT = 4096;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
|
|
9
9
|
import { dirname, resolve } from "node:path";
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
|
-
import { ROUNDHOUSE_DIR } from "
|
|
11
|
+
import { ROUNDHOUSE_DIR } from "../../config";
|
|
12
12
|
|
|
13
13
|
export interface PendingPairing {
|
|
14
14
|
version: 1;
|