@inceptionstack/roundhouse 0.5.2 → 0.5.4

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/src/gateway.ts CHANGED
@@ -7,90 +7,47 @@
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, MessageAttachment } from "./types";
11
- import { splitMessage, isAllowed, startTypingLoop, threadIdToDir, generateAttachmentId, DEBUG_STREAM } from "./util";
12
- import { isTelegramThread, postTelegramHtml, handleTelegramHtmlStream } from "./telegram-html";
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
13
  import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from "./voice/stt-service";
14
14
  import { sendTelegramToMany } from "./notify/telegram";
15
15
  import { runDoctor, formatDoctorTelegram, createDoctorContext } from "./cli/doctor/runner";
16
16
  import { ROUNDHOUSE_DIR, ROUNDHOUSE_VERSION } from "./config";
17
17
  import { CronSchedulerService } from "./cron/scheduler";
18
- import { isBuiltinJob } from "./cron/helpers";
19
- import { formatSchedule, formatRunCounts, jobEnabledIcon } from "./cron/format";
20
18
  import { BOT_COMMANDS } from "./commands";
21
19
  import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact, determineMemoryMode } from "./memory/lifecycle";
22
20
  import { maxPressure } from "./memory/policy";
23
21
  import type { PressureLevel, CompactResult } from "./memory/types";
24
- import { READ_ONLY_TOOLS } from "./memory/types";
25
22
  import { readPendingPairing, completePendingPairing, isStartForNonce } from "./pairing";
26
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";
27
28
 
28
29
  /** Match a Telegram command, handling optional @botname suffix */
29
30
  /** Bot username for command suffix validation (set during gateway init) */
30
31
  let _botUsername = "";
31
32
 
32
33
  function isCommand(text: string, cmd: string): boolean {
33
- if (text === cmd) return true;
34
- if (!text.startsWith(`${cmd}@`)) return false;
35
- if (!_botUsername) return false; // no bot name configured, reject suffixed commands
36
- const suffix = text.slice(cmd.length + 1).toLowerCase();
37
- return suffix === _botUsername.toLowerCase();
34
+ return _isCmd(text, cmd, _botUsername);
38
35
  }
39
36
 
40
37
  /** Match a command that accepts subcommands (e.g. /crons trigger <id>) */
41
38
  function isCommandWithArgs(text: string, cmd: string): boolean {
42
- if (text === cmd || text.startsWith(`${cmd} `)) return true;
43
- if (!text.startsWith(`${cmd}@`)) return false;
44
- if (!_botUsername) return false;
45
- const rest = text.slice(cmd.length + 1);
46
- const spaceIdx = rest.indexOf(" ");
47
- const suffix = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
48
- return suffix.toLowerCase() === _botUsername.toLowerCase();
39
+ return _isCmdArgs(text, cmd, _botUsername);
49
40
  }
50
- import { hostname, loadavg, totalmem, freemem, cpus } from "node:os";
41
+ import { hostname } from "node:os";
51
42
 
52
- /** Get system resource info */
53
43
  function getSystemResources() {
54
- const load1 = loadavg()[0];
55
- const cpuCount = cpus().length;
56
- const totalGB = (totalmem() / 1024 / 1024 / 1024).toFixed(1);
57
- const usedGB = ((totalmem() - freemem()) / 1024 / 1024 / 1024).toFixed(1);
58
- const memPct = Math.round(((totalmem() - freemem()) / totalmem()) * 100);
59
- const cpuPct = Math.min(100, Math.round((load1 / cpuCount) * 100));
60
- return { load1, cpuCount, totalGB, usedGB, memPct, cpuPct };
61
- }
62
- import { readFileSync, mkdirSync } from "node:fs";
63
- import { writeFile } from "node:fs/promises";
64
- import { join, dirname, basename } from "node:path";
65
- import { fileURLToPath } from "node:url";
66
-
67
- const __gatewayDir = dirname(fileURLToPath(import.meta.url));
68
-
69
- function telegramChatIdFromThreadId(threadId: unknown): number | null {
70
- if (typeof threadId !== "string") return null;
71
- const match = threadId.match(/^telegram:(-?\d+)/);
72
- if (!match) return null;
73
- const parsed = parseInt(match[1], 10);
74
- return Number.isNaN(parsed) ? null : parsed;
44
+ return _getSysRes();
75
45
  }
46
+ import { join } from "node:path";
76
47
 
77
- function getChatId(thread: any, message: any): string {
78
- const id = message?.chat?.id ?? message?.chatId ?? thread?.chatId;
79
- if (id !== undefined && id !== null) return String(id);
80
- return String(thread?.id ?? "unknown");
81
- }
82
48
 
83
49
  function resolveAgentThreadId(thread: any, message: any): string {
84
- const chatType = String(message?.chat?.type ?? thread?.chat?.type ?? thread?.type ?? "").toLowerCase();
85
- if (["private", "dm", "direct", "im"].includes(chatType)) return "main";
86
- if (["group", "supergroup", "channel"].includes(chatType)) return `group:${getChatId(thread, message)}`;
87
-
88
- const telegramChatId = telegramChatIdFromThreadId(thread?.id);
89
- if (telegramChatId !== null) {
90
- return telegramChatId < 0 ? `group:${telegramChatId}` : "main";
91
- }
92
-
93
- return String(thread?.id ?? "main");
50
+ return _resolveThread(thread, message);
94
51
  }
95
52
 
96
53
  // ── Chat SDK adapter factories ───────────────────────
@@ -111,155 +68,13 @@ async function buildChatAdapters(
111
68
  return adapters;
112
69
  }
113
70
 
114
- // ── Tool name formatting ─────────────────────────────
115
-
116
- const TOOL_ICONS: Record<string, string> = {
117
- bash: "⚡",
118
- read: "📖",
119
- edit: "✏️",
120
- write: "📝",
121
- grep: "🔍",
122
- find: "🔎",
123
- ls: "📂",
124
- };
125
-
126
71
  function toolIcon(name: string): string {
127
- return TOOL_ICONS[name] ?? "🔧";
128
- }
129
-
130
- // ── Incoming file storage ─────────────────────────────
131
-
132
- const INCOMING_DIR = process.env.ROUNDHOUSE_INCOMING_DIR
133
- ?? join(ROUNDHOUSE_DIR, "incoming");
134
-
135
- const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB per file
136
- const MAX_ATTACHMENTS = 5;
137
-
138
- const MIME_EXTENSIONS: Record<string, string> = {
139
- "audio/ogg": ".ogg",
140
- "audio/mpeg": ".mp3",
141
- "audio/mp4": ".m4a",
142
- "audio/wav": ".wav",
143
- "audio/webm": ".webm",
144
- "image/jpeg": ".jpg",
145
- "image/png": ".png",
146
- "image/webp": ".webp",
147
- "image/gif": ".gif",
148
- "video/mp4": ".mp4",
149
- "application/pdf": ".pdf",
150
- };
151
-
152
- /** Sanitize a filename to safe ASCII characters, capped length */
153
- function safeName(raw: string): string {
154
- let name = basename(raw);
155
- // Replace anything not alphanumeric, dot, dash, underscore with _
156
- name = name.replace(/[^a-zA-Z0-9._-]/g, "_");
157
- // Cap length (truncate from start to preserve extension)
158
- if (name.length > 100) name = name.slice(-100);
159
- // Remove leading dashes/dots/underscores (prevent hidden files or option-like names)
160
- // Applied AFTER truncation so slice(-100) can't reintroduce them
161
- name = name.replace(/^[-_.]+/, "");
162
- return name || "attachment";
72
+ return _toolIcon(name);
163
73
  }
164
74
 
165
- /** Result of saving attachments: saved files + user-facing warnings */
166
- interface AttachmentResult {
167
- saved: MessageAttachment[];
168
- skipped: string[]; // user-facing reasons for skipped attachments
169
- }
170
75
 
171
76
  async function saveAttachments(threadId: string, attachments: any[]): Promise<AttachmentResult> {
172
- if (!attachments?.length) return { saved: [], skipped: [] };
173
-
174
- const skipped: string[] = [];
175
- const toProcess = attachments.slice(0, MAX_ATTACHMENTS);
176
- if (attachments.length > MAX_ATTACHMENTS) {
177
- skipped.push(`${attachments.length - MAX_ATTACHMENTS} attachment(s) skipped (max ${MAX_ATTACHMENTS} per message)`);
178
- console.warn(`[roundhouse] too many attachments (${attachments.length}), processing first ${MAX_ATTACHMENTS}`);
179
- }
180
-
181
- // Per-message directory: <thread>/<timestamp_nonce>/
182
- const msgDir = join(INCOMING_DIR, threadIdToDir(threadId), `${Date.now()}_${generateAttachmentId()}`);
183
- try {
184
- mkdirSync(msgDir, { recursive: true, mode: 0o700 });
185
- } catch (err) {
186
- console.error(`[roundhouse] failed to create incoming dir ${msgDir}:`, (err as Error).message);
187
- return { saved: [], skipped: ["Failed to create storage directory"] };
188
- }
189
-
190
- const saved: MessageAttachment[] = [];
191
- for (let i = 0; i < toProcess.length; i++) {
192
- const att = toProcess[i];
193
- try {
194
- // Check size hint before downloading if available
195
- if (att.size && att.size > MAX_FILE_SIZE) {
196
- const sizeMB = (att.size / 1024 / 1024).toFixed(1);
197
- skipped.push(`${att.name ?? att.type} (${sizeMB} MB) exceeds ${MAX_FILE_SIZE / 1024 / 1024} MB limit`);
198
- console.warn(`[roundhouse] attachment too large (${att.size} bytes), skipping: ${att.name ?? att.type}`);
199
- continue;
200
- }
201
-
202
- const data = att.data ?? (att.fetchData ? await att.fetchData() : null);
203
- if (!data) {
204
- console.warn(`[roundhouse] attachment has no data: ${att.name ?? att.type}`);
205
- continue;
206
- }
207
-
208
- // Convert to Buffer with size cap to prevent memory exhaustion
209
- let buf: Buffer;
210
- if (Buffer.isBuffer(data)) {
211
- buf = data;
212
- } else if (data instanceof Blob) {
213
- // Stream blobs with size cap
214
- if (data.size > MAX_FILE_SIZE) {
215
- skipped.push(`${att.name ?? att.type} (${(data.size / 1024 / 1024).toFixed(1)} MB) exceeds size limit`);
216
- continue;
217
- }
218
- buf = Buffer.from(await data.arrayBuffer());
219
- } else if (ArrayBuffer.isView(data)) {
220
- buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
221
- } else if (data instanceof ArrayBuffer) {
222
- buf = Buffer.from(data);
223
- } else {
224
- console.warn(`[roundhouse] unknown attachment data type, skipping: ${att.name ?? att.type}`);
225
- continue;
226
- }
227
-
228
- if (buf.length > MAX_FILE_SIZE) {
229
- const sizeMB = (buf.length / 1024 / 1024).toFixed(1);
230
- skipped.push(`${att.name ?? att.type} (${sizeMB} MB) exceeds size limit`);
231
- console.warn(`[roundhouse] attachment too large after download (${buf.length} bytes), skipping`);
232
- continue;
233
- }
234
-
235
- const mime = att.mimeType ?? "application/octet-stream";
236
- const ext = att.name
237
- ? (att.name.includes(".") ? "" : (MIME_EXTENSIONS[mime] ?? ""))
238
- : (MIME_EXTENSIONS[mime] ?? ".bin");
239
- const rawName = att.name ? safeName(att.name) + ext : `${att.type ?? "file"}${ext}`;
240
- const fileName = `${i}-${rawName}`;
241
- const filePath = join(msgDir, fileName);
242
-
243
- await writeFile(filePath, buf, { mode: 0o600 });
244
-
245
- const VALID_MEDIA_TYPES = new Set(["audio", "image", "file", "video"]);
246
- const mediaType = VALID_MEDIA_TYPES.has(att.type) ? att.type : "file";
247
- const id = generateAttachmentId();
248
- saved.push({
249
- id,
250
- mediaType,
251
- name: rawName,
252
- localPath: filePath,
253
- mime,
254
- sizeBytes: buf.length,
255
- untrusted: true,
256
- });
257
- console.log(`[roundhouse] saved ${att.type} [${id}]: ${filePath} (${buf.length} bytes)`);
258
- } catch (err) {
259
- console.error(`[roundhouse] failed to save attachment:`, (err as Error).message);
260
- }
261
- }
262
- return { saved, skipped };
77
+ return _saveAttachments(threadId, attachments);
263
78
  }
264
79
 
265
80
  // ── Gateway ──────────────────────────────────────────
@@ -442,281 +257,150 @@ export class Gateway {
442
257
  if (isCommand(userText, "/start")) return;
443
258
  if (!userText.trim() && !rawAttachments.length) return;
444
259
 
445
- // Handle /new command — dispose current session, start fresh
260
+ // Handle /new command
446
261
  if (isCommand(userText.trim(), "/new")) {
447
- const agent = this.router.resolve(agentThreadId);
448
- if (agent.restart) {
449
- await agent.restart(agentThreadId);
450
- await thread.post(`🔄 Session restarted (\`${agentThreadId}\`). Send a message to begin a new conversation.`);
451
- } else {
452
- await thread.post("⚠️ New session not supported for this agent.");
453
- }
454
- console.log(`[roundhouse] /new for thread=${thread.id} agentThread=${agentThreadId}`);
262
+ await handleNew(this.buildCommandContext(thread, message, agentThreadId, authorName, allowedUsers, allowedUserIds, verboseThreads, threadLocks));
455
263
  return;
456
264
  }
457
265
 
458
- // Handle /restart command — restart the gateway process
459
- // Only available when an allowlist is configured (all allowed users can restart)
266
+ // Handle /restart command
460
267
  if (isCommand(userText.trim(), "/restart")) {
461
- if (allowedUsers.length === 0 && allowedUserIds.length === 0) {
462
- await thread.post("⚠️ /restart requires an allowlist (allowedUsers or allowedUserIds) to be configured.");
463
- return;
464
- }
465
- console.log(`[roundhouse] /restart requested by @${authorName} in thread=${thread.id}`);
466
- await thread.post("🔄 Restarting gateway...");
467
- // Graceful shutdown then exit with non-zero so systemd Restart=on-failure brings us back
468
- setTimeout(async () => {
469
- console.log("[roundhouse] shutting down for restart");
470
- try { await this.stop(); } catch (e) { console.error("[roundhouse] stop error:", e); }
471
- process.exit(75);
472
- }, 1000);
268
+ await handleRestart(this.buildCommandContext(thread, message, agentThreadId, authorName, allowedUsers, allowedUserIds, verboseThreads, threadLocks));
473
269
  return;
474
270
  }
475
271
 
476
- // Handle /update command — update roundhouse then restart
272
+ // Handle /update command
477
273
  if (isCommand(userText.trim(), "/update")) {
478
- if (allowedUsers.length === 0 && allowedUserIds.length === 0) {
479
- await thread.post("⚠️ /update requires an allowlist to be configured.");
480
- return;
481
- }
482
- console.log(`[roundhouse] /update requested by @${authorName} in thread=${thread.id}`);
483
- const progress = await createProgressMessage(thread, "📦 Checking for updates...");
484
- try {
485
- const { performUpdate } = await import("./commands/update");
486
- const result = await performUpdate(progress);
487
- if (result.action === "already-latest") {
488
- await progress.update(`✅ Already on latest (v${result.currentVersion})`);
489
- } else if (result.action === "updated") {
490
- await progress.update(`✅ Updated v${result.currentVersion} → v${result.latestVersion}. Restarting...`);
491
- console.log(`[roundhouse] updated ${result.currentVersion} -> ${result.latestVersion}, restarting`);
492
- setTimeout(async () => {
493
- try { await this.stop(); } catch (e) { console.error("[roundhouse] stop error:", e); }
494
- process.exit(75);
495
- }, 1500);
496
- }
497
- } catch (err) {
498
- const msg = err instanceof Error ? err.message : String(err);
499
- await progress.update(`⚠️ Update failed: ${msg.slice(0, 200)}`);
500
- console.error(`[roundhouse] /update failed:`, msg);
501
- }
274
+ await handleUpdate(this.buildCommandContext(thread, message, agentThreadId, authorName, allowedUsers, allowedUserIds, verboseThreads, threadLocks));
502
275
  return;
503
276
  }
504
277
 
505
- // Handle /compact command — flush memory then compact session context
506
- // Routed through the per-thread lock to prevent concurrent agent access
278
+ // Handle /compact command
507
279
  if (isCommand(userText.trim(), "/compact")) {
508
- const agent = this.router.resolve(agentThreadId);
509
- if (!agent.compact) {
510
- await thread.post("⚠️ Compaction not supported for this agent.");
511
- return;
512
- }
513
- console.log(`[roundhouse] /compact for thread=${thread.id} agentThread=${agentThreadId}`);
514
-
515
- // Acquire per-thread lock (same as normal prompts)
516
- const prevLock = threadLocks.get(agentThreadId);
517
- let releaseLock: () => void;
518
- const lockPromise = new Promise<void>((resolve) => { releaseLock = resolve; });
519
- threadLocks.set(agentThreadId, lockPromise);
520
- if (prevLock) await prevLock;
521
-
522
- const progress = await createProgressMessage(thread, "📝 Saving memory and compacting...");
523
- const stopTyping = startTypingLoop(thread);
524
- try {
525
- const agentCwd = (agent.getInfo?.()?.cwd as string) ?? process.cwd();
526
- const memoryRoot = this.config.memory?.rootDir ?? agentCwd;
527
- // If memory is disabled, compact directly without flush
528
- if (this.config.memory?.enabled === false) {
529
- const result = await agent.compact(agentThreadId);
530
- if (!result) {
531
- await progress.update("⚠️ No active session to compact. Send a message first.");
532
- } else {
533
- const beforeK = (result.tokensBefore / 1000).toFixed(1);
534
- await progress.update(`✅ Compaction complete\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`);
535
- }
536
- } else {
537
- const result = await flushMemoryThenCompact(
538
- agentThreadId, agent, memoryRoot, "manual", this.config.memory,
539
- (step) => progress.update(step),
540
- );
541
- if (!result) {
542
- await progress.update("⚠️ No active session to compact. Send a message first.");
543
- } else {
544
- const beforeK = (result.tokensBefore / 1000).toFixed(1);
545
- const timing = result.timing;
546
- const timingLine = timing ? `\nTiming: flush ${(timing.flushMs / 1000).toFixed(1)}s, compact ${(timing.compactMs / 1000).toFixed(1)}s, total ${(timing.totalMs / 1000).toFixed(1)}s\nModel: ${timing.model}` : "";
547
- await progress.update(`✅ Memory saved & compacted\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.${timingLine}`);
548
- }
549
- }
550
- } catch (err) {
551
- const msg = err instanceof Error ? err.message : String(err);
552
- await progress.update(`⚠️ Compaction failed: ${msg.slice(0, 200)}`);
553
- } finally {
554
- stopTyping();
555
- releaseLock!();
556
- if (threadLocks.get(agentThreadId) === lockPromise) {
557
- threadLocks.delete(agentThreadId);
558
- }
559
- }
280
+ await handleCompact(this.buildCommandContext(thread, message, agentThreadId, authorName, allowedUsers, allowedUserIds, verboseThreads, threadLocks));
560
281
  return;
561
282
  }
562
283
 
563
- // Handle /status command — show gateway details
284
+ // Handle /status command
564
285
  if (isCommand(userText.trim(), "/status")) {
565
- const agent = this.router.resolve(agentThreadId);
566
- const uptimeSec = process.uptime();
567
- const uptimeStr = uptimeSec < 3600
568
- ? `${Math.floor(uptimeSec / 60)}m ${Math.floor(uptimeSec % 60)}s`
569
- : `${Math.floor(uptimeSec / 3600)}h ${Math.floor((uptimeSec % 3600) / 60)}m`;
570
- const platforms = Object.keys(this.config.chat.adapters).join(", ");
571
- const debugStream = process.env.ROUNDHOUSE_DEBUG_STREAM === "1";
572
- const nodeVer = process.version;
573
- const memMB = (process.memoryUsage.rss() / 1024 / 1024).toFixed(1);
574
-
575
- const info = agent.getInfo ? agent.getInfo(agentThreadId) : {};
576
- const agentVersion = info.version ? `v${info.version}` : "";
577
- const agentLabel = agentVersion ? `\`${agent.name}\` (${agentVersion})` : `\`${agent.name}\``;
578
-
579
- const lines = [
580
- `📊 *Roundhouse Status*`,
581
- ``,
582
- `🎫 Session: \`${agentThreadId}\``,
583
- `📦 Roundhouse: v${ROUNDHOUSE_VERSION}`,
584
- `🤖 Agent: ${agentLabel}`,
585
- ];
586
-
587
- if (info.model) lines.push(`🧠 Model: \`${info.model}\``);
588
- if (info.activeSessions !== undefined) lines.push(`💬 Active sessions: ${info.activeSessions}`);
589
-
590
- lines.push(
591
- `🌐 Platforms: ${platforms}`,
592
- `👤 Bot: @${this.config.chat.botUsername}`,
593
- `⏱ Uptime: ${uptimeStr}`,
594
- `💾 Memory: ${memMB} MB`,
595
- `🟢 Node: ${nodeVer}`,
596
- `🔧 Debug stream: ${debugStream ? "on" : "off"}`,
597
- `📢 Verbose: ${verboseThreads.has(agentThreadId) ? "on" : "off"}`,
598
- );
599
-
600
- const allowedCount = allowedUsers.length;
601
- lines.push(`🔐 Allowed users: ${allowedCount === 0 ? "all (no allowlist)" : allowedCount}`);
602
-
603
- // System resources
604
- const sys = getSystemResources();
605
- lines.push(``);
606
- lines.push(`🖥 *System*`);
607
- lines.push(` CPU: ${sys.cpuPct}% (load ${sys.load1.toFixed(2)}, ${sys.cpuCount} cores)`);
608
- lines.push(` RAM: ${sys.usedGB}/${sys.totalGB} GB (${sys.memPct}%)`);
609
- lines.push(` Process: ${memMB} MB RSS`);
610
-
611
- // Memory system mode
612
- const memMode = determineMemoryMode(info);
613
- const memEnabled = this.config.memory?.enabled !== false;
614
- const memLabel = !memEnabled ? "disabled"
615
- : memMode === "complement" ? "agent-managed (pi-memory)"
616
- : memMode === "full" ? "roundhouse-managed"
617
- : "pending detection";
618
- lines.push(``);
619
- lines.push(`🧠 Memory: ${memLabel}`);
620
-
621
- // Context usage with progress bar
622
- if (typeof info.contextTokens === "number" && typeof info.contextWindow === "number" && info.contextWindow > 0) {
623
- const pct = Math.min(100, Math.round((info.contextTokens as number) / (info.contextWindow as number) * 100));
624
- const barLen = 20;
625
- const filled = Math.round(pct / 100 * barLen);
626
- const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
627
- const tokensK = ((info.contextTokens as number) / 1000).toFixed(1);
628
- const windowK = ((info.contextWindow as number) / 1000).toFixed(0);
629
- lines.push(``);
630
- lines.push(`📝 Context: \`${bar}\` ${pct}%`);
631
- lines.push(` ${tokensK}K / ${windowK}K tokens`);
632
- } else if (typeof info.contextWindow === "number" && info.contextWindow > 0) {
633
- const windowK = ((info.contextWindow as number) / 1000).toFixed(0);
634
- lines.push(``);
635
- lines.push(`📝 Context: no usage data yet (${windowK}K window)`);
636
- }
286
+ await handleStatus(this.buildCommandContext(thread, message, agentThreadId, authorName, allowedUsers, allowedUserIds, verboseThreads, threadLocks));
287
+ return;
288
+ }
637
289
 
638
- // Extensions
639
- const extensions = Array.isArray(info.extensions) ? info.extensions as string[] : [];
640
- if (extensions.length > 0) {
641
- lines.push(``);
642
- lines.push(`🧩 Extensions (${extensions.length}):`);
643
- for (const ext of extensions) {
644
- // Show short name: strip npm: prefix and path noise
645
- const short = ext.replace(/^.*node_modules\//, "").replace(/\/index\.[tj]s$/, "");
646
- lines.push(` • ${short}`);
647
- }
648
- }
290
+ // Dispatch to agent turn handler
291
+ await this.handleAgentTurn(thread, agentThreadId, userText, rawAttachments, verboseThreads, threadLocks, abortControllers);
292
+ };
649
293
 
650
- await this.postWithFallback(thread, lines.join("\n"));
651
- console.log(`[roundhouse] /status for thread=${thread.id} agentThread=${agentThreadId}`);
294
+ // ── Wire Chat SDK events ───────────────────────
295
+ const handleOrAbort = async (thread: any, message: any) => {
296
+ const agentThreadId = resolveAgentThreadId(thread, message);
297
+ const text = (message.text ?? "").trim();
298
+ // /stop — abort the in-flight agent run immediately
299
+ if (isCommand(text, "/stop")) {
300
+ if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
301
+ await handleStop({ thread, agentThreadId, agent: this.router.resolve(agentThreadId), abortControllers });
652
302
  return;
653
303
  }
654
-
655
- // Save any attachments (voice messages, images, files, etc.)
656
- let attachmentResult: AttachmentResult = { saved: [], skipped: [] };
657
- try {
658
- attachmentResult = await saveAttachments(agentThreadId, rawAttachments);
659
- } catch (err) {
660
- console.error(`[roundhouse] saveAttachments error:`, (err as Error).message);
661
- if (!userText.trim()) {
662
- try { await thread.post("⚠️ Failed to process attachment(s). Please try again."); } catch {}
663
- return;
664
- }
304
+ // /verbose — toggle verbose mode immediately
305
+ if (isCommand(text, "/verbose")) {
306
+ if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
307
+ await handleVerbose({ thread, agentThreadId, verboseThreads });
308
+ return;
665
309
  }
666
-
667
- // Notify user about skipped attachments
668
- if (attachmentResult.skipped.length > 0) {
669
- const skipMsg = attachmentResult.skipped.map((s) => `\u2022 ${s}`).join("\n");
670
- try { await thread.post(`⚠️ Some attachments were skipped:\n${skipMsg}`); } catch {}
310
+ // /doctor — run health checks immediately
311
+ if (isCommand(text, "/doctor")) {
312
+ if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
313
+ await handleDoctor({ thread, runDoctor, createDoctorContext, formatDoctorTelegram, postWithFallback: (t, txt) => this.postWithFallback(t, txt) });
314
+ return;
671
315
  }
672
-
673
- // Build AgentMessage
674
- const promptText = userText.trim();
675
- let agentMessage: AgentMessage = {
676
- text: promptText,
677
- attachments: attachmentResult.saved.length > 0 ? attachmentResult.saved : undefined,
678
- };
679
-
680
- if (!promptText && !agentMessage.attachments) {
681
- if (rawAttachments.length > 0) {
682
- // All attachments failed to save but message was attachment-only
683
- try { await thread.post("⚠️ Failed to save attachment(s). Please try again."); } catch {}
684
- }
316
+ // /crons manages scheduled jobs
317
+ if (isCommandWithArgs(text, "/crons") || isCommandWithArgs(text, "/jobs")) {
318
+ if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
319
+ await handleCrons({ thread, text, cronScheduler: this.cronScheduler, postWithFallback: (t, txt) => this.postWithFallback(t, txt) });
685
320
  return;
686
321
  }
322
+ await handle(thread, message);
323
+ };
324
+
325
+ this.chat.onDirectMessage(async (thread, message) => {
326
+ await thread.subscribe();
327
+ await handleOrAbort(thread, message);
328
+ });
329
+
330
+ this.chat.onNewMention(async (thread, message) => {
331
+ await thread.subscribe();
332
+ await handleOrAbort(thread, message);
333
+ });
334
+
335
+ this.chat.onSubscribedMessage(async (thread, message) => {
336
+ await handleOrAbort(thread, message);
337
+ });
338
+
339
+ await this.chat.initialize();
340
+
341
+ const platforms = Object.keys(this.config.chat.adapters).join(", ");
342
+ console.log(`[roundhouse] gateway ready (platforms: ${platforms})`);
687
343
 
688
- const agent = this.router.resolve(agentThreadId);
344
+ // ── Register bot commands ───
345
+ await this.registerBotCommands();
689
346
 
690
- // Serialize prompts per-thread (concurrent mode allows /stop to bypass)
691
- const prevLock = threadLocks.get(agentThreadId);
692
- let releaseLock: () => void;
693
- const lockPromise = new Promise<void>((resolve) => { releaseLock = resolve; });
694
- threadLocks.set(agentThreadId, lockPromise);
695
- if (prevLock) await prevLock;
347
+ // Start cron scheduler (await so job counts are available for startup notification)
348
+ this.cronScheduler = new CronSchedulerService({
349
+ agentConfig: this.config.agent,
350
+ notifyChatIds: this.config.chat.notifyChatIds,
351
+ });
352
+ try {
353
+ await this.cronScheduler.start();
354
+ } catch (err) {
355
+ console.error("[roundhouse] cron scheduler start failed:", (err as Error).message);
356
+ }
696
357
 
358
+ // Send startup notification (after cron init so we can include job counts)
359
+ await this.notifyStartup(platforms);
360
+ }
361
+
362
+ /**
363
+ * Process a user message through the agent pipeline:
364
+ * save attachments → build message → STT → memory inject → prompt → memory finalize → pressure check
365
+ */
366
+ private async handleAgentTurn(
367
+ thread: any, agentThreadId: string, userText: string, rawAttachments: any[],
368
+ verboseThreads: Set<string>, threadLocks: Map<string, Promise<void>>, abortControllers: Map<string, AbortController>,
369
+ ): Promise<void> {
370
+ // Prepare message (save attachments, build AgentMessage)
371
+ const result = await this.prepareAgentMessage(thread, agentThreadId, userText, rawAttachments);
372
+ if (!result) return; // nothing to send (empty message after attachment failure)
373
+ let agentMessage = result;
374
+
375
+ const agent = this.router.resolve(agentThreadId);
376
+
377
+ // Serialize prompts per-thread (concurrent mode allows /stop to bypass)
378
+ const prevLock = threadLocks.get(agentThreadId);
379
+ let releaseLock: () => void;
380
+ const lockPromise = new Promise<void>((resolve) => { releaseLock = resolve; });
381
+ threadLocks.set(agentThreadId, lockPromise);
382
+ if (prevLock) await prevLock;
383
+
384
+ let stopTyping: (() => void) | null = null;
385
+ try {
697
386
  console.log(`[roundhouse] → ${agent.name} | thread=${agentThreadId}`);
698
387
 
699
- // Enrich audio attachments with transcripts (STT) — inside thread lock to prevent stampede
700
- if (this.sttService && agentMessage.attachments?.length) {
388
+ // Enrich audio attachments with transcripts (STT)
389
+ await this.enrichWithStt(thread, agentMessage);
390
+
391
+ // Let the agent adapter apply platform-specific message transforms
392
+ if (agent.prepareMessage) {
701
393
  try {
702
- await enrichAttachmentsWithTranscripts(agentMessage.attachments, this.sttService, (text) => thread.post(text));
703
- // Update text for voice-only messages after transcription
704
- if (!agentMessage.text) {
705
- const transcripts = agentMessage.attachments
706
- .filter((a) => a.transcript?.status === "completed" && a.transcript.text)
707
- .map((a) => a.transcript!.text);
708
- if (transcripts.length > 0) {
709
- agentMessage.text = `Voice message transcript: ${transcripts.join(" ")}`;
710
- } else if (agentMessage.attachments.some((a) => a.mediaType === "audio")) {
711
- agentMessage.text = "Voice message attached, but automatic transcription failed.";
712
- }
713
- }
394
+ agentMessage = agent.prepareMessage(agentThreadId, agentMessage, {
395
+ platform: "telegram",
396
+ hasAttachments: !!(agentMessage.attachments?.length),
397
+ });
714
398
  } catch (err) {
715
- console.error(`[roundhouse] STT enrichment error:`, (err as Error).message);
399
+ console.error(`[roundhouse] prepareMessage error:`, (err as Error).message);
716
400
  }
717
401
  }
718
402
 
719
- // ── Memory: pre-turn injection (Full mode only) ───
403
+ // Memory: pre-turn injection (Full mode only)
720
404
  const agentCwd = (agent.getInfo?.()?.cwd as string) ?? process.cwd();
721
405
  const memoryRoot = this.config.memory?.rootDir ?? agentCwd;
722
406
  let memoryPrepared: Awaited<ReturnType<typeof prepareMemoryForTurn>> | undefined;
@@ -727,7 +411,7 @@ export class Gateway {
727
411
  console.error(`[roundhouse] memory prepare error:`, (err as Error).message);
728
412
  }
729
413
 
730
- const stopTyping = startTypingLoop(thread);
414
+ stopTyping = startTypingLoop(thread);
731
415
 
732
416
  try {
733
417
  let turnUsedTools = false;
@@ -741,7 +425,6 @@ export class Gateway {
741
425
  abortControllers.delete(agentThreadId);
742
426
  }
743
427
  } else {
744
- // Fallback: non-streaming prompt (assume tools may have been used)
745
428
  const reply = await agent.prompt(agentThreadId, agentMessage);
746
429
  turnUsedTools = true;
747
430
  if (reply.text) {
@@ -749,7 +432,7 @@ export class Gateway {
749
432
  }
750
433
  }
751
434
 
752
- // ── Memory: post-turn finalize + pressure check ───
435
+ // Memory: post-turn finalize + pressure check
753
436
  try {
754
437
  if (memoryPrepared) memoryPrepared.turnUsedTools = turnUsedTools;
755
438
  const pressure = await finalizeMemoryForTurn(
@@ -757,10 +440,8 @@ export class Gateway {
757
440
  memoryPrepared ?? { message: agentMessage, beforeDigest: null, injected: false },
758
441
  agent, memoryRoot, this.config.memory,
759
442
  );
760
- // Use higher severity between pending compact and current pressure
761
443
  const effectivePressure = maxPressure(memoryPrepared?.pendingCompact, pressure);
762
444
  if (effectivePressure !== "none") {
763
- // Run flush/compact INSIDE the thread lock to prevent race with next user message
764
445
  try {
765
446
  await this.handleContextPressure(thread, agentThreadId, agent, memoryRoot, effectivePressure);
766
447
  } catch (err) {
@@ -778,158 +459,76 @@ export class Gateway {
778
459
  await thread.post(`⚠️ Error: ${safeMsg}`);
779
460
  } catch {}
780
461
  } finally {
781
- stopTyping();
782
- releaseLock!();
783
- if (threadLocks.get(agentThreadId) === lockPromise) {
784
- threadLocks.delete(agentThreadId);
785
- }
462
+ if (stopTyping) stopTyping();
786
463
  }
787
- };
788
-
789
- // ── Wire Chat SDK events ───────────────────────
790
- const handleOrAbort = async (thread: any, message: any) => {
791
- const agentThreadId = resolveAgentThreadId(thread, message);
792
- const text = (message.text ?? "").trim();
793
- // /stop is handled immediately — abort the in-flight agent run
794
- // without waiting for the current handler to finish
795
- if (isCommand(text, "/stop")) {
796
- if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
797
- const agent = this.router.resolve(agentThreadId);
798
- if (agent.abort) {
799
- await agent.abort(agentThreadId);
800
- abortControllers.get(agentThreadId)?.abort();
801
- try { await thread.post("⏹️ Stopped."); } catch {}
802
- } else {
803
- try { await thread.post("⚠️ Abort not supported for this agent."); } catch {}
804
- }
805
- console.log(`[roundhouse] /stop for thread=${thread.id} agentThread=${agentThreadId}`);
806
- return;
807
- }
808
- // /verbose is a gateway toggle — runs immediately, no queuing
809
- if (isCommand(text, "/verbose")) {
810
- if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
811
- const threadId = agentThreadId;
812
- if (verboseThreads.has(threadId)) {
813
- verboseThreads.delete(threadId);
814
- try { await thread.post("🔇 Verbose mode OFF — tool status messages hidden."); } catch {}
815
- } else {
816
- verboseThreads.add(threadId);
817
- try { await thread.post("📢 Verbose mode ON — showing tool calls."); } catch {}
818
- }
819
- console.log(`[roundhouse] /verbose for thread=${thread.id} agentThread=${threadId} -> ${verboseThreads.has(threadId) ? "on" : "off"}`);
820
- return;
464
+ } finally {
465
+ releaseLock!();
466
+ if (threadLocks.get(agentThreadId) === lockPromise) {
467
+ threadLocks.delete(agentThreadId);
821
468
  }
822
- // /doctor runs health checks immediately — no agent access needed
823
- if (isCommand(text, "/doctor")) {
824
- if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
825
- const stopTyping = startTypingLoop(thread);
826
- try {
827
- const results = await runDoctor(await createDoctorContext());
828
- const report = formatDoctorTelegram(results);
829
- await this.postWithFallback(thread, report);
830
- } catch (err) {
831
- try { await thread.post(`⚠️ Doctor failed: ${(err as Error).message}`); } catch {}
832
- } finally {
833
- stopTyping();
834
- }
835
- console.log(`[roundhouse] /doctor for thread=${thread.id}`);
836
- return;
837
- }
838
- // /crons manages scheduled jobs
839
- if (isCommandWithArgs(text, "/crons") || isCommandWithArgs(text, "/jobs")) {
840
- if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
841
- const stopTyping = startTypingLoop(thread);
842
- try {
843
- const parts = text.split(/\s+/).slice(1); // remove /crons
844
- const sub = parts[0];
845
- const id = parts[1];
846
-
847
- if (!this.cronScheduler) {
848
- await thread.post("⚠️ Cron scheduler not running.");
849
- } else if (sub === "trigger" && id) {
850
- if (isBuiltinJob(id)) { await thread.post(`⚠️ ${id} is a built-in job and cannot be triggered manually.`); }
851
- else { await thread.post(`⏳ Triggering ${id}...`); await this.cronScheduler.trigger(id); await thread.post(`✅ ${id} queued.`); }
852
- } else if (sub === "pause" && id) {
853
- if (isBuiltinJob(id)) { await thread.post(`⚠️ ${id} is a built-in job and cannot be paused.`); }
854
- else { await this.cronScheduler.pauseJob(id); await thread.post(`⏸️ ${id} paused.`); }
855
- } else if (sub === "resume" && id) {
856
- if (isBuiltinJob(id)) { await thread.post(`⚠️ ${id} is a built-in job and cannot be resumed.`); }
857
- else { await this.cronScheduler.resumeJob(id); await thread.post(`▶️ ${id} resumed.`); }
858
- } else {
859
- // Default: list jobs
860
- const items = await this.cronScheduler.listJobs();
861
- if (items.length === 0) {
862
- await thread.post("No cron jobs configured.\n\nCreate one with:\n`roundhouse cron add <id> --prompt \"...\" --every 6h`");
863
- } else {
864
- const lines = ["🕓 *Scheduled Jobs*", ""];
865
- for (const { job, state } of items) {
866
- const icon = jobEnabledIcon(job.enabled);
867
- const sched = formatSchedule(job.schedule);
868
- lines.push(`${icon} *${job.id}*`);
869
- lines.push(` 📅 ${sched}`);
870
- if (job.description) lines.push(` 📝 ${job.description}`);
871
- if (state.totalRuns > 0) {
872
- lines.push(` 📊 ${formatRunCounts(state)}`);
873
- if (state.lastFinishedAt) {
874
- const ago = Math.round((Date.now() - new Date(state.lastFinishedAt).getTime()) / 60000);
875
- const agoStr = ago < 60 ? `${ago}m ago` : `${Math.round(ago / 60)}h ago`;
876
- lines.push(` ⏱ Last run: ${agoStr}`);
877
- }
878
- } else {
879
- lines.push(` 📊 No runs yet`);
880
- }
881
- lines.push("");
882
- }
883
- lines.push(`_${items.length} job(s) configured_`);
884
- await this.postWithFallback(thread, lines.join("\n"));
885
- }
886
- }
887
- } catch (err) {
888
- try { await thread.post(`⚠️ Cron error: ${(err as Error).message}`); } catch {}
889
- } finally {
890
- stopTyping();
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Enrich audio attachments with speech-to-text transcripts.
474
+ * Updates agentMessage.text for voice-only messages.
475
+ */
476
+ private async enrichWithStt(thread: any, agentMessage: AgentMessage): Promise<void> {
477
+ if (!this.sttService || !agentMessage.attachments?.length) return;
478
+ try {
479
+ await enrichAttachmentsWithTranscripts(agentMessage.attachments, this.sttService, (text) => thread.post(text));
480
+ if (!agentMessage.text) {
481
+ const transcripts = agentMessage.attachments
482
+ .filter((a) => a.transcript?.status === "completed" && a.transcript.text)
483
+ .map((a) => a.transcript!.text);
484
+ if (transcripts.length > 0) {
485
+ 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.";
891
488
  }
892
- console.log(`[roundhouse] /crons for thread=${thread.id}`);
893
- return;
894
489
  }
895
- await handle(thread, message);
896
- };
897
-
898
- this.chat.onDirectMessage(async (thread, message) => {
899
- await thread.subscribe();
900
- await handleOrAbort(thread, message);
901
- });
902
-
903
- this.chat.onNewMention(async (thread, message) => {
904
- await thread.subscribe();
905
- await handleOrAbort(thread, message);
906
- });
907
-
908
- this.chat.onSubscribedMessage(async (thread, message) => {
909
- await handleOrAbort(thread, message);
910
- });
490
+ } catch (err) {
491
+ console.error(`[roundhouse] STT enrichment error:`, (err as Error).message);
492
+ }
493
+ }
911
494
 
912
- await this.chat.initialize();
495
+ /**
496
+ * Save attachments, notify skipped, and build the AgentMessage.
497
+ * Returns null if there's nothing to send (empty text + failed attachments).
498
+ */
499
+ private async prepareAgentMessage(
500
+ thread: any, agentThreadId: string, userText: string, rawAttachments: any[],
501
+ ): Promise<AgentMessage | null> {
502
+ let attachmentResult: AttachmentResult = { saved: [], skipped: [] };
503
+ try {
504
+ attachmentResult = await saveAttachments(agentThreadId, rawAttachments);
505
+ } catch (err) {
506
+ console.error(`[roundhouse] saveAttachments error:`, (err as Error).message);
507
+ if (!userText.trim()) {
508
+ try { await thread.post("⚠️ Failed to process attachment(s). Please try again."); } catch {}
509
+ return null;
510
+ }
511
+ }
913
512
 
914
- const platforms = Object.keys(this.config.chat.adapters).join(", ");
915
- console.log(`[roundhouse] gateway ready (platforms: ${platforms})`);
513
+ if (attachmentResult.skipped.length > 0) {
514
+ const skipMsg = attachmentResult.skipped.map((s) => `\u2022 ${s}`).join("\n");
515
+ try { await thread.post(`⚠️ Some attachments were skipped:\n${skipMsg}`); } catch {}
516
+ }
916
517
 
917
- // ── Register bot commands ───
918
- await this.registerBotCommands();
518
+ const promptText = userText.trim();
519
+ const agentMessage: AgentMessage = {
520
+ text: promptText,
521
+ attachments: attachmentResult.saved.length > 0 ? attachmentResult.saved : undefined,
522
+ };
919
523
 
920
- // Start cron scheduler (await so job counts are available for startup notification)
921
- this.cronScheduler = new CronSchedulerService({
922
- agentConfig: this.config.agent,
923
- notifyChatIds: this.config.chat.notifyChatIds,
924
- });
925
- try {
926
- await this.cronScheduler.start();
927
- } catch (err) {
928
- console.error("[roundhouse] cron scheduler start failed:", (err as Error).message);
524
+ if (!promptText && !agentMessage.attachments) {
525
+ if (rawAttachments.length > 0) {
526
+ try { await thread.post("⚠️ Failed to save attachment(s). Please try again."); } catch {}
527
+ }
528
+ return null;
929
529
  }
930
530
 
931
- // Send startup notification (after cron init so we can include job counts)
932
- await this.notifyStartup(platforms);
531
+ return agentMessage;
933
532
  }
934
533
 
935
534
  /**
@@ -980,185 +579,35 @@ export class Gateway {
980
579
  * - Tool starts/ends are sent as compact status messages.
981
580
  * - Turn boundaries trigger a new message for the next turn's text.
982
581
  */
983
- private async handleStreaming(thread: any, stream: AsyncIterable<AgentStreamEvent>, verbose: boolean, signal?: AbortSignal): Promise<{ usedTools: boolean }> {
984
- let activeTools = new Map<string, string>(); // toolCallId -> toolName
985
- let usedFileModifyingTools = false;
986
-
987
- // Per-turn streaming state — each turn gets a fresh iterable + promise
988
- let currentPush: ((text: string) => void) | null = null;
989
- let currentFinish: (() => void) | null = null;
990
- let currentPromise: Promise<void> | null = null;
991
-
992
- function createTextStream(): { iterable: AsyncIterable<string>; push: (text: string) => void; finish: () => void } {
993
- let buffer = "";
994
- let resolve: ((value: IteratorResult<string>) => void) | null = null;
995
- let done = false;
996
-
997
- const iterable: AsyncIterable<string> = {
998
- [Symbol.asyncIterator]() {
999
- return {
1000
- async next(): Promise<IteratorResult<string>> {
1001
- if (buffer) {
1002
- const chunk = buffer;
1003
- buffer = "";
1004
- return { value: chunk, done: false };
1005
- }
1006
- if (done) return { value: undefined as any, done: true };
1007
- return new Promise((r) => { resolve = r; });
1008
- },
1009
- };
1010
- },
1011
- };
1012
-
1013
- return {
1014
- iterable,
1015
- push(text: string) {
1016
- if (resolve) {
1017
- const r = resolve;
1018
- resolve = null;
1019
- r({ value: text, done: false });
1020
- } else {
1021
- buffer += text;
1022
- }
1023
- },
1024
- finish() {
1025
- done = true;
1026
- resolve?.({ value: undefined as any, done: true });
1027
- },
1028
- };
1029
- }
1030
582
 
1031
- const flushCurrentStream = async () => {
1032
- if (!currentPromise) return;
1033
- currentFinish?.();
1034
- try {
1035
- await currentPromise;
1036
- } catch (err) {
1037
- console.warn(`[roundhouse] stream flush error:`, (err as Error).message);
1038
- }
1039
- currentPush = null;
1040
- currentFinish = null;
1041
- currentPromise = null;
583
+ private buildCommandContext(
584
+ thread: any, message: any, agentThreadId: string, authorName: string,
585
+ allowedUsers: string[], allowedUserIds: number[],
586
+ verboseThreads: Set<string>, threadLocks: Map<string, Promise<void>>,
587
+ ): CommandContext {
588
+ return {
589
+ thread,
590
+ message,
591
+ agentThreadId,
592
+ authorName,
593
+ agent: this.router.resolve(agentThreadId),
594
+ config: this.config,
595
+ allowedUsers,
596
+ allowedUserIds,
597
+ verboseThreads,
598
+ threadLocks,
599
+ postWithFallback: (t, text) => this.postWithFallback(t, text),
600
+ stopGateway: () => this.stop(),
1042
601
  };
602
+ }
1043
603
 
1044
- const useTelegramHtml = isTelegramThread(thread);
1045
-
1046
- const ensureStream = () => {
1047
- if (!currentPromise) {
1048
- const ts = createTextStream();
1049
- currentPush = ts.push;
1050
- currentFinish = ts.finish;
1051
- currentPromise = useTelegramHtml
1052
- ? handleTelegramHtmlStream(thread, ts.iterable).catch((err: Error) => {
1053
- console.warn(`[roundhouse] telegram html stream error:`, err.message);
1054
- })
1055
- : thread.handleStream(ts.iterable).catch((err: Error) => {
1056
- console.warn(`[roundhouse] handleStream error:`, err.message);
1057
- });
1058
- }
1059
- };
1060
-
1061
- let hasTextInCurrentTurn = false;
1062
- let eventCount = 0;
1063
- let drainingNotified = false;
1064
-
1065
- for await (const event of stream) {
1066
- // Check if /stop was called
1067
- if (signal?.aborted) {
1068
- console.log(`[roundhouse] stream aborted for thread`);
1069
- break;
1070
- }
1071
- if (DEBUG_STREAM) {
1072
- eventCount++;
1073
- const preview = event.type === "text_delta" ? `"${event.text.slice(0, 30)}"`
1074
- : event.type === "custom_message" ? `${event.customType}:${event.content.slice(0, 30)}`
1075
- : event.type === "tool_start" || event.type === "tool_end" ? event.toolName
1076
- : "";
1077
- console.log(`[roundhouse/stream] #${eventCount} ${event.type} ${preview}`);
1078
- }
1079
- switch (event.type) {
1080
- case "text_delta": {
1081
- ensureStream();
1082
- currentPush!(event.text);
1083
- hasTextInCurrentTurn = true;
1084
- break;
1085
- }
1086
-
1087
- case "tool_start": {
1088
- activeTools.set(event.toolCallId, event.toolName);
1089
- if (!READ_ONLY_TOOLS.has(event.toolName)) usedFileModifyingTools = true;
1090
- if (verbose) {
1091
- try {
1092
- await thread.post(`${toolIcon(event.toolName)} Running \`${event.toolName}\`…`);
1093
- } catch {}
1094
- }
1095
- break;
1096
- }
1097
-
1098
- case "tool_end": {
1099
- activeTools.delete(event.toolCallId);
1100
- break;
1101
- }
1102
-
1103
- case "custom_message": {
1104
- // Extension messages (e.g. code review) — flush current stream and post as distinct message
1105
- if (currentPromise) {
1106
- await flushCurrentStream();
1107
- hasTextInCurrentTurn = false;
1108
- }
1109
- await this.postWithFallback(thread, event.content);
1110
- break;
1111
- }
1112
-
1113
- case "turn_end": {
1114
- if (hasTextInCurrentTurn) {
1115
- await flushCurrentStream();
1116
- hasTextInCurrentTurn = false;
1117
- }
1118
- break;
1119
- }
1120
-
1121
- case "draining": {
1122
- if (hasTextInCurrentTurn) {
1123
- await flushCurrentStream();
1124
- hasTextInCurrentTurn = false;
1125
- }
1126
- try {
1127
- await thread.post("⏳ Hold on — waiting for follow-up messages...");
1128
- drainingNotified = true;
1129
- } catch {}
1130
- break;
1131
- }
1132
-
1133
- case "drain_complete": {
1134
- if (hasTextInCurrentTurn) {
1135
- await flushCurrentStream();
1136
- hasTextInCurrentTurn = false;
1137
- }
1138
- if (drainingNotified) {
1139
- try {
1140
- await thread.post("✅ All done — waiting for your input.");
1141
- } catch {}
1142
- drainingNotified = false;
1143
- }
1144
- break;
1145
- }
1146
-
1147
- case "agent_end": {
1148
- if (hasTextInCurrentTurn) {
1149
- await flushCurrentStream();
1150
- }
1151
- break;
1152
- }
1153
- }
1154
- }
1155
-
1156
- // Safety: make sure we flush
1157
- if (currentPromise) {
1158
- await flushCurrentStream();
1159
- }
1160
-
1161
- return { usedTools: usedFileModifyingTools };
604
+ private async handleStreaming(thread: any, stream: AsyncIterable<AgentStreamEvent>, verbose: boolean, signal?: AbortSignal): Promise<{ usedTools: boolean }> {
605
+ return _handleStream(stream, {
606
+ thread,
607
+ verbose,
608
+ signal,
609
+ postWithFallback: (t, text) => this.postWithFallback(t, text),
610
+ });
1162
611
  }
1163
612
 
1164
613
  /** Post text with markdown, falling back to plain text */