@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.
Files changed (42) hide show
  1. package/README.md +1 -3
  2. package/architecture.md +37 -19
  3. package/package.json +2 -1
  4. package/skills/pr-merge-discipline/SKILL.md +36 -0
  5. package/skills/roundhouse-cron/SKILL.md +136 -0
  6. package/src/agents/kiro/kiro-adapter.ts +1 -4
  7. package/src/agents/pi/pi-adapter.ts +1 -4
  8. package/src/cli/cli.ts +6 -1
  9. package/src/cli/doctor/checks/system.ts +1 -1
  10. package/src/cli/setup/args.ts +8 -9
  11. package/src/cli/setup/flows.ts +47 -14
  12. package/src/cli/{setup-logger.ts → setup/logger.ts} +4 -4
  13. package/src/cli/{setup-prompts.ts → setup/prompts.ts} +23 -2
  14. package/src/cli/setup/runtime.ts +1 -1
  15. package/src/cli/setup/steps.ts +5 -5
  16. package/src/cli/{setup-telegram.ts → setup/telegram.ts} +4 -4
  17. package/src/cli/setup/types.ts +4 -3
  18. package/src/cli/setup.ts +8 -8
  19. package/src/cli/systemd.ts +2 -0
  20. package/src/cli/update.ts +111 -0
  21. package/src/cron/runner.ts +2 -1
  22. package/src/gateway/commands.ts +29 -4
  23. package/src/{gateway.ts → gateway/gateway.ts} +126 -100
  24. package/src/gateway/helpers.ts +1 -1
  25. package/src/gateway/index.ts +2 -5
  26. package/src/gateway/streaming.ts +1 -1
  27. package/src/gateway/tools-inject.ts +45 -0
  28. package/src/gateway/tools.md +54 -0
  29. package/src/{bundle.ts → provisioning/bundle.ts} +32 -0
  30. package/src/transports/index.ts +6 -0
  31. package/src/{telegram-html.ts → transports/telegram/html.ts} +2 -2
  32. package/src/{pairing.ts → transports/telegram/pairing.ts} +1 -1
  33. package/src/transports/telegram/telegram-adapter.ts +111 -0
  34. package/src/transports/types.ts +71 -0
  35. package/src/voice/providers/whisper.ts +37 -94
  36. package/src/voice/stt-service.ts +35 -17
  37. package/src/voice/types.ts +1 -3
  38. package/src/commands/update.ts +0 -69
  39. /package/src/{commands.ts → transports/telegram/bot-commands.ts} +0 -0
  40. /package/src/{telegram-format.ts → transports/telegram/format.ts} +0 -0
  41. /package/src/{notify/telegram.ts → transports/telegram/notify.ts} +0 -0
  42. /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 "./types";
11
- import { splitMessage, isAllowed, startTypingLoop } from "./util";
12
- import { isTelegramThread, postTelegramHtml } from "./telegram-html";
13
- import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from "./voice/stt-service";
14
- import { sendTelegramToMany } from "./notify/telegram";
15
- import { runDoctor, formatDoctorTelegram, createDoctorContext } from "./cli/doctor/runner";
16
- import { ROUNDHOUSE_DIR, ROUNDHOUSE_VERSION } from "./config";
17
- import { CronSchedulerService } from "./cron/scheduler";
18
- import { BOT_COMMANDS } from "./commands";
19
- import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact, determineMemoryMode } from "./memory/lifecycle";
20
- import { maxPressure } from "./memory/policy";
21
- import type { PressureLevel, CompactResult } from "./memory/types";
22
- import { readPendingPairing, completePendingPairing, isStartForNonce } from "./pairing";
23
- import { createProgressMessage } from "./telegram-progress";
24
- import { isCommand as _isCmd, isCommandWithArgs as _isCmdArgs, resolveAgentThreadId as _resolveThread, getSystemResources as _getSysRes, toolIcon as _toolIcon } from "./gateway/helpers";
25
- import { saveAttachments as _saveAttachments, type AttachmentResult } from "./gateway/attachments";
26
- import { handleStreaming as _handleStream, type StreamResult } from "./gateway/streaming";
27
- import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, handleStop, handleVerbose, handleDoctor, handleCrons, type CommandContext, type StopContext, type VerboseContext, type DoctorContext, type CronsContext } from "./gateway/commands";
28
-
29
- /** Match a Telegram command, handling optional @botname suffix */
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 Telegram pairing from headless setup. Returns true if handled. */
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 pending = await readPendingPairing();
105
- if (!pending || pending.status !== "pending" || !isStartForNonce(text, pending.nonce)) {
106
- return false;
107
- }
99
+ const result = await this.transport.handlePairing(thread, message);
100
+ if (!result) return false;
108
101
 
109
- const fromUser = authorName.toLowerCase();
110
- const allowed = pending.allowedUsers.map(u => u.toLowerCase());
111
- if (!fromUser || !allowed.includes(fromUser)) {
112
- console.log(`[roundhouse] Pairing nonce from unauthorized user @${authorName}`);
113
- return false;
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
- // Extract IDs from the chat adapter message
117
- const chatId = typeof message.chatId === "number"
118
- ? message.chatId
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(chatId)) {
145
- this.config.chat.notifyChatIds.push(chatId);
119
+ if (!this.config.chat.notifyChatIds.includes(threadId)) {
120
+ this.config.chat.notifyChatIds.push(threadId);
146
121
  }
147
122
 
148
- // Atomic config file update
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(chatId)) configRaw.chat.notifyChatIds.push(chatId);
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] Telegram pairing complete: @${authorName} chatId=${chatId} userId=${userId}`);
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(" -> ")}, autoInstall: ${sttConfig.autoInstall ?? false})`);
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 Telegram pairing (headless setup)
247
- if (userText.trim().startsWith("/start ") && !this.pairingComplete) {
248
- const handled = await this.handlePendingPairing(userText.trim(), message, thread, authorName ?? "");
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
- await this.enrichWithStt(thread, agentMessage);
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: "telegram",
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 (agentMessage.attachments.some((a) => a.mediaType === "audio")) {
487
- agentMessage.text = "Voice message attached, but automatic transcription failed.";
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
- // Telegram: send as HTML for proper markdown rendering
616
- if (isTelegramThread(thread)) {
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 sendTelegramToMany([chatId], perChatText);
744
+ await this.transport.notify([chatId], perChatText);
719
745
  }
720
746
  }
721
747
 
@@ -10,7 +10,7 @@ import { hostname, loadavg, totalmem, freemem, cpus } from "node:os";
10
10
  // ── Command Matching ─────────────────────────────────
11
11
 
12
12
  /**
13
- * Match a Telegram command, handling optional @botname suffix.
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;
@@ -1,11 +1,8 @@
1
1
  /**
2
- * gateway/index.ts — Barrel export for gateway sub-modules
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";
@@ -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-html";
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, "&lt;").replace(/>/g, "&gt;"));
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);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * transports/index.ts — Transport adapter registry
3
+ */
4
+
5
+ export type { TransportAdapter, ChatThread, IncomingMessage, PairingResult } from "./types";
6
+ export { TelegramAdapter } from "./telegram/telegram-adapter";
@@ -8,8 +8,8 @@
8
8
  * typing indicators, command handling, authorization, message history.
9
9
  */
10
10
 
11
- import { markdownToTelegramHtml, truncateHtmlSafe } from "./telegram-format";
12
- import { splitMessage } from "./util";
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 "./config";
11
+ import { ROUNDHOUSE_DIR } from "../../config";
12
12
 
13
13
  export interface PendingPairing {
14
14
  version: 1;