@inceptionstack/roundhouse 0.2.2 → 0.3.0

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 (49) hide show
  1. package/README.md +321 -9
  2. package/architecture.md +77 -8
  3. package/package.json +3 -1
  4. package/src/agents/pi.ts +433 -26
  5. package/src/agents/registry.ts +8 -0
  6. package/src/cli/cli.ts +384 -189
  7. package/src/cli/cron.ts +296 -0
  8. package/src/cli/doctor/checks/agent.ts +68 -0
  9. package/src/cli/doctor/checks/config.ts +88 -0
  10. package/src/cli/doctor/checks/credentials.ts +62 -0
  11. package/src/cli/doctor/checks/disk.ts +69 -0
  12. package/src/cli/doctor/checks/stt.ts +76 -0
  13. package/src/cli/doctor/checks/system.ts +86 -0
  14. package/src/cli/doctor/checks/systemd.ts +76 -0
  15. package/src/cli/doctor/output.ts +58 -0
  16. package/src/cli/doctor/runner.ts +142 -0
  17. package/src/cli/doctor/shell.ts +33 -0
  18. package/src/cli/doctor/types.ts +44 -0
  19. package/src/cli/doctor.ts +48 -0
  20. package/src/cli/setup-telegram.ts +148 -0
  21. package/src/cli/setup.ts +936 -0
  22. package/src/commands.ts +23 -0
  23. package/src/config.ts +188 -0
  24. package/src/cron/constants.ts +54 -0
  25. package/src/cron/durations.ts +33 -0
  26. package/src/cron/format.ts +139 -0
  27. package/src/cron/helpers.ts +30 -0
  28. package/src/cron/runner.ts +148 -0
  29. package/src/cron/schedule.ts +101 -0
  30. package/src/cron/scheduler.ts +295 -0
  31. package/src/cron/store.ts +125 -0
  32. package/src/cron/template.ts +89 -0
  33. package/src/cron/types.ts +76 -0
  34. package/src/gateway.ts +927 -18
  35. package/src/index.ts +1 -58
  36. package/src/memory/bootstrap.ts +98 -0
  37. package/src/memory/files.ts +100 -0
  38. package/src/memory/inject.ts +41 -0
  39. package/src/memory/lifecycle.ts +245 -0
  40. package/src/memory/policy.ts +122 -0
  41. package/src/memory/prompts.ts +42 -0
  42. package/src/memory/state.ts +43 -0
  43. package/src/memory/types.ts +90 -0
  44. package/src/notify/telegram.ts +48 -0
  45. package/src/types.ts +68 -1
  46. package/src/util.ts +28 -2
  47. package/src/voice/providers/whisper.ts +339 -0
  48. package/src/voice/stt-service.ts +284 -0
  49. package/src/voice/types.ts +63 -0
package/src/gateway.ts CHANGED
@@ -7,8 +7,52 @@
7
7
 
8
8
  import { Chat } from "chat";
9
9
  import { createMemoryState } from "@chat-adapter/state-memory";
10
- import type { AgentRouter, GatewayConfig } from "./types";
11
- import { splitMessage, isAllowed, startTypingLoop } from "./util";
10
+ import type { AgentAdapter, AgentMessage, AgentRouter, AgentStreamEvent, GatewayConfig, MessageAttachment } from "./types";
11
+ import { splitMessage, isAllowed, startTypingLoop, threadIdToDir, generateAttachmentId, DEBUG_STREAM } from "./util";
12
+ import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from "./voice/stt-service";
13
+ import { sendTelegramToMany } from "./notify/telegram";
14
+ import { runDoctor, formatDoctorTelegram, createDoctorContext } from "./cli/doctor/runner";
15
+ import { ROUNDHOUSE_DIR } from "./config";
16
+ import { CronSchedulerService } from "./cron/scheduler";
17
+ import { isBuiltinJob } from "./cron/helpers";
18
+ import { formatSchedule, formatRunCounts, jobEnabledIcon } from "./cron/format";
19
+ import { BOT_COMMANDS } from "./commands";
20
+ import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact, determineMemoryMode } from "./memory/lifecycle";
21
+ import { maxPressure } from "./memory/policy";
22
+ import type { PressureLevel } from "./memory/types";
23
+
24
+ /** Match a Telegram command, handling optional @botname suffix */
25
+ function isCommand(text: string, cmd: string): boolean {
26
+ return text === cmd || text.startsWith(`${cmd}@`);
27
+ }
28
+
29
+ /** Match a command that accepts subcommands (e.g. /crons trigger <id>) */
30
+ function isCommandWithArgs(text: string, cmd: string): boolean {
31
+ return text === cmd || text.startsWith(`${cmd}@`) || text.startsWith(`${cmd} `);
32
+ }
33
+ import { hostname, loadavg, totalmem, freemem, cpus } from "node:os";
34
+ import { homedir } from "node:os";
35
+
36
+ /** Get system resource info */
37
+ function getSystemResources() {
38
+ const load1 = loadavg()[0];
39
+ const cpuCount = cpus().length;
40
+ const totalGB = (totalmem() / 1024 / 1024 / 1024).toFixed(1);
41
+ const usedGB = ((totalmem() - freemem()) / 1024 / 1024 / 1024).toFixed(1);
42
+ const memPct = Math.round(((totalmem() - freemem()) / totalmem()) * 100);
43
+ const cpuPct = Math.min(100, Math.round((load1 / cpuCount) * 100));
44
+ return { load1, cpuCount, totalGB, usedGB, memPct, cpuPct };
45
+ }
46
+ import { readFileSync, mkdirSync } from "node:fs";
47
+ import { writeFile } from "node:fs/promises";
48
+ import { join, dirname, basename } from "node:path";
49
+ import { fileURLToPath } from "node:url";
50
+
51
+ const __gatewayDir = dirname(fileURLToPath(import.meta.url));
52
+ const ROUNDHOUSE_VERSION: string = (() => {
53
+ try { return JSON.parse(readFileSync(join(__gatewayDir, "..", "package.json"), "utf8")).version; }
54
+ catch { return "unknown"; }
55
+ })();
12
56
 
13
57
  // ── Chat SDK adapter factories ───────────────────────
14
58
  // Lazy-imported so we don't crash if an adapter package isn't installed.
@@ -25,19 +69,153 @@ async function buildChatAdapters(
25
69
  });
26
70
  }
27
71
 
28
- // Future:
29
- // if (config.slack) { ... }
30
- // if (config.discord) { ... }
31
-
32
72
  return adapters;
33
73
  }
34
74
 
75
+ // ── Tool name formatting ─────────────────────────────
76
+
77
+ const TOOL_ICONS: Record<string, string> = {
78
+ bash: "⚡",
79
+ read: "📖",
80
+ edit: "✏️",
81
+ write: "📝",
82
+ grep: "🔍",
83
+ find: "🔎",
84
+ ls: "📂",
85
+ };
86
+
87
+ function toolIcon(name: string): string {
88
+ return TOOL_ICONS[name] ?? "🔧";
89
+ }
90
+
91
+ // ── Incoming file storage ─────────────────────────────
92
+
93
+ const INCOMING_DIR = process.env.ROUNDHOUSE_INCOMING_DIR
94
+ ?? join(ROUNDHOUSE_DIR, "incoming");
95
+
96
+ const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB per file
97
+ const MAX_ATTACHMENTS = 5;
98
+
99
+ const MIME_EXTENSIONS: Record<string, string> = {
100
+ "audio/ogg": ".ogg",
101
+ "audio/mpeg": ".mp3",
102
+ "audio/mp4": ".m4a",
103
+ "audio/wav": ".wav",
104
+ "audio/webm": ".webm",
105
+ "image/jpeg": ".jpg",
106
+ "image/png": ".png",
107
+ "image/webp": ".webp",
108
+ "image/gif": ".gif",
109
+ "video/mp4": ".mp4",
110
+ "application/pdf": ".pdf",
111
+ };
112
+
113
+ /** Sanitize a filename to safe ASCII characters, capped length */
114
+ function safeName(raw: string): string {
115
+ let name = basename(raw);
116
+ // Replace anything not alphanumeric, dot, dash, underscore with _
117
+ name = name.replace(/[^a-zA-Z0-9._-]/g, "_");
118
+ // Cap length (truncate from start to preserve extension)
119
+ if (name.length > 100) name = name.slice(-100);
120
+ // Remove leading dashes/dots/underscores (prevent hidden files or option-like names)
121
+ // Applied AFTER truncation so slice(-100) can't reintroduce them
122
+ name = name.replace(/^[-_.]+/, "");
123
+ return name || "attachment";
124
+ }
125
+
126
+ /** Result of saving attachments: saved files + user-facing warnings */
127
+ interface AttachmentResult {
128
+ saved: MessageAttachment[];
129
+ skipped: string[]; // user-facing reasons for skipped attachments
130
+ }
131
+
132
+ async function saveAttachments(threadId: string, attachments: any[]): Promise<AttachmentResult> {
133
+ if (!attachments?.length) return { saved: [], skipped: [] };
134
+
135
+ const skipped: string[] = [];
136
+ const toProcess = attachments.slice(0, MAX_ATTACHMENTS);
137
+ if (attachments.length > MAX_ATTACHMENTS) {
138
+ skipped.push(`${attachments.length - MAX_ATTACHMENTS} attachment(s) skipped (max ${MAX_ATTACHMENTS} per message)`);
139
+ console.warn(`[roundhouse] too many attachments (${attachments.length}), processing first ${MAX_ATTACHMENTS}`);
140
+ }
141
+
142
+ // Per-message directory: <thread>/<timestamp_nonce>/
143
+ const msgDir = join(INCOMING_DIR, threadIdToDir(threadId), `${Date.now()}_${generateAttachmentId()}`);
144
+ try {
145
+ mkdirSync(msgDir, { recursive: true });
146
+ } catch (err) {
147
+ console.error(`[roundhouse] failed to create incoming dir ${msgDir}:`, (err as Error).message);
148
+ return { saved: [], skipped: ["Failed to create storage directory"] };
149
+ }
150
+
151
+ const saved: MessageAttachment[] = [];
152
+ for (let i = 0; i < toProcess.length; i++) {
153
+ const att = toProcess[i];
154
+ try {
155
+ // Check size hint before downloading if available
156
+ if (att.size && att.size > MAX_FILE_SIZE) {
157
+ const sizeMB = (att.size / 1024 / 1024).toFixed(1);
158
+ skipped.push(`${att.name ?? att.type} (${sizeMB} MB) exceeds ${MAX_FILE_SIZE / 1024 / 1024} MB limit`);
159
+ console.warn(`[roundhouse] attachment too large (${att.size} bytes), skipping: ${att.name ?? att.type}`);
160
+ continue;
161
+ }
162
+
163
+ const data = att.data ?? (att.fetchData ? await att.fetchData() : null);
164
+ if (!data) {
165
+ console.warn(`[roundhouse] attachment has no data: ${att.name ?? att.type}`);
166
+ continue;
167
+ }
168
+
169
+ const buf = Buffer.isBuffer(data) ? data
170
+ : ArrayBuffer.isView(data) ? Buffer.from(data.buffer, data.byteOffset, data.byteLength)
171
+ : data instanceof ArrayBuffer ? Buffer.from(data)
172
+ : Buffer.from(await (data as Blob).arrayBuffer());
173
+
174
+ if (buf.length > MAX_FILE_SIZE) {
175
+ const sizeMB = (buf.length / 1024 / 1024).toFixed(1);
176
+ skipped.push(`${att.name ?? att.type} (${sizeMB} MB) exceeds size limit`);
177
+ console.warn(`[roundhouse] attachment too large after download (${buf.length} bytes), skipping`);
178
+ continue;
179
+ }
180
+
181
+ const mime = att.mimeType ?? "application/octet-stream";
182
+ const ext = att.name
183
+ ? (att.name.includes(".") ? "" : (MIME_EXTENSIONS[mime] ?? ""))
184
+ : (MIME_EXTENSIONS[mime] ?? ".bin");
185
+ const rawName = att.name ? safeName(att.name) + ext : `${att.type ?? "file"}${ext}`;
186
+ const fileName = `${i}-${rawName}`;
187
+ const filePath = join(msgDir, fileName);
188
+
189
+ await writeFile(filePath, buf);
190
+
191
+ const VALID_MEDIA_TYPES = new Set(["audio", "image", "file", "video"]);
192
+ const mediaType = VALID_MEDIA_TYPES.has(att.type) ? att.type : "file";
193
+ const id = generateAttachmentId();
194
+ saved.push({
195
+ id,
196
+ mediaType,
197
+ name: rawName,
198
+ localPath: filePath,
199
+ mime,
200
+ sizeBytes: buf.length,
201
+ untrusted: true,
202
+ });
203
+ console.log(`[roundhouse] saved ${att.type} [${id}]: ${filePath} (${buf.length} bytes)`);
204
+ } catch (err) {
205
+ console.error(`[roundhouse] failed to save attachment:`, (err as Error).message);
206
+ }
207
+ }
208
+ return { saved, skipped };
209
+ }
210
+
35
211
  // ── Gateway ──────────────────────────────────────────
36
212
 
37
213
  export class Gateway {
38
214
  private chat!: Chat;
39
215
  private router: AgentRouter;
40
216
  private config: GatewayConfig;
217
+ private sttService: SttService | null = null;
218
+ private cronScheduler: CronSchedulerService | null = null;
41
219
 
42
220
  constructor(router: AgentRouter, config: GatewayConfig) {
43
221
  this.router = router;
@@ -47,6 +225,27 @@ export class Gateway {
47
225
  async start() {
48
226
  const chatAdapters = await buildChatAdapters(this.config.chat.adapters);
49
227
 
228
+ // Initialize STT service (enabled by default, can be disabled via config)
229
+ const rawSttConfig = this.config.voice?.stt;
230
+ // Deep merge with defaults to handle partial configs
231
+ const defaultProviders = DEFAULT_STT_CONFIG.providers;
232
+ const mergedProviders: Record<string, any> = {};
233
+ for (const key of new Set([...Object.keys(defaultProviders), ...Object.keys(rawSttConfig?.providers ?? {})])) {
234
+ mergedProviders[key] = { ...defaultProviders[key], ...(rawSttConfig?.providers ?? {})[key] };
235
+ }
236
+ const sttConfig = {
237
+ ...DEFAULT_STT_CONFIG,
238
+ ...rawSttConfig,
239
+ autoTranscribe: { ...DEFAULT_STT_CONFIG.autoTranscribe, ...rawSttConfig?.autoTranscribe },
240
+ providers: mergedProviders,
241
+ };
242
+ if (sttConfig.enabled && sttConfig.mode !== "off") {
243
+ this.sttService = new SttService(sttConfig);
244
+ console.log(`[roundhouse] STT enabled (chain: ${sttConfig.chain.join(" -> ")}, autoInstall: ${sttConfig.autoInstall ?? false})`);
245
+ // Prepare providers in background (install + warm model if needed)
246
+ void this.sttService.prepareInBackground();
247
+ }
248
+
50
249
  if (Object.keys(chatAdapters).length === 0) {
51
250
  throw new Error("No chat adapters configured. Add at least one in config.chat.adapters.");
52
251
  }
@@ -55,74 +254,784 @@ export class Gateway {
55
254
  userName: this.config.chat.botUsername,
56
255
  adapters: chatAdapters as any,
57
256
  state: createMemoryState(),
257
+ concurrency: "concurrent",
58
258
  });
59
259
 
60
260
  const allowedUsers = (this.config.chat.allowedUsers ?? []).map((u) =>
61
261
  u.toLowerCase()
62
262
  );
263
+ const allowedUserIds = this.config.chat.allowedUserIds ?? [];
264
+
265
+ // Per-thread verbose toggle (shows tool_start messages)
266
+ const verboseThreads = new Set<string>();
267
+
268
+ // Per-thread abort signal for /stop
269
+ const abortControllers = new Map<string, AbortController>();
270
+
271
+ // Per-thread lock to serialize prompts (concurrent mode lets /stop through)
272
+ const threadLocks = new Map<string, Promise<void>>();
63
273
 
64
274
  // ── Unified handler ────────────────────────────
65
275
  const handle = async (thread: any, message: any) => {
66
276
  const userText = message.text ?? "";
67
277
  const authorName = message.author?.userName ?? message.author?.userId ?? "?";
278
+ const rawAttachments = message.attachments ?? [];
68
279
 
69
280
  console.log(
70
- `[roundhouse] ${thread.id} @${authorName}: "${userText.slice(0, 120)}"`
281
+ `[roundhouse] ${thread.id} @${authorName}: "${userText.slice(0, 120)}"${rawAttachments.length ? ` +${rawAttachments.length} attachment(s)` : ""}`
71
282
  );
72
283
 
73
- if (!isAllowed(message, allowedUsers)) {
284
+ if (!isAllowed(message, allowedUsers, allowedUserIds)) {
74
285
  console.log(`[roundhouse] blocked @${authorName} (not in allowlist)`);
75
286
  return;
76
287
  }
77
288
 
78
- if (!userText.trim() || userText === "/start") return;
289
+ if (isCommand(userText, "/start")) return;
290
+ if (!userText.trim() && !rawAttachments.length) return;
291
+
292
+ // Handle /new command — dispose current session, start fresh
293
+ if (isCommand(userText.trim(), "/new")) {
294
+ const agent = this.router.resolve(thread.id);
295
+ if (agent.restart) {
296
+ await agent.restart(thread.id);
297
+ await thread.post("🔄 Session restarted. Send a message to begin a new conversation.");
298
+ } else {
299
+ await thread.post("⚠️ New session not supported for this agent.");
300
+ }
301
+ console.log(`[roundhouse] /new for thread=${thread.id}`);
302
+ return;
303
+ }
304
+
305
+ // Handle /restart command — restart the gateway process
306
+ // Only available when an allowlist is configured (all allowed users can restart)
307
+ if (isCommand(userText.trim(), "/restart")) {
308
+ if (allowedUsers.length === 0) {
309
+ await thread.post("⚠️ /restart requires an allowedUsers list to be configured.");
310
+ return;
311
+ }
312
+ console.log(`[roundhouse] /restart requested by @${authorName} in thread=${thread.id}`);
313
+ await thread.post("🔄 Restarting gateway...");
314
+ // Graceful shutdown then exit with non-zero so systemd Restart=on-failure brings us back
315
+ setTimeout(async () => {
316
+ console.log("[roundhouse] shutting down for restart");
317
+ try { await this.stop(); } catch (e) { console.error("[roundhouse] stop error:", e); }
318
+ process.exit(75);
319
+ }, 1000);
320
+ return;
321
+ }
322
+
323
+ // Handle /compact command — flush memory then compact session context
324
+ if (isCommand(userText.trim(), "/compact")) {
325
+ const agent = this.router.resolve(thread.id);
326
+ if (!agent.compact) {
327
+ await thread.post("⚠️ Compaction not supported for this agent.");
328
+ return;
329
+ }
330
+ console.log(`[roundhouse] /compact for thread=${thread.id}`);
331
+ await thread.post("📝 Saving memory and compacting...");
332
+ const stopTyping = startTypingLoop(thread);
333
+ try {
334
+ const agentCwd = (agent.getInfo?.()?.cwd as string) ?? process.cwd();
335
+ const memoryRoot = this.config.memory?.rootDir ?? agentCwd;
336
+ // If memory is disabled, compact directly without flush
337
+ if (this.config.memory?.enabled === false) {
338
+ const result = await agent.compact(thread.id);
339
+ if (!result) {
340
+ await thread.post("⚠️ No active session to compact. Send a message first.");
341
+ } else {
342
+ const beforeK = (result.tokensBefore / 1000).toFixed(1);
343
+ await thread.post(`✅ Compaction complete\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`);
344
+ }
345
+ } else {
346
+ const result = await flushMemoryThenCompact(thread.id, agent, memoryRoot, "manual", this.config.memory);
347
+ if (!result) {
348
+ await thread.post("⚠️ No active session to compact. Send a message first.");
349
+ } else {
350
+ const beforeK = (result.tokensBefore / 1000).toFixed(1);
351
+ await thread.post(`✅ Memory saved & compacted\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`);
352
+ }
353
+ }
354
+ } catch (err) {
355
+ const msg = err instanceof Error ? err.message : String(err);
356
+ await thread.post(`⚠️ Compaction failed: ${msg.slice(0, 200)}`);
357
+ } finally {
358
+ stopTyping();
359
+ }
360
+ return;
361
+ }
362
+
363
+ // Handle /status command — show gateway details
364
+ if (isCommand(userText.trim(), "/status")) {
365
+ const agent = this.router.resolve(thread.id);
366
+ const uptimeSec = process.uptime();
367
+ const uptimeStr = uptimeSec < 3600
368
+ ? `${Math.floor(uptimeSec / 60)}m ${Math.floor(uptimeSec % 60)}s`
369
+ : `${Math.floor(uptimeSec / 3600)}h ${Math.floor((uptimeSec % 3600) / 60)}m`;
370
+ const platforms = Object.keys(this.config.chat.adapters).join(", ");
371
+ const debugStream = process.env.ROUNDHOUSE_DEBUG_STREAM === "1";
372
+ const nodeVer = process.version;
373
+ const memMB = (process.memoryUsage.rss() / 1024 / 1024).toFixed(1);
374
+
375
+ const info = agent.getInfo ? agent.getInfo(thread.id) : {};
376
+ const agentVersion = info.version ? `v${info.version}` : "";
377
+ const agentLabel = agentVersion ? `\`${agent.name}\` (${agentVersion})` : `\`${agent.name}\``;
378
+
379
+ const lines = [
380
+ `📊 *Roundhouse Status*`,
381
+ ``,
382
+ `📦 Roundhouse: v${ROUNDHOUSE_VERSION}`,
383
+ `🤖 Agent: ${agentLabel}`,
384
+ ];
385
+
386
+ if (info.model) lines.push(`🧠 Model: \`${info.model}\``);
387
+ if (info.activeSessions !== undefined) lines.push(`💬 Active sessions: ${info.activeSessions}`);
388
+
389
+ lines.push(
390
+ `🌐 Platforms: ${platforms}`,
391
+ `👤 Bot: @${this.config.chat.botUsername}`,
392
+ `⏱ Uptime: ${uptimeStr}`,
393
+ `💾 Memory: ${memMB} MB`,
394
+ `🟢 Node: ${nodeVer}`,
395
+ `🔧 Debug stream: ${debugStream ? "on" : "off"}`,
396
+ `📢 Verbose: ${verboseThreads.has(thread.id) ? "on" : "off"}`,
397
+ );
398
+
399
+ const allowedCount = allowedUsers.length;
400
+ lines.push(`🔐 Allowed users: ${allowedCount === 0 ? "all (no allowlist)" : allowedCount}`);
401
+
402
+ // System resources
403
+ const sys = getSystemResources();
404
+ lines.push(``);
405
+ lines.push(`🖥 *System*`);
406
+ lines.push(` CPU: ${sys.cpuPct}% (load ${sys.load1.toFixed(2)}, ${sys.cpuCount} cores)`);
407
+ lines.push(` RAM: ${sys.usedGB}/${sys.totalGB} GB (${sys.memPct}%)`);
408
+ lines.push(` Process: ${memMB} MB RSS`);
409
+
410
+ // Memory system mode
411
+ const memMode = determineMemoryMode(info);
412
+ const memEnabled = this.config.memory?.enabled !== false;
413
+ const memLabel = !memEnabled ? "disabled"
414
+ : memMode === "complement" ? "agent-managed (pi-memory)"
415
+ : memMode === "full" ? "roundhouse-managed"
416
+ : "pending detection";
417
+ lines.push(``);
418
+ lines.push(`🧠 Memory: ${memLabel}`);
419
+
420
+ // Context usage with progress bar
421
+ if (typeof info.contextTokens === "number" && typeof info.contextWindow === "number" && info.contextWindow > 0) {
422
+ const pct = Math.min(100, Math.round((info.contextTokens as number) / (info.contextWindow as number) * 100));
423
+ const barLen = 20;
424
+ const filled = Math.round(pct / 100 * barLen);
425
+ const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
426
+ const tokensK = ((info.contextTokens as number) / 1000).toFixed(1);
427
+ const windowK = ((info.contextWindow as number) / 1000).toFixed(0);
428
+ lines.push(``);
429
+ lines.push(`📝 Context: \`${bar}\` ${pct}%`);
430
+ lines.push(` ${tokensK}K / ${windowK}K tokens`);
431
+ } else if (typeof info.contextWindow === "number" && info.contextWindow > 0) {
432
+ const windowK = ((info.contextWindow as number) / 1000).toFixed(0);
433
+ lines.push(``);
434
+ lines.push(`📝 Context: no usage data yet (${windowK}K window)`);
435
+ }
436
+
437
+ await thread.post({ markdown: lines.join("\n") });
438
+ console.log(`[roundhouse] /status for thread=${thread.id}`);
439
+ return;
440
+ }
441
+
442
+ // Save any attachments (voice messages, images, files, etc.)
443
+ let attachmentResult: AttachmentResult = { saved: [], skipped: [] };
444
+ try {
445
+ attachmentResult = await saveAttachments(thread.id, rawAttachments);
446
+ } catch (err) {
447
+ console.error(`[roundhouse] saveAttachments error:`, (err as Error).message);
448
+ if (!userText.trim()) {
449
+ try { await thread.post("⚠️ Failed to process attachment(s). Please try again."); } catch {}
450
+ return;
451
+ }
452
+ }
453
+
454
+ // Notify user about skipped attachments
455
+ if (attachmentResult.skipped.length > 0) {
456
+ const skipMsg = attachmentResult.skipped.map((s) => `\u2022 ${s}`).join("\n");
457
+ try { await thread.post(`⚠️ Some attachments were skipped:\n${skipMsg}`); } catch {}
458
+ }
459
+
460
+ // Build AgentMessage
461
+ const promptText = userText.trim();
462
+ let agentMessage: AgentMessage = {
463
+ text: promptText,
464
+ attachments: attachmentResult.saved.length > 0 ? attachmentResult.saved : undefined,
465
+ };
466
+
467
+ if (!promptText && !agentMessage.attachments) {
468
+ if (rawAttachments.length > 0) {
469
+ // All attachments failed to save but message was attachment-only
470
+ try { await thread.post("⚠️ Failed to save attachment(s). Please try again."); } catch {}
471
+ }
472
+ return;
473
+ }
79
474
 
80
475
  const agent = this.router.resolve(thread.id);
476
+
477
+ // Serialize prompts per-thread (concurrent mode allows /stop to bypass)
478
+ const prevLock = threadLocks.get(thread.id);
479
+ let releaseLock: () => void;
480
+ const lockPromise = new Promise<void>((resolve) => { releaseLock = resolve; });
481
+ threadLocks.set(thread.id, lockPromise);
482
+ if (prevLock) await prevLock;
483
+
81
484
  console.log(`[roundhouse] → ${agent.name} | thread=${thread.id}`);
82
485
 
486
+ // Enrich audio attachments with transcripts (STT) — inside thread lock to prevent stampede
487
+ if (this.sttService && agentMessage.attachments?.length) {
488
+ try {
489
+ await enrichAttachmentsWithTranscripts(agentMessage.attachments, this.sttService, (text) => thread.post(text));
490
+ // Update text for voice-only messages after transcription
491
+ if (!agentMessage.text) {
492
+ const transcripts = agentMessage.attachments
493
+ .filter((a) => a.transcript?.status === "completed" && a.transcript.text)
494
+ .map((a) => a.transcript!.text);
495
+ if (transcripts.length > 0) {
496
+ agentMessage.text = `Voice message transcript: ${transcripts.join(" ")}`;
497
+ } else if (agentMessage.attachments.some((a) => a.mediaType === "audio")) {
498
+ agentMessage.text = "Voice message attached, but automatic transcription failed.";
499
+ }
500
+ }
501
+ } catch (err) {
502
+ console.error(`[roundhouse] STT enrichment error:`, (err as Error).message);
503
+ }
504
+ }
505
+
506
+ // ── Memory: pre-turn injection (Full mode only) ───
507
+ const agentCwd = (agent.getInfo?.()?.cwd as string) ?? process.cwd();
508
+ const memoryRoot = this.config.memory?.rootDir ?? agentCwd;
509
+ let memoryPrepared: Awaited<ReturnType<typeof prepareMemoryForTurn>> | undefined;
510
+ try {
511
+ memoryPrepared = await prepareMemoryForTurn(thread.id, agentMessage, agent, memoryRoot, this.config.memory);
512
+ agentMessage = memoryPrepared.message;
513
+ } catch (err) {
514
+ console.error(`[roundhouse] memory prepare error:`, (err as Error).message);
515
+ }
516
+
83
517
  const stopTyping = startTypingLoop(thread);
84
518
 
85
519
  try {
86
- const reply = await agent.prompt(thread.id, userText);
87
- if (reply.text) {
88
- for (const chunk of splitMessage(reply.text, 4000)) {
89
- await thread.post(chunk);
520
+ if (agent.promptStream) {
521
+ const ac = new AbortController();
522
+ abortControllers.set(thread.id, ac);
523
+ try {
524
+ await this.handleStreaming(thread, agent.promptStream(thread.id, agentMessage), verboseThreads.has(thread.id), ac.signal);
525
+ } finally {
526
+ abortControllers.delete(thread.id);
90
527
  }
91
528
  } else {
92
- await thread.post("(empty response)");
529
+ // Fallback: non-streaming prompt
530
+ const reply = await agent.prompt(thread.id, agentMessage);
531
+ if (reply.text) {
532
+ await this.postWithFallback(thread, reply.text);
533
+ }
534
+ }
535
+
536
+ // ── Memory: post-turn finalize + pressure check ───
537
+ try {
538
+ const pressure = await finalizeMemoryForTurn(
539
+ thread.id,
540
+ memoryPrepared?.beforeDigest ?? null,
541
+ agent, memoryRoot, this.config.memory,
542
+ );
543
+ // Use higher severity between pending compact and current pressure
544
+ const effectivePressure = maxPressure(memoryPrepared?.pendingCompact, pressure);
545
+ if (effectivePressure !== "none") {
546
+ // Run flush/compact INSIDE the thread lock to prevent race with next user message
547
+ try {
548
+ await this.handleContextPressure(thread, agent, memoryRoot, effectivePressure);
549
+ } catch (err) {
550
+ console.error(`[roundhouse] context pressure handler error:`, (err as Error).message);
551
+ }
552
+ }
553
+ } catch (err) {
554
+ console.error(`[roundhouse] memory finalize error:`, (err as Error).message);
93
555
  }
94
556
  } catch (err) {
557
+ const errMsg = err instanceof Error ? err.message : String(err);
558
+ const safeMsg = errMsg.split('\n')[0].slice(0, 200);
95
559
  console.error(`[roundhouse] agent error:`, err);
96
560
  try {
97
- await thread.post("⚠️ Something went wrong.");
561
+ await thread.post(`⚠️ Error: ${safeMsg}`);
98
562
  } catch {}
99
563
  } finally {
100
564
  stopTyping();
565
+ releaseLock!();
566
+ if (threadLocks.get(thread.id) === lockPromise) {
567
+ threadLocks.delete(thread.id);
568
+ }
101
569
  }
102
570
  };
103
571
 
104
572
  // ── Wire Chat SDK events ───────────────────────
573
+ const handleOrAbort = async (thread: any, message: any) => {
574
+ const text = (message.text ?? "").trim();
575
+ // /stop is handled immediately — abort the in-flight agent run
576
+ // without waiting for the current handler to finish
577
+ if (isCommand(text, "/stop")) {
578
+ if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
579
+ const agent = this.router.resolve(thread.id);
580
+ if (agent.abort) {
581
+ await agent.abort(thread.id);
582
+ abortControllers.get(thread.id)?.abort();
583
+ try { await thread.post("⏹️ Stopped."); } catch {}
584
+ } else {
585
+ try { await thread.post("⚠️ Abort not supported for this agent."); } catch {}
586
+ }
587
+ console.log(`[roundhouse] /stop for thread=${thread.id}`);
588
+ return;
589
+ }
590
+ // /verbose is a gateway toggle — runs immediately, no queuing
591
+ if (isCommand(text, "/verbose")) {
592
+ if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
593
+ const threadId = thread.id;
594
+ if (verboseThreads.has(threadId)) {
595
+ verboseThreads.delete(threadId);
596
+ try { await thread.post("🔇 Verbose mode OFF — tool status messages hidden."); } catch {}
597
+ } else {
598
+ verboseThreads.add(threadId);
599
+ try { await thread.post("📢 Verbose mode ON — showing tool calls."); } catch {}
600
+ }
601
+ console.log(`[roundhouse] /verbose for thread=${threadId} -> ${verboseThreads.has(threadId) ? "on" : "off"}`);
602
+ return;
603
+ }
604
+ // /doctor runs health checks immediately — no agent access needed
605
+ if (isCommand(text, "/doctor")) {
606
+ if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
607
+ const stopTyping = startTypingLoop(thread);
608
+ try {
609
+ const results = await runDoctor(await createDoctorContext());
610
+ const report = formatDoctorTelegram(results);
611
+ await this.postWithFallback(thread, report);
612
+ } catch (err) {
613
+ try { await thread.post(`⚠️ Doctor failed: ${(err as Error).message}`); } catch {}
614
+ } finally {
615
+ stopTyping();
616
+ }
617
+ console.log(`[roundhouse] /doctor for thread=${thread.id}`);
618
+ return;
619
+ }
620
+ // /crons manages scheduled jobs
621
+ if (isCommandWithArgs(text, "/crons") || isCommandWithArgs(text, "/jobs")) {
622
+ if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
623
+ const stopTyping = startTypingLoop(thread);
624
+ try {
625
+ const parts = text.split(/\s+/).slice(1); // remove /crons
626
+ const sub = parts[0];
627
+ const id = parts[1];
628
+
629
+ if (!this.cronScheduler) {
630
+ await thread.post("⚠️ Cron scheduler not running.");
631
+ } else if (sub === "trigger" && id) {
632
+ if (isBuiltinJob(id)) { await thread.post(`⚠️ ${id} is a built-in job and cannot be triggered manually.`); }
633
+ else { await thread.post(`⏳ Triggering ${id}...`); await this.cronScheduler.trigger(id); await thread.post(`✅ ${id} queued.`); }
634
+ } else if (sub === "pause" && id) {
635
+ if (isBuiltinJob(id)) { await thread.post(`⚠️ ${id} is a built-in job and cannot be paused.`); }
636
+ else { await this.cronScheduler.pauseJob(id); await thread.post(`⏸️ ${id} paused.`); }
637
+ } else if (sub === "resume" && id) {
638
+ if (isBuiltinJob(id)) { await thread.post(`⚠️ ${id} is a built-in job and cannot be resumed.`); }
639
+ else { await this.cronScheduler.resumeJob(id); await thread.post(`▶️ ${id} resumed.`); }
640
+ } else {
641
+ // Default: list jobs
642
+ const items = await this.cronScheduler.listJobs();
643
+ if (items.length === 0) {
644
+ await thread.post("No cron jobs configured.\n\nCreate one with:\n`roundhouse cron add <id> --prompt \"...\" --every 6h`");
645
+ } else {
646
+ const lines = ["🕓 *Scheduled Jobs*", ""];
647
+ for (const { job, state } of items) {
648
+ const icon = jobEnabledIcon(job.enabled);
649
+ const sched = formatSchedule(job.schedule);
650
+ lines.push(`${icon} *${job.id}*`);
651
+ lines.push(` 📅 ${sched}`);
652
+ if (job.description) lines.push(` 📝 ${job.description}`);
653
+ if (state.totalRuns > 0) {
654
+ lines.push(` 📊 ${formatRunCounts(state)}`);
655
+ if (state.lastFinishedAt) {
656
+ const ago = Math.round((Date.now() - new Date(state.lastFinishedAt).getTime()) / 60000);
657
+ const agoStr = ago < 60 ? `${ago}m ago` : `${Math.round(ago / 60)}h ago`;
658
+ lines.push(` ⏱ Last run: ${agoStr}`);
659
+ }
660
+ } else {
661
+ lines.push(` 📊 No runs yet`);
662
+ }
663
+ lines.push("");
664
+ }
665
+ lines.push(`_${items.length} job(s) configured_`);
666
+ await this.postWithFallback(thread, lines.join("\n"));
667
+ }
668
+ }
669
+ } catch (err) {
670
+ try { await thread.post(`⚠️ Cron error: ${(err as Error).message}`); } catch {}
671
+ } finally {
672
+ stopTyping();
673
+ }
674
+ console.log(`[roundhouse] /crons for thread=${thread.id}`);
675
+ return;
676
+ }
677
+ await handle(thread, message);
678
+ };
679
+
105
680
  this.chat.onDirectMessage(async (thread, message) => {
106
681
  await thread.subscribe();
107
- await handle(thread, message);
682
+ await handleOrAbort(thread, message);
108
683
  });
109
684
 
110
685
  this.chat.onNewMention(async (thread, message) => {
111
686
  await thread.subscribe();
112
- await handle(thread, message);
687
+ await handleOrAbort(thread, message);
113
688
  });
114
689
 
115
690
  this.chat.onSubscribedMessage(async (thread, message) => {
116
- await handle(thread, message);
691
+ await handleOrAbort(thread, message);
117
692
  });
118
693
 
119
694
  await this.chat.initialize();
120
695
 
121
696
  const platforms = Object.keys(this.config.chat.adapters).join(", ");
122
697
  console.log(`[roundhouse] gateway ready (platforms: ${platforms})`);
698
+
699
+ // ── Register bot commands ───
700
+ await this.registerBotCommands();
701
+
702
+ // Start cron scheduler (await so job counts are available for startup notification)
703
+ this.cronScheduler = new CronSchedulerService({
704
+ agentConfig: this.config.agent,
705
+ notifyChatIds: this.config.chat.notifyChatIds,
706
+ });
707
+ try {
708
+ await this.cronScheduler.start();
709
+ } catch (err) {
710
+ console.error("[roundhouse] cron scheduler start failed:", (err as Error).message);
711
+ }
712
+
713
+ // Send startup notification (after cron init so we can include job counts)
714
+ await this.notifyStartup(platforms);
715
+ }
716
+
717
+ /**
718
+ * Handle context pressure — flush memory and/or compact.
719
+ * Runs inside the thread lock after a turn completes.
720
+ */
721
+ private async handleContextPressure(thread: any, agent: AgentAdapter, memoryRoot: string, pressure: PressureLevel) {
722
+ if (pressure === "none") return;
723
+
724
+ console.log(`[roundhouse] context pressure: ${pressure} for thread=${thread.id}`);
725
+
726
+ if (pressure === "soft") {
727
+ // Soft: prompt agent to save facts, no compact
728
+ // Cooldown is checked inside flushMemoryThenCompact (returns null if skipped)
729
+ try {
730
+ await flushMemoryThenCompact(thread.id, agent, memoryRoot, "soft", this.config.memory);
731
+ } catch (err) {
732
+ console.error(`[roundhouse] soft flush error:`, (err as Error).message);
733
+ }
734
+ return;
735
+ }
736
+
737
+ // Hard or emergency: flush + compact
738
+ try {
739
+ await thread.post(`📝 ${pressure === "emergency" ? "⚠️ Context nearly full! " : ""}Saving memory and compacting...`);
740
+ const result = await flushMemoryThenCompact(thread.id, agent, memoryRoot, pressure, this.config.memory);
741
+ if (result) {
742
+ const beforeK = (result.tokensBefore / 1000).toFixed(1);
743
+ await thread.post(`✅ Auto-compacted: ${beforeK}K tokens → summary.`);
744
+ }
745
+ } catch (err) {
746
+ console.error(`[roundhouse] ${pressure} compact error:`, (err as Error).message);
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Stream agent events to the chat thread.
752
+ *
753
+ * Strategy:
754
+ * - Text deltas are collected per-turn and streamed via thread.handleStream()
755
+ * which does post+edit with rate limiting.
756
+ * - Tool starts/ends are sent as compact status messages.
757
+ * - Turn boundaries trigger a new message for the next turn's text.
758
+ */
759
+ private async handleStreaming(thread: any, stream: AsyncIterable<AgentStreamEvent>, verbose: boolean, signal?: AbortSignal) {
760
+ let activeTools = new Map<string, string>(); // toolCallId -> toolName
761
+
762
+ // Per-turn streaming state — each turn gets a fresh iterable + promise
763
+ let currentPush: ((text: string) => void) | null = null;
764
+ let currentFinish: (() => void) | null = null;
765
+ let currentPromise: Promise<void> | null = null;
766
+
767
+ function createTextStream(): { iterable: AsyncIterable<string>; push: (text: string) => void; finish: () => void } {
768
+ let buffer = "";
769
+ let resolve: ((value: IteratorResult<string>) => void) | null = null;
770
+ let done = false;
771
+
772
+ const iterable: AsyncIterable<string> = {
773
+ [Symbol.asyncIterator]() {
774
+ return {
775
+ async next(): Promise<IteratorResult<string>> {
776
+ if (buffer) {
777
+ const chunk = buffer;
778
+ buffer = "";
779
+ return { value: chunk, done: false };
780
+ }
781
+ if (done) return { value: undefined as any, done: true };
782
+ return new Promise((r) => { resolve = r; });
783
+ },
784
+ };
785
+ },
786
+ };
787
+
788
+ return {
789
+ iterable,
790
+ push(text: string) {
791
+ if (resolve) {
792
+ const r = resolve;
793
+ resolve = null;
794
+ r({ value: text, done: false });
795
+ } else {
796
+ buffer += text;
797
+ }
798
+ },
799
+ finish() {
800
+ done = true;
801
+ resolve?.({ value: undefined as any, done: true });
802
+ },
803
+ };
804
+ }
805
+
806
+ const flushCurrentStream = async () => {
807
+ if (!currentPromise) return;
808
+ currentFinish?.();
809
+ try {
810
+ await currentPromise;
811
+ } catch (err) {
812
+ console.warn(`[roundhouse] stream flush error:`, (err as Error).message);
813
+ }
814
+ currentPush = null;
815
+ currentFinish = null;
816
+ currentPromise = null;
817
+ };
818
+
819
+ const ensureStream = () => {
820
+ if (!currentPromise) {
821
+ const ts = createTextStream();
822
+ currentPush = ts.push;
823
+ currentFinish = ts.finish;
824
+ currentPromise = thread.handleStream(ts.iterable).catch((err: Error) => {
825
+ console.warn(`[roundhouse] handleStream error:`, err.message);
826
+ });
827
+ }
828
+ };
829
+
830
+ let hasTextInCurrentTurn = false;
831
+ let eventCount = 0;
832
+ let drainingNotified = false;
833
+
834
+ for await (const event of stream) {
835
+ // Check if /stop was called
836
+ if (signal?.aborted) {
837
+ console.log(`[roundhouse] stream aborted for thread`);
838
+ break;
839
+ }
840
+ if (DEBUG_STREAM) {
841
+ eventCount++;
842
+ const preview = event.type === "text_delta" ? `"${event.text.slice(0, 30)}"`
843
+ : event.type === "custom_message" ? `${event.customType}:${event.content.slice(0, 30)}`
844
+ : event.type === "tool_start" || event.type === "tool_end" ? event.toolName
845
+ : "";
846
+ console.log(`[roundhouse/stream] #${eventCount} ${event.type} ${preview}`);
847
+ }
848
+ switch (event.type) {
849
+ case "text_delta": {
850
+ ensureStream();
851
+ currentPush!(event.text);
852
+ hasTextInCurrentTurn = true;
853
+ break;
854
+ }
855
+
856
+ case "tool_start": {
857
+ activeTools.set(event.toolCallId, event.toolName);
858
+ if (verbose) {
859
+ try {
860
+ await thread.post(`${toolIcon(event.toolName)} Running \`${event.toolName}\`…`);
861
+ } catch {}
862
+ }
863
+ break;
864
+ }
865
+
866
+ case "tool_end": {
867
+ activeTools.delete(event.toolCallId);
868
+ break;
869
+ }
870
+
871
+ case "custom_message": {
872
+ // Extension messages (e.g. code review) — flush current stream and post as distinct message
873
+ if (currentPromise) {
874
+ await flushCurrentStream();
875
+ hasTextInCurrentTurn = false;
876
+ }
877
+ await this.postWithFallback(thread, event.content);
878
+ break;
879
+ }
880
+
881
+ case "turn_end": {
882
+ if (hasTextInCurrentTurn) {
883
+ await flushCurrentStream();
884
+ hasTextInCurrentTurn = false;
885
+ }
886
+ break;
887
+ }
888
+
889
+ case "draining": {
890
+ if (hasTextInCurrentTurn) {
891
+ await flushCurrentStream();
892
+ hasTextInCurrentTurn = false;
893
+ }
894
+ try {
895
+ await thread.post("⏳ Hold on — waiting for follow-up messages...");
896
+ drainingNotified = true;
897
+ } catch {}
898
+ break;
899
+ }
900
+
901
+ case "drain_complete": {
902
+ if (hasTextInCurrentTurn) {
903
+ await flushCurrentStream();
904
+ hasTextInCurrentTurn = false;
905
+ }
906
+ if (drainingNotified) {
907
+ try {
908
+ await thread.post("✅ All done — waiting for your input.");
909
+ } catch {}
910
+ drainingNotified = false;
911
+ }
912
+ break;
913
+ }
914
+
915
+ case "agent_end": {
916
+ if (hasTextInCurrentTurn) {
917
+ await flushCurrentStream();
918
+ }
919
+ break;
920
+ }
921
+ }
922
+ }
923
+
924
+ // Safety: make sure we flush
925
+ if (currentPromise) {
926
+ await flushCurrentStream();
927
+ }
928
+ }
929
+
930
+ /** Post text with markdown, falling back to plain text */
931
+ private async postWithFallback(thread: any, text: string) {
932
+ for (const chunk of splitMessage(text, 4000)) {
933
+ try {
934
+ await thread.post({ markdown: chunk });
935
+ } catch {
936
+ try {
937
+ await thread.post(chunk);
938
+ } catch (err) {
939
+ console.error(`[roundhouse] post failed:`, (err as Error).message);
940
+ }
941
+ }
942
+ }
943
+ }
944
+
945
+ /**
946
+ * Register bot commands with Telegram so they appear in the / menu.
947
+ * Runs on every startup to keep commands in sync with the code.
948
+ */
949
+ private async registerBotCommands() {
950
+ if (!this.config.chat.adapters.telegram) return;
951
+
952
+ const token = process.env.TELEGRAM_BOT_TOKEN;
953
+ if (!token) return;
954
+
955
+ try {
956
+ const res = await fetch(`https://api.telegram.org/bot${token}/setMyCommands`, {
957
+ method: "POST",
958
+ headers: { "Content-Type": "application/json" },
959
+ body: JSON.stringify({ commands: BOT_COMMANDS }),
960
+ });
961
+ if (res.ok) {
962
+ console.log(`[roundhouse] registered ${BOT_COMMANDS.length} bot commands with Telegram`);
963
+ } else {
964
+ const body = await res.text().catch(() => "");
965
+ console.warn(`[roundhouse] failed to register bot commands (${res.status}): ${body.slice(0, 200)}`);
966
+ }
967
+ } catch (err) {
968
+ console.warn(`[roundhouse] failed to register bot commands:`, (err as Error).message);
969
+ }
970
+ }
971
+
972
+ /**
973
+ * Send a startup notification to configured chat IDs.
974
+ * Currently Telegram-only — when Slack/Discord adapters are added,
975
+ * extend this to use their respective APIs or a Chat SDK broadcast API.
976
+ */
977
+ private async notifyStartup(platforms: string) {
978
+ const chatIds = this.config.chat.notifyChatIds;
979
+ if (!chatIds?.length) return;
980
+
981
+ if (!process.env.TELEGRAM_BOT_TOKEN) {
982
+ console.warn("[roundhouse] notifyChatIds configured but TELEGRAM_BOT_TOKEN not set — skipping startup notification");
983
+ return;
984
+ }
985
+
986
+ const bootTime = process.uptime();
987
+ const host = hostname();
988
+ const agentName = this.config.agent.type;
989
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " UTC";
990
+ const nodeVer = process.version;
991
+ const memMB = (process.memoryUsage.rss() / 1024 / 1024).toFixed(1);
992
+ const sys = getSystemResources();
993
+
994
+ // Get agent info if available (use first resolve — SingleAgentRouter always returns same agent)
995
+ let agentInfo = "";
996
+ try {
997
+ const info = this.router.resolve("status").getInfo?.() ?? {};
998
+ if (info.version) agentInfo += ` v${info.version}`;
999
+ if (info.model) agentInfo += `\nModel: ${info.model}`;
1000
+ } catch {}
1001
+
1002
+ // Cron info
1003
+ let cronInfo: string | null = null;
1004
+ if (this.cronScheduler) {
1005
+ const cs = this.cronScheduler.getStatus();
1006
+ cronInfo = `Cron jobs: ${cs.enabledCount}/${cs.jobCount} enabled`;
1007
+ }
1008
+
1009
+ const text = [
1010
+ `\u2705 Roundhouse is online`,
1011
+ ``,
1012
+ `Host: ${host}`,
1013
+ `Platforms: ${platforms}`,
1014
+ `Agent: ${agentName}${agentInfo}`,
1015
+ `Roundhouse: v${ROUNDHOUSE_VERSION}`,
1016
+ `Node: ${nodeVer}`,
1017
+ `Started: ${now}`,
1018
+ `Boot time: ${bootTime.toFixed(1)}s`,
1019
+ cronInfo,
1020
+ ``,
1021
+ `System:`,
1022
+ ` CPU: ${sys.cpuPct}% (load ${sys.load1.toFixed(2)}, ${sys.cpuCount} cores)`,
1023
+ ` RAM: ${sys.usedGB}/${sys.totalGB} GB (${sys.memPct}%)`,
1024
+ ` Process: ${memMB} MB RSS`,
1025
+ ].filter(line => line != null).join("\n");
1026
+
1027
+ await sendTelegramToMany(chatIds, text);
123
1028
  }
124
1029
 
125
1030
  async stop() {
1031
+ if (this.cronScheduler) {
1032
+ try { await this.cronScheduler.stop(); } catch (e) { console.warn("[roundhouse] cron stop error:", e); }
1033
+ }
1034
+ try { await this.chat?.shutdown(); } catch (e) { console.warn("[roundhouse] chat shutdown error:", e); }
126
1035
  await this.router.dispose();
127
1036
  console.log("[roundhouse] stopped");
128
1037
  }