@inceptionstack/roundhouse 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +321 -9
- package/architecture.md +77 -8
- package/package.json +9 -6
- package/src/agents/pi.ts +433 -26
- package/src/agents/registry.ts +8 -0
- package/src/cli/cli.ts +384 -189
- package/src/cli/cron.ts +296 -0
- package/src/cli/doctor/checks/agent.ts +68 -0
- package/src/cli/doctor/checks/config.ts +88 -0
- package/src/cli/doctor/checks/credentials.ts +62 -0
- package/src/cli/doctor/checks/disk.ts +69 -0
- package/src/cli/doctor/checks/stt.ts +76 -0
- package/src/cli/doctor/checks/system.ts +86 -0
- package/src/cli/doctor/checks/systemd.ts +76 -0
- package/src/cli/doctor/output.ts +58 -0
- package/src/cli/doctor/runner.ts +142 -0
- package/src/cli/doctor/shell.ts +33 -0
- package/src/cli/doctor/types.ts +44 -0
- package/src/cli/doctor.ts +48 -0
- package/src/cli/setup-telegram.ts +148 -0
- package/src/cli/setup.ts +936 -0
- package/src/commands.ts +23 -0
- package/src/config.ts +188 -0
- package/src/cron/constants.ts +54 -0
- package/src/cron/durations.ts +33 -0
- package/src/cron/format.ts +139 -0
- package/src/cron/helpers.ts +30 -0
- package/src/cron/runner.ts +148 -0
- package/src/cron/schedule.ts +101 -0
- package/src/cron/scheduler.ts +295 -0
- package/src/cron/store.ts +125 -0
- package/src/cron/template.ts +89 -0
- package/src/cron/types.ts +76 -0
- package/src/gateway.ts +927 -18
- package/src/index.ts +1 -58
- package/src/memory/bootstrap.ts +98 -0
- package/src/memory/files.ts +100 -0
- package/src/memory/inject.ts +41 -0
- package/src/memory/lifecycle.ts +245 -0
- package/src/memory/policy.ts +122 -0
- package/src/memory/prompts.ts +42 -0
- package/src/memory/state.ts +43 -0
- package/src/memory/types.ts +90 -0
- package/src/notify/telegram.ts +48 -0
- package/src/types.ts +68 -1
- package/src/util.ts +28 -2
- package/src/voice/providers/whisper.ts +339 -0
- package/src/voice/stt-service.ts +284 -0
- 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 (
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
682
|
+
await handleOrAbort(thread, message);
|
|
108
683
|
});
|
|
109
684
|
|
|
110
685
|
this.chat.onNewMention(async (thread, message) => {
|
|
111
686
|
await thread.subscribe();
|
|
112
|
-
await
|
|
687
|
+
await handleOrAbort(thread, message);
|
|
113
688
|
});
|
|
114
689
|
|
|
115
690
|
this.chat.onSubscribedMessage(async (thread, message) => {
|
|
116
|
-
await
|
|
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
|
}
|