@inceptionstack/roundhouse 0.5.1 → 0.5.3

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.
@@ -0,0 +1,371 @@
1
+ /**
2
+ * gateway/commands.ts — Telegram command handlers
3
+ *
4
+ * Each handler is a standalone async function that receives a CommandContext.
5
+ * Extracted from Gateway.start() to reduce method size and enable unit testing.
6
+ */
7
+
8
+ import type { AgentAdapter, AgentStreamEvent, GatewayConfig } from "../types";
9
+ import { ROUNDHOUSE_VERSION } from "../config";
10
+ import { startTypingLoop } from "../util";
11
+ import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact, determineMemoryMode } from "../memory/lifecycle";
12
+ import { createProgressMessage } from "../telegram-progress";
13
+ import { getSystemResources } from "./helpers";
14
+
15
+ // ── Types ────────────────────────────────────────────
16
+
17
+ export interface CommandContext {
18
+ thread: any;
19
+ message: any;
20
+ agentThreadId: string;
21
+ authorName: string;
22
+ agent: AgentAdapter;
23
+ config: GatewayConfig;
24
+ allowedUsers: string[];
25
+ allowedUserIds: number[];
26
+ verboseThreads: Set<string>;
27
+ threadLocks: Map<string, Promise<void>>;
28
+ postWithFallback: (thread: any, text: string) => Promise<void>;
29
+ stopGateway: () => Promise<void>;
30
+ }
31
+
32
+ // ── /new ─────────────────────────────────────────────
33
+
34
+ export async function handleNew(ctx: CommandContext): Promise<void> {
35
+ const { thread, agent, agentThreadId } = ctx;
36
+ if (agent.restart) {
37
+ await agent.restart(agentThreadId);
38
+ await thread.post(`🔄 Session restarted (\`${agentThreadId}\`). Send a message to begin a new conversation.`);
39
+ } else {
40
+ await thread.post("⚠️ New session not supported for this agent.");
41
+ }
42
+ console.log(`[roundhouse] /new for thread=${thread.id} agentThread=${agentThreadId}`);
43
+ }
44
+
45
+ // ── /restart ─────────────────────────────────────────
46
+
47
+ export async function handleRestart(ctx: CommandContext): Promise<void> {
48
+ const { thread, authorName, allowedUsers, allowedUserIds, stopGateway } = ctx;
49
+ if (allowedUsers.length === 0 && allowedUserIds.length === 0) {
50
+ await thread.post("⚠️ /restart requires an allowlist (allowedUsers or allowedUserIds) to be configured.");
51
+ return;
52
+ }
53
+ console.log(`[roundhouse] /restart requested by @${authorName} in thread=${thread.id}`);
54
+ await thread.post("🔄 Restarting gateway...");
55
+ setTimeout(async () => {
56
+ console.log("[roundhouse] shutting down for restart");
57
+ try { await stopGateway(); } catch (e) { console.error("[roundhouse] stop error:", e); }
58
+ process.exit(75);
59
+ }, 1000);
60
+ }
61
+
62
+ // ── /update ──────────────────────────────────────────
63
+
64
+ export async function handleUpdate(ctx: CommandContext): Promise<void> {
65
+ const { thread, authorName, allowedUsers, allowedUserIds, stopGateway } = ctx;
66
+ if (allowedUsers.length === 0 && allowedUserIds.length === 0) {
67
+ await thread.post("⚠️ /update requires an allowlist to be configured.");
68
+ return;
69
+ }
70
+ console.log(`[roundhouse] /update requested by @${authorName} in thread=${thread.id}`);
71
+ const progress = await createProgressMessage(thread, "📦 Checking for updates...");
72
+ try {
73
+ const { performUpdate } = await import("../commands/update");
74
+ const result = await performUpdate(progress);
75
+ if (result.action === "already-latest") {
76
+ await progress.update(`✅ Already on latest (v${result.currentVersion})`);
77
+ } else if (result.action === "updated") {
78
+ await progress.update(`✅ Updated v${result.currentVersion} → v${result.latestVersion}. Restarting...`);
79
+ console.log(`[roundhouse] updated ${result.currentVersion} -> ${result.latestVersion}, restarting`);
80
+ setTimeout(async () => {
81
+ try { await stopGateway(); } catch (e) { console.error("[roundhouse] stop error:", e); }
82
+ process.exit(75);
83
+ }, 1500);
84
+ }
85
+ } catch (err) {
86
+ const msg = err instanceof Error ? err.message : String(err);
87
+ await progress.update(`⚠️ Update failed: ${msg.slice(0, 200)}`);
88
+ console.error(`[roundhouse] /update failed:`, msg);
89
+ }
90
+ }
91
+
92
+ // ── /compact ─────────────────────────────────────────
93
+
94
+ export async function handleCompact(ctx: CommandContext): Promise<void> {
95
+ const { thread, agent, agentThreadId, config, threadLocks } = ctx;
96
+ if (!agent.compact) {
97
+ await thread.post("⚠️ Compaction not supported for this agent.");
98
+ return;
99
+ }
100
+ console.log(`[roundhouse] /compact for thread=${thread.id} agentThread=${agentThreadId}`);
101
+
102
+ // Acquire per-thread lock
103
+ const prevLock = threadLocks.get(agentThreadId);
104
+ let releaseLock: () => void;
105
+ const lockPromise = new Promise<void>((resolve) => { releaseLock = resolve; });
106
+ threadLocks.set(agentThreadId, lockPromise);
107
+ if (prevLock) await prevLock;
108
+
109
+ const progress = await createProgressMessage(thread, "📝 Saving memory and compacting...");
110
+ const stopTyping = startTypingLoop(thread);
111
+ try {
112
+ const agentCwd = (agent.getInfo?.()?.cwd as string) ?? process.cwd();
113
+ const memoryRoot = config.memory?.rootDir ?? agentCwd;
114
+
115
+ if (config.memory?.enabled === false) {
116
+ const result = await agent.compact(agentThreadId);
117
+ if (!result) {
118
+ await progress.update("⚠️ No active session to compact. Send a message first.");
119
+ } else {
120
+ const beforeK = (result.tokensBefore / 1000).toFixed(1);
121
+ await progress.update(`✅ Compaction complete\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`);
122
+ }
123
+ } else {
124
+ const result = await flushMemoryThenCompact(
125
+ agentThreadId, agent, memoryRoot, "manual", config.memory,
126
+ (step) => progress.update(step),
127
+ );
128
+ if (!result) {
129
+ await progress.update("⚠️ No active session to compact. Send a message first.");
130
+ } else {
131
+ const beforeK = (result.tokensBefore / 1000).toFixed(1);
132
+ const timing = result.timing;
133
+ 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}` : "";
134
+ await progress.update(`✅ Memory saved & compacted\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.${timingLine}`);
135
+ }
136
+ }
137
+ } catch (err) {
138
+ const msg = err instanceof Error ? err.message : String(err);
139
+ await progress.update(`⚠️ Compaction failed: ${msg.slice(0, 200)}`);
140
+ } finally {
141
+ stopTyping();
142
+ releaseLock!();
143
+ if (threadLocks.get(agentThreadId) === lockPromise) {
144
+ threadLocks.delete(agentThreadId);
145
+ }
146
+ }
147
+ }
148
+
149
+ // ── /status ──────────────────────────────────────────
150
+
151
+ export async function handleStatus(ctx: CommandContext): Promise<void> {
152
+ const { thread, agent, agentThreadId, config, allowedUsers, verboseThreads, postWithFallback } = ctx;
153
+
154
+ const uptimeSec = process.uptime();
155
+ const uptimeStr = uptimeSec < 3600
156
+ ? `${Math.floor(uptimeSec / 60)}m ${Math.floor(uptimeSec % 60)}s`
157
+ : `${Math.floor(uptimeSec / 3600)}h ${Math.floor((uptimeSec % 3600) / 60)}m`;
158
+ const platforms = Object.keys(config.chat.adapters).join(", ");
159
+ const debugStream = process.env.ROUNDHOUSE_DEBUG_STREAM === "1";
160
+ const nodeVer = process.version;
161
+ const memMB = (process.memoryUsage.rss() / 1024 / 1024).toFixed(1);
162
+
163
+ const info = agent.getInfo ? agent.getInfo(agentThreadId) : {};
164
+ const agentVersion = info.version ? `v${info.version}` : "";
165
+ const agentLabel = agentVersion ? `\`${agent.name}\` (${agentVersion})` : `\`${agent.name}\``;
166
+
167
+ const lines = [
168
+ `📊 *Roundhouse Status*`,
169
+ ``,
170
+ `🎫 Session: \`${agentThreadId}\``,
171
+ `📦 Roundhouse: v${ROUNDHOUSE_VERSION}`,
172
+ `🤖 Agent: ${agentLabel}`,
173
+ ];
174
+
175
+ if (info.model) lines.push(`🧠 Model: \`${info.model}\``);
176
+ if (info.activeSessions !== undefined) lines.push(`💬 Active sessions: ${info.activeSessions}`);
177
+
178
+ lines.push(
179
+ `🌐 Platforms: ${platforms}`,
180
+ `👤 Bot: @${config.chat.botUsername}`,
181
+ `⏱ Uptime: ${uptimeStr}`,
182
+ `💾 Memory: ${memMB} MB`,
183
+ `🟢 Node: ${nodeVer}`,
184
+ `🔧 Debug stream: ${debugStream ? "on" : "off"}`,
185
+ `📢 Verbose: ${verboseThreads.has(agentThreadId) ? "on" : "off"}`,
186
+ );
187
+
188
+ const allowedCount = allowedUsers.length;
189
+ lines.push(`🔐 Allowed users: ${allowedCount === 0 ? "all (no allowlist)" : allowedCount}`);
190
+
191
+ const sys = getSystemResources();
192
+ lines.push(``);
193
+ lines.push(`🖥 *System*`);
194
+ lines.push(` CPU: ${sys.cpuPct}% (load ${sys.load1.toFixed(2)}, ${sys.cpuCount} cores)`);
195
+ lines.push(` RAM: ${sys.usedGB}/${sys.totalGB} GB (${sys.memPct}%)`);
196
+ lines.push(` Process: ${memMB} MB RSS`);
197
+
198
+ const memMode = determineMemoryMode(info);
199
+ const memEnabled = config.memory?.enabled !== false;
200
+ const memLabel = !memEnabled ? "disabled"
201
+ : memMode === "complement" ? "agent-managed (pi-memory)"
202
+ : memMode === "full" ? "roundhouse-managed"
203
+ : "pending detection";
204
+ lines.push(``);
205
+ lines.push(`🧠 Memory: ${memLabel}`);
206
+
207
+ if (typeof info.contextTokens === "number" && typeof info.contextWindow === "number" && info.contextWindow > 0) {
208
+ const pct = Math.min(100, Math.round((info.contextTokens as number) / (info.contextWindow as number) * 100));
209
+ const barLen = 20;
210
+ const filled = Math.round(pct / 100 * barLen);
211
+ const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
212
+ const tokensK = ((info.contextTokens as number) / 1000).toFixed(1);
213
+ const windowK = ((info.contextWindow as number) / 1000).toFixed(0);
214
+ lines.push(``);
215
+ lines.push(`📝 Context: \`${bar}\` ${pct}%`);
216
+ lines.push(` ${tokensK}K / ${windowK}K tokens`);
217
+ } else if (typeof info.contextWindow === "number" && info.contextWindow > 0) {
218
+ const windowK = ((info.contextWindow as number) / 1000).toFixed(0);
219
+ lines.push(``);
220
+ lines.push(`📝 Context: no usage data yet (${windowK}K window)`);
221
+ }
222
+
223
+ const extensions = Array.isArray(info.extensions) ? info.extensions as string[] : [];
224
+ if (extensions.length > 0) {
225
+ lines.push(``);
226
+ lines.push(`🧩 Extensions (${extensions.length}):`);
227
+ for (const ext of extensions) {
228
+ const short = ext.replace(/^.*node_modules\//, "").replace(/\/index\.[tj]s$/, "");
229
+ lines.push(` • ${short}`);
230
+ }
231
+ }
232
+
233
+ await postWithFallback(thread, lines.join("\n"));
234
+ console.log(`[roundhouse] /status for thread=${thread.id} agentThread=${agentThreadId}`);
235
+ }
236
+
237
+ // ── /stop ────────────────────────────────────────────
238
+
239
+ export interface StopContext {
240
+ thread: any;
241
+ agentThreadId: string;
242
+ agent: AgentAdapter;
243
+ abortControllers: Map<string, AbortController>;
244
+ }
245
+
246
+ export async function handleStop(ctx: StopContext): Promise<void> {
247
+ const { thread, agentThreadId, agent, abortControllers } = ctx;
248
+ if (agent.abort) {
249
+ await agent.abort(agentThreadId);
250
+ abortControllers.get(agentThreadId)?.abort();
251
+ try { await thread.post("⏹️ Stopped."); } catch {}
252
+ } else {
253
+ try { await thread.post("⚠️ Abort not supported for this agent."); } catch {}
254
+ }
255
+ console.log(`[roundhouse] /stop for thread=${thread.id} agentThread=${agentThreadId}`);
256
+ }
257
+
258
+ // ── /verbose ─────────────────────────────────────────
259
+
260
+ export interface VerboseContext {
261
+ thread: any;
262
+ agentThreadId: string;
263
+ verboseThreads: Set<string>;
264
+ }
265
+
266
+ export async function handleVerbose(ctx: VerboseContext): Promise<void> {
267
+ const { thread, agentThreadId, verboseThreads } = ctx;
268
+ if (verboseThreads.has(agentThreadId)) {
269
+ verboseThreads.delete(agentThreadId);
270
+ try { await thread.post("🔇 Verbose mode OFF — tool status messages hidden."); } catch {}
271
+ } else {
272
+ verboseThreads.add(agentThreadId);
273
+ try { await thread.post("📢 Verbose mode ON — showing tool calls."); } catch {}
274
+ }
275
+ console.log(`[roundhouse] /verbose for thread=${thread.id} agentThread=${agentThreadId} -> ${verboseThreads.has(agentThreadId) ? "on" : "off"}`);
276
+ }
277
+
278
+ // ── /doctor ──────────────────────────────────────────
279
+
280
+ export interface DoctorContext {
281
+ thread: any;
282
+ runDoctor: (ctx: any) => Promise<any>;
283
+ createDoctorContext: () => Promise<any>;
284
+ formatDoctorTelegram: (results: any) => string;
285
+ postWithFallback: (thread: any, text: string) => Promise<void>;
286
+ }
287
+
288
+ export async function handleDoctor(ctx: DoctorContext): Promise<void> {
289
+ const { thread, runDoctor, createDoctorContext, formatDoctorTelegram, postWithFallback } = ctx;
290
+ const { startTypingLoop } = await import("../util");
291
+ const stopTyping = startTypingLoop(thread);
292
+ try {
293
+ const results = await runDoctor(await createDoctorContext());
294
+ const report = formatDoctorTelegram(results);
295
+ await postWithFallback(thread, report);
296
+ } catch (err) {
297
+ try { await thread.post(`⚠️ Doctor failed: ${(err as Error).message}`); } catch {}
298
+ } finally {
299
+ stopTyping();
300
+ }
301
+ console.log(`[roundhouse] /doctor for thread=${thread.id}`);
302
+ }
303
+
304
+ // ── /crons (/jobs) ───────────────────────────────────
305
+
306
+ export interface CronsContext {
307
+ thread: any;
308
+ text: string;
309
+ cronScheduler: any | null;
310
+ postWithFallback: (thread: any, text: string) => Promise<void>;
311
+ }
312
+
313
+ export async function handleCrons(ctx: CronsContext): Promise<void> {
314
+ const { thread, text, cronScheduler, postWithFallback } = ctx;
315
+ const { startTypingLoop } = await import("../util");
316
+ const { isBuiltinJob } = await import("../cron/helpers");
317
+ const { formatSchedule, formatRunCounts, jobEnabledIcon } = await import("../cron/format");
318
+
319
+ const stopTyping = startTypingLoop(thread);
320
+ try {
321
+ const parts = text.split(/\s+/).slice(1);
322
+ const sub = parts[0];
323
+ const id = parts[1];
324
+
325
+ if (!cronScheduler) {
326
+ await thread.post("⚠️ Cron scheduler not running.");
327
+ } else if (sub === "trigger" && id) {
328
+ if (isBuiltinJob(id)) { await thread.post(`⚠️ ${id} is a built-in job and cannot be triggered manually.`); }
329
+ else { await thread.post(`⏳ Triggering ${id}...`); await cronScheduler.trigger(id); await thread.post(`✅ ${id} queued.`); }
330
+ } else if (sub === "pause" && id) {
331
+ if (isBuiltinJob(id)) { await thread.post(`⚠️ ${id} is a built-in job and cannot be paused.`); }
332
+ else { await cronScheduler.pauseJob(id); await thread.post(`⏸️ ${id} paused.`); }
333
+ } else if (sub === "resume" && id) {
334
+ if (isBuiltinJob(id)) { await thread.post(`⚠️ ${id} is a built-in job and cannot be resumed.`); }
335
+ else { await cronScheduler.resumeJob(id); await thread.post(`▶️ ${id} resumed.`); }
336
+ } else {
337
+ // Default: list jobs
338
+ const items = await cronScheduler.listJobs();
339
+ if (items.length === 0) {
340
+ await thread.post("No cron jobs configured.\n\nCreate one with:\n`roundhouse cron add <id> --prompt \"...\" --every 6h`");
341
+ } else {
342
+ const lines = ["🕓 *Scheduled Jobs*", ""];
343
+ for (const { job, state } of items) {
344
+ const icon = jobEnabledIcon(job.enabled);
345
+ const sched = formatSchedule(job.schedule);
346
+ lines.push(`${icon} *${job.id}*`);
347
+ lines.push(` 📅 ${sched}`);
348
+ if (job.description) lines.push(` 📝 ${job.description}`);
349
+ if (state.totalRuns > 0) {
350
+ lines.push(` 📊 ${formatRunCounts(state)}`);
351
+ if (state.lastFinishedAt) {
352
+ const ago = Math.round((Date.now() - new Date(state.lastFinishedAt).getTime()) / 60000);
353
+ const agoStr = ago < 60 ? `${ago}m ago` : `${Math.round(ago / 60)}h ago`;
354
+ lines.push(` ⏱ Last run: ${agoStr}`);
355
+ }
356
+ } else {
357
+ lines.push(` 📊 No runs yet`);
358
+ }
359
+ lines.push("");
360
+ }
361
+ lines.push(`_${items.length} job(s) configured_`);
362
+ await postWithFallback(thread, lines.join("\n"));
363
+ }
364
+ }
365
+ } catch (err) {
366
+ try { await thread.post(`⚠️ Cron error: ${(err as Error).message}`); } catch {}
367
+ } finally {
368
+ stopTyping();
369
+ }
370
+ console.log(`[roundhouse] /crons for thread=${thread.id}`);
371
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * gateway/helpers.ts — Pure utility functions for the gateway
3
+ *
4
+ * Thread routing, command matching, system resources.
5
+ * No side effects, no I/O — easily unit-testable.
6
+ */
7
+
8
+ import { hostname, loadavg, totalmem, freemem, cpus } from "node:os";
9
+
10
+ // ── Command Matching ─────────────────────────────────
11
+
12
+ /**
13
+ * Match a Telegram command, handling optional @botname suffix.
14
+ */
15
+ export function isCommand(text: string, cmd: string, botUsername: string): boolean {
16
+ if (text === cmd) return true;
17
+ if (!text.startsWith(`${cmd}@`)) return false;
18
+ if (!botUsername) return false;
19
+ const suffix = text.slice(cmd.length + 1).toLowerCase();
20
+ return suffix === botUsername.toLowerCase();
21
+ }
22
+
23
+ /**
24
+ * Match a command that accepts subcommands (e.g. /crons trigger <id>).
25
+ */
26
+ export function isCommandWithArgs(text: string, cmd: string, botUsername: string): boolean {
27
+ if (text === cmd || text.startsWith(`${cmd} `)) return true;
28
+ if (!text.startsWith(`${cmd}@`)) return false;
29
+ if (!botUsername) return false;
30
+ const rest = text.slice(cmd.length + 1);
31
+ const spaceIdx = rest.indexOf(" ");
32
+ const suffix = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
33
+ return suffix.toLowerCase() === botUsername.toLowerCase();
34
+ }
35
+
36
+ // ── Thread Routing ───────────────────────────────────
37
+
38
+ function telegramChatIdFromThreadId(threadId: unknown): number | null {
39
+ if (typeof threadId !== "string") return null;
40
+ const match = threadId.match(/^telegram:(-?\d+)/);
41
+ if (!match) return null;
42
+ const parsed = parseInt(match[1], 10);
43
+ return Number.isNaN(parsed) ? null : parsed;
44
+ }
45
+
46
+ function getChatId(thread: any, message: any): string {
47
+ const id = message?.chat?.id ?? message?.chatId ?? thread?.chatId;
48
+ if (id !== undefined && id !== null) return String(id);
49
+ return String(thread?.id ?? "unknown");
50
+ }
51
+
52
+ /**
53
+ * Resolve the agent-facing thread ID from a chat message.
54
+ * Private/DM → "main", group → "group:<chatId>"
55
+ */
56
+ export function resolveAgentThreadId(thread: any, message: any): string {
57
+ const chatType = String(message?.chat?.type ?? thread?.chat?.type ?? thread?.type ?? "").toLowerCase();
58
+ if (["private", "dm", "direct", "im"].includes(chatType)) return "main";
59
+ if (["group", "supergroup", "channel"].includes(chatType)) return `group:${getChatId(thread, message)}`;
60
+
61
+ const telegramChatId = telegramChatIdFromThreadId(thread?.id);
62
+ if (telegramChatId !== null) {
63
+ return telegramChatId < 0 ? `group:${telegramChatId}` : "main";
64
+ }
65
+
66
+ return String(thread?.id ?? "main");
67
+ }
68
+
69
+ // ── System Resources ─────────────────────────────────
70
+
71
+ export interface SystemResources {
72
+ load1: number;
73
+ cpuCount: number;
74
+ totalGB: string;
75
+ usedGB: string;
76
+ memPct: number;
77
+ cpuPct: number;
78
+ }
79
+
80
+ export function getSystemResources(): SystemResources {
81
+ const load1 = loadavg()[0];
82
+ const cpuCount = cpus().length;
83
+ const totalGB = (totalmem() / 1024 / 1024 / 1024).toFixed(1);
84
+ const usedGB = ((totalmem() - freemem()) / 1024 / 1024 / 1024).toFixed(1);
85
+ const memPct = Math.round(((totalmem() - freemem()) / totalmem()) * 100);
86
+ const cpuPct = Math.min(100, Math.round((load1 / cpuCount) * 100));
87
+ return { load1, cpuCount, totalGB, usedGB, memPct, cpuPct };
88
+ }
89
+
90
+ // ── Tool Icons ───────────────────────────────────────
91
+
92
+ const TOOL_ICONS: Record<string, string> = {
93
+ bash: "⚡",
94
+ read: "📖",
95
+ edit: "✏️",
96
+ write: "📝",
97
+ grep: "🔍",
98
+ find: "🔎",
99
+ ls: "📂",
100
+ };
101
+
102
+ export function toolIcon(name: string): string {
103
+ return TOOL_ICONS[name] ?? "🔧";
104
+ }
@@ -0,0 +1,11 @@
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).
7
+ */
8
+
9
+ export { isCommand, isCommandWithArgs, resolveAgentThreadId, getSystemResources, toolIcon } from "./helpers";
10
+ export { saveAttachments } from "./attachments";
11
+ export { handleStreaming } from "./streaming";
@@ -0,0 +1,211 @@
1
+ /**
2
+ * gateway/streaming.ts — Agent stream event handler
3
+ *
4
+ * Processes the async stream of agent events and routes them:
5
+ * - text_delta → collected per-turn, sent via thread.handleStream()
6
+ * - tool_start/end → compact status messages (verbose mode)
7
+ * - turn_end → flush current stream, start fresh
8
+ * - custom_message → flush and post as separate message
9
+ */
10
+
11
+ import type { AgentStreamEvent } from "../types";
12
+ import { READ_ONLY_TOOLS } from "../memory/types";
13
+ import { isTelegramThread, handleTelegramHtmlStream } from "../telegram-html";
14
+ import { DEBUG_STREAM } from "../util";
15
+ import { toolIcon } from "./helpers";
16
+
17
+ // ── Text Stream Factory ──────────────────────────────
18
+
19
+ export function createTextStream(): {
20
+ iterable: AsyncIterable<string>;
21
+ push: (text: string) => void;
22
+ finish: () => void;
23
+ } {
24
+ let buffer = "";
25
+ let resolve: ((value: IteratorResult<string>) => void) | null = null;
26
+ let done = false;
27
+
28
+ const iterable: AsyncIterable<string> = {
29
+ [Symbol.asyncIterator]() {
30
+ return {
31
+ async next(): Promise<IteratorResult<string>> {
32
+ if (buffer) {
33
+ const chunk = buffer;
34
+ buffer = "";
35
+ return { value: chunk, done: false };
36
+ }
37
+ if (done) return { value: undefined as any, done: true };
38
+ return new Promise((r) => { resolve = r; });
39
+ },
40
+ };
41
+ },
42
+ };
43
+
44
+ return {
45
+ iterable,
46
+ push(text: string) {
47
+ if (resolve) {
48
+ const r = resolve;
49
+ resolve = null;
50
+ r({ value: text, done: false });
51
+ } else {
52
+ buffer += text;
53
+ }
54
+ },
55
+ finish() {
56
+ done = true;
57
+ resolve?.({ value: undefined as any, done: true });
58
+ },
59
+ };
60
+ }
61
+
62
+ // ── Stream Handler ───────────────────────────────────
63
+
64
+ export interface StreamContext {
65
+ thread: any;
66
+ verbose: boolean;
67
+ signal?: AbortSignal;
68
+ postWithFallback: (thread: any, text: string) => Promise<void>;
69
+ }
70
+
71
+ export interface StreamResult {
72
+ usedTools: boolean;
73
+ }
74
+
75
+ /**
76
+ * Handle the agent's event stream, routing events to the chat thread.
77
+ */
78
+ export async function handleStreaming(
79
+ stream: AsyncIterable<AgentStreamEvent>,
80
+ ctx: StreamContext,
81
+ ): Promise<StreamResult> {
82
+ const { thread, verbose, signal, postWithFallback } = ctx;
83
+ let activeTools = new Map<string, string>();
84
+ let usedFileModifyingTools = false;
85
+
86
+ let currentPush: ((text: string) => void) | null = null;
87
+ let currentFinish: (() => void) | null = null;
88
+ let currentPromise: Promise<void> | null = null;
89
+
90
+ const flushCurrentStream = async () => {
91
+ if (!currentPromise) return;
92
+ currentFinish?.();
93
+ try { await currentPromise; } catch (err) {
94
+ console.warn(`[roundhouse] stream flush error:`, (err as Error).message);
95
+ }
96
+ currentPush = null;
97
+ currentFinish = null;
98
+ currentPromise = null;
99
+ };
100
+
101
+ const useTelegramHtml = isTelegramThread(thread);
102
+
103
+ const ensureStream = () => {
104
+ if (!currentPromise) {
105
+ const ts = createTextStream();
106
+ currentPush = ts.push;
107
+ currentFinish = ts.finish;
108
+ currentPromise = useTelegramHtml
109
+ ? handleTelegramHtmlStream(thread, ts.iterable).catch((err: Error) => {
110
+ console.warn(`[roundhouse] telegram html stream error:`, err.message);
111
+ })
112
+ : thread.handleStream(ts.iterable).catch((err: Error) => {
113
+ console.warn(`[roundhouse] handleStream error:`, err.message);
114
+ });
115
+ }
116
+ };
117
+
118
+ let hasTextInCurrentTurn = false;
119
+ let eventCount = 0;
120
+ let drainingNotified = false;
121
+
122
+ for await (const event of stream) {
123
+ if (signal?.aborted) {
124
+ console.log(`[roundhouse] stream aborted for thread`);
125
+ break;
126
+ }
127
+
128
+ if (DEBUG_STREAM) {
129
+ eventCount++;
130
+ const preview = event.type === "text_delta" ? `"${event.text.slice(0, 30)}"`
131
+ : event.type === "custom_message" ? `${event.customType}:${event.content.slice(0, 30)}`
132
+ : event.type === "tool_start" || event.type === "tool_end" ? event.toolName
133
+ : "";
134
+ console.log(`[roundhouse/stream] #${eventCount} ${event.type} ${preview}`);
135
+ }
136
+
137
+ switch (event.type) {
138
+ case "text_delta": {
139
+ ensureStream();
140
+ currentPush!(event.text);
141
+ hasTextInCurrentTurn = true;
142
+ break;
143
+ }
144
+
145
+ case "tool_start": {
146
+ activeTools.set(event.toolCallId, event.toolName);
147
+ if (!READ_ONLY_TOOLS.has(event.toolName)) usedFileModifyingTools = true;
148
+ if (verbose) {
149
+ try { await thread.post(`${toolIcon(event.toolName)} Running \`${event.toolName}\`…`); } catch {}
150
+ }
151
+ break;
152
+ }
153
+
154
+ case "tool_end": {
155
+ activeTools.delete(event.toolCallId);
156
+ break;
157
+ }
158
+
159
+ case "custom_message": {
160
+ if (currentPromise) {
161
+ await flushCurrentStream();
162
+ hasTextInCurrentTurn = false;
163
+ }
164
+ await postWithFallback(thread, event.content);
165
+ break;
166
+ }
167
+
168
+ case "turn_end": {
169
+ if (hasTextInCurrentTurn) {
170
+ await flushCurrentStream();
171
+ hasTextInCurrentTurn = false;
172
+ }
173
+ break;
174
+ }
175
+
176
+ case "draining": {
177
+ if (hasTextInCurrentTurn) {
178
+ await flushCurrentStream();
179
+ hasTextInCurrentTurn = false;
180
+ }
181
+ try { await thread.post("⏳ Hold on — waiting for follow-up messages..."); drainingNotified = true; } catch {}
182
+ break;
183
+ }
184
+
185
+ case "drain_complete": {
186
+ if (hasTextInCurrentTurn) {
187
+ await flushCurrentStream();
188
+ hasTextInCurrentTurn = false;
189
+ }
190
+ if (drainingNotified) {
191
+ try { await thread.post("✅ All done — waiting for your input."); } catch {}
192
+ drainingNotified = false;
193
+ }
194
+ break;
195
+ }
196
+
197
+ case "agent_end": {
198
+ if (hasTextInCurrentTurn) {
199
+ await flushCurrentStream();
200
+ }
201
+ break;
202
+ }
203
+ }
204
+ }
205
+
206
+ if (currentPromise) {
207
+ await flushCurrentStream();
208
+ }
209
+
210
+ return { usedTools: usedFileModifyingTools };
211
+ }