@inceptionstack/roundhouse 0.1.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.
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/architecture.md +214 -0
- package/bin/roundhouse.mjs +5 -0
- package/package.json +48 -0
- package/src/agents/pi.ts +154 -0
- package/src/agents/registry.ts +25 -0
- package/src/cli/cli.ts +425 -0
- package/src/gateway.ts +129 -0
- package/src/index.ts +121 -0
- package/src/router.ts +24 -0
- package/src/types.ts +56 -0
- package/src/util.ts +87 -0
package/src/cli/cli.ts
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* roundhouse CLI entry point
|
|
5
|
+
*
|
|
6
|
+
* Commands:
|
|
7
|
+
* roundhouse start — start the gateway (foreground)
|
|
8
|
+
* roundhouse install — install as a systemd daemon
|
|
9
|
+
* roundhouse uninstall — remove the systemd daemon
|
|
10
|
+
* roundhouse update — update to latest version from npm + restart daemon
|
|
11
|
+
* roundhouse status — show daemon status
|
|
12
|
+
* roundhouse logs — tail daemon logs
|
|
13
|
+
* roundhouse stop — stop the daemon
|
|
14
|
+
* roundhouse restart — restart the daemon
|
|
15
|
+
* roundhouse config — show config path and current config
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { resolve, dirname } from "node:path";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { readFile, writeFile, mkdir, access } from "node:fs/promises";
|
|
21
|
+
import { execSync, spawn } from "node:child_process";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
25
|
+
const __dirname = dirname(__filename);
|
|
26
|
+
|
|
27
|
+
const SERVICE_NAME = "roundhouse";
|
|
28
|
+
const CONFIG_DIR = resolve(homedir(), ".config", "roundhouse");
|
|
29
|
+
const CONFIG_PATH = resolve(CONFIG_DIR, "gateway.config.json");
|
|
30
|
+
const SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
|
|
31
|
+
|
|
32
|
+
const DEFAULT_CONFIG = {
|
|
33
|
+
agent: {
|
|
34
|
+
type: "pi",
|
|
35
|
+
cwd: homedir(),
|
|
36
|
+
},
|
|
37
|
+
chat: {
|
|
38
|
+
botUsername: "roundhouse_bot",
|
|
39
|
+
allowedUsers: [] as string[],
|
|
40
|
+
adapters: {
|
|
41
|
+
telegram: { mode: "polling" },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function run(cmd: string, opts?: { silent?: boolean }): string {
|
|
47
|
+
try {
|
|
48
|
+
return execSync(cmd, { encoding: "utf8", stdio: opts?.silent ? "pipe" : "inherit" }).trim();
|
|
49
|
+
} catch (e: any) {
|
|
50
|
+
if (opts?.silent) return "";
|
|
51
|
+
throw e;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runSudo(cmd: string): void {
|
|
56
|
+
execSync(`sudo ${cmd}`, { stdio: "inherit" });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
60
|
+
try {
|
|
61
|
+
await access(path);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Commands ────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
async function cmdStart() {
|
|
71
|
+
// Import and run the gateway in-process (foreground)
|
|
72
|
+
process.env.ROUNDHOUSE_CONFIG = CONFIG_PATH;
|
|
73
|
+
const indexPath = resolve(__dirname, "..", "src", "index.ts");
|
|
74
|
+
|
|
75
|
+
// If running from installed npm package, use compiled JS
|
|
76
|
+
const jsPath = resolve(__dirname, "..", "dist", "index.js");
|
|
77
|
+
if (await fileExists(jsPath)) {
|
|
78
|
+
await import(jsPath);
|
|
79
|
+
} else {
|
|
80
|
+
// Dev mode: use tsx
|
|
81
|
+
const { execSync } = await import("node:child_process");
|
|
82
|
+
execSync(`node ${resolve(__dirname, "..", "node_modules", "tsx", "dist", "cli.mjs")} ${indexPath}`, {
|
|
83
|
+
stdio: "inherit",
|
|
84
|
+
env: { ...process.env, ROUNDHOUSE_CONFIG: CONFIG_PATH },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function cmdInstall() {
|
|
90
|
+
console.log("[roundhouse] Installing as systemd daemon...\n");
|
|
91
|
+
|
|
92
|
+
// 1. Create config if missing
|
|
93
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
94
|
+
if (await fileExists(CONFIG_PATH)) {
|
|
95
|
+
console.log(` Config exists: ${CONFIG_PATH}`);
|
|
96
|
+
} else {
|
|
97
|
+
await writeFile(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
|
|
98
|
+
console.log(` Created config: ${CONFIG_PATH}`);
|
|
99
|
+
console.log(` ⚠️ Edit this file to set allowedUsers and other settings.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 2. Find roundhouse binary
|
|
103
|
+
const binPath = run("which roundhouse", { silent: true }) || resolve(__dirname, "cli.ts");
|
|
104
|
+
const nodePath = run("which node", { silent: true }) || process.execPath;
|
|
105
|
+
|
|
106
|
+
// 3. Gather env vars for the service (only known safe ones)
|
|
107
|
+
const envLines: string[] = [];
|
|
108
|
+
for (const key of ["TELEGRAM_BOT_TOKEN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "BOT_USERNAME", "ALLOWED_USERS"]) {
|
|
109
|
+
if (process.env[key]) {
|
|
110
|
+
envLines.push(`Environment=${key}=${process.env[key]}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 4. Create systemd unit
|
|
115
|
+
const unit = `[Unit]
|
|
116
|
+
Description=Roundhouse Chat Gateway
|
|
117
|
+
After=network.target
|
|
118
|
+
|
|
119
|
+
[Service]
|
|
120
|
+
Type=simple
|
|
121
|
+
User=${process.env.USER || "root"}
|
|
122
|
+
WorkingDirectory=${homedir()}
|
|
123
|
+
ExecStart=${nodePath} ${binPath} start
|
|
124
|
+
Restart=on-failure
|
|
125
|
+
RestartSec=5
|
|
126
|
+
${envLines.join("\n")}
|
|
127
|
+
Environment=ROUNDHOUSE_CONFIG=${CONFIG_PATH}
|
|
128
|
+
Environment=NODE_ENV=production
|
|
129
|
+
|
|
130
|
+
[Install]
|
|
131
|
+
WantedBy=multi-user.target
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
const tmpUnit = `/tmp/${SERVICE_NAME}.service`;
|
|
135
|
+
await writeFile(tmpUnit, unit);
|
|
136
|
+
runSudo(`cp ${tmpUnit} ${SERVICE_PATH}`);
|
|
137
|
+
runSudo("systemctl daemon-reload");
|
|
138
|
+
runSudo(`systemctl enable ${SERVICE_NAME}`);
|
|
139
|
+
runSudo(`systemctl start ${SERVICE_NAME}`);
|
|
140
|
+
|
|
141
|
+
console.log(`\n ✅ Daemon installed and started.`);
|
|
142
|
+
console.log(`\n Config: ${CONFIG_PATH}`);
|
|
143
|
+
console.log(` Service: ${SERVICE_PATH}`);
|
|
144
|
+
console.log(` Logs: roundhouse logs`);
|
|
145
|
+
console.log(` Status: roundhouse status`);
|
|
146
|
+
|
|
147
|
+
if (envLines.length === 0) {
|
|
148
|
+
console.log(`\n ⚠️ No env vars detected. You may need to add TELEGRAM_BOT_TOKEN etc.`);
|
|
149
|
+
console.log(` Edit ${SERVICE_PATH} or use an EnvironmentFile=`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function cmdUninstall() {
|
|
154
|
+
console.log("[roundhouse] Removing systemd daemon...");
|
|
155
|
+
try {
|
|
156
|
+
runSudo(`systemctl stop ${SERVICE_NAME}`);
|
|
157
|
+
} catch {}
|
|
158
|
+
try {
|
|
159
|
+
runSudo(`systemctl disable ${SERVICE_NAME}`);
|
|
160
|
+
} catch {}
|
|
161
|
+
try {
|
|
162
|
+
runSudo(`rm -f ${SERVICE_PATH}`);
|
|
163
|
+
} catch {}
|
|
164
|
+
runSudo("systemctl daemon-reload");
|
|
165
|
+
console.log(" ✅ Daemon removed. Config preserved at:", CONFIG_PATH);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function cmdUpdate() {
|
|
169
|
+
console.log("[roundhouse] Updating to latest version...\n");
|
|
170
|
+
run("npm update -g roundhouse");
|
|
171
|
+
console.log("\n[roundhouse] Restarting daemon...");
|
|
172
|
+
try {
|
|
173
|
+
runSudo(`systemctl restart ${SERVICE_NAME}`);
|
|
174
|
+
console.log(" ✅ Updated and restarted.");
|
|
175
|
+
} catch {
|
|
176
|
+
console.log(" ⚠️ Daemon not running. Start with: roundhouse install");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function cmdStatus() {
|
|
181
|
+
try {
|
|
182
|
+
run(`systemctl status ${SERVICE_NAME}`);
|
|
183
|
+
} catch {
|
|
184
|
+
console.log("Daemon is not installed. Run: roundhouse install");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function cmdLogs() {
|
|
189
|
+
const child = spawn("journalctl", ["-u", SERVICE_NAME, "-f", "--no-pager", "-n", "100"], {
|
|
190
|
+
stdio: "inherit",
|
|
191
|
+
});
|
|
192
|
+
child.on("error", () => {
|
|
193
|
+
console.log("Could not read logs. Is the daemon installed?");
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function cmdStop() {
|
|
198
|
+
runSudo(`systemctl stop ${SERVICE_NAME}`);
|
|
199
|
+
console.log(" ✅ Daemon stopped.");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function cmdRestart() {
|
|
203
|
+
runSudo(`systemctl restart ${SERVICE_NAME}`);
|
|
204
|
+
console.log(" ✅ Daemon restarted.");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function cmdConfig() {
|
|
208
|
+
console.log(`Config path: ${CONFIG_PATH}\n`);
|
|
209
|
+
if (await fileExists(CONFIG_PATH)) {
|
|
210
|
+
const content = await readFile(CONFIG_PATH, "utf8");
|
|
211
|
+
console.log(content);
|
|
212
|
+
} else {
|
|
213
|
+
console.log("(no config file — defaults will be used)");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function cmdTui() {
|
|
218
|
+
// 1. Load config to determine agent type
|
|
219
|
+
let config: any = DEFAULT_CONFIG;
|
|
220
|
+
if (await fileExists(CONFIG_PATH)) {
|
|
221
|
+
try {
|
|
222
|
+
config = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
223
|
+
} catch {}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const agentType = config.agent?.type ?? "pi";
|
|
227
|
+
|
|
228
|
+
if (agentType !== "pi") {
|
|
229
|
+
console.error(`roundhouse tui: agent type "${agentType}" does not support TUI yet.`);
|
|
230
|
+
console.error("Only \"pi\" is supported currently.");
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 2. Find gateway sessions
|
|
235
|
+
const sessionsBase = config.agent?.sessionDir ?? resolve(homedir(), ".pi", "agent", "gateway-sessions");
|
|
236
|
+
let threadDirs: string[] = [];
|
|
237
|
+
try {
|
|
238
|
+
const { readdirSync, statSync } = await import("node:fs");
|
|
239
|
+
threadDirs = readdirSync(sessionsBase)
|
|
240
|
+
.filter((d: string) => {
|
|
241
|
+
try {
|
|
242
|
+
return statSync(resolve(sessionsBase, d)).isDirectory();
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
.sort();
|
|
248
|
+
} catch {
|
|
249
|
+
console.error(`No gateway sessions found at ${sessionsBase}`);
|
|
250
|
+
console.error("Send a message via Telegram/Slack first to create a session.");
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (threadDirs.length === 0) {
|
|
255
|
+
console.error("No gateway sessions found. Send a message via Telegram/Slack first.");
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 3. Find session files in each thread dir, pick the most recent
|
|
260
|
+
const { readdirSync, statSync } = await import("node:fs");
|
|
261
|
+
const threadArg = process.argv[3]; // optional: roundhouse tui <thread>
|
|
262
|
+
|
|
263
|
+
interface SessionCandidate {
|
|
264
|
+
threadDir: string;
|
|
265
|
+
sessionFile: string;
|
|
266
|
+
mtime: number;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const candidates: SessionCandidate[] = [];
|
|
270
|
+
for (const dir of threadDirs) {
|
|
271
|
+
if (threadArg && !dir.includes(threadArg)) continue;
|
|
272
|
+
const threadPath = resolve(sessionsBase, dir);
|
|
273
|
+
try {
|
|
274
|
+
const files = readdirSync(threadPath).filter((f: string) => f.endsWith(".jsonl"));
|
|
275
|
+
for (const f of files) {
|
|
276
|
+
const fullPath = resolve(threadPath, f);
|
|
277
|
+
const st = statSync(fullPath);
|
|
278
|
+
candidates.push({ threadDir: dir, sessionFile: fullPath, mtime: st.mtimeMs });
|
|
279
|
+
}
|
|
280
|
+
} catch {}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (candidates.length === 0) {
|
|
284
|
+
if (threadArg) {
|
|
285
|
+
console.error(`No sessions found matching "${threadArg}".`);
|
|
286
|
+
console.log("Available threads:");
|
|
287
|
+
for (const d of threadDirs) console.log(` ${d}`);
|
|
288
|
+
} else {
|
|
289
|
+
console.error("No session files found.");
|
|
290
|
+
}
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Sort by most recently modified
|
|
295
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
296
|
+
|
|
297
|
+
// If multiple threads and no filter, let user pick
|
|
298
|
+
let selected: SessionCandidate;
|
|
299
|
+
const uniqueThreads = [...new Set(candidates.map((c) => c.threadDir))];
|
|
300
|
+
|
|
301
|
+
if (uniqueThreads.length === 1 || threadArg) {
|
|
302
|
+
selected = candidates[0];
|
|
303
|
+
} else {
|
|
304
|
+
console.log("Available sessions (most recent first):\n");
|
|
305
|
+
const shown: SessionCandidate[] = [];
|
|
306
|
+
const seen = new Set<string>();
|
|
307
|
+
for (const c of candidates) {
|
|
308
|
+
if (seen.has(c.threadDir)) continue;
|
|
309
|
+
seen.add(c.threadDir);
|
|
310
|
+
shown.push(c);
|
|
311
|
+
}
|
|
312
|
+
for (let i = 0; i < shown.length; i++) {
|
|
313
|
+
const age = Math.round((Date.now() - shown[i].mtime) / 60000);
|
|
314
|
+
const ageStr = age < 60 ? `${age}m ago` : `${Math.round(age / 60)}h ago`;
|
|
315
|
+
console.log(` [${i + 1}] ${shown[i].threadDir} (${ageStr})`);
|
|
316
|
+
}
|
|
317
|
+
console.log();
|
|
318
|
+
|
|
319
|
+
// Simple prompt
|
|
320
|
+
const readline = await import("node:readline");
|
|
321
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
322
|
+
const answer = await new Promise<string>((resolve) => {
|
|
323
|
+
rl.question("Pick a session [1]: ", (ans) => {
|
|
324
|
+
rl.close();
|
|
325
|
+
resolve(ans.trim() || "1");
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const idx = parseInt(answer, 10) - 1;
|
|
330
|
+
if (isNaN(idx) || idx < 0 || idx >= shown.length) {
|
|
331
|
+
console.error("Invalid selection.");
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
// Find most recent session file for the selected thread
|
|
335
|
+
selected = candidates.find((c) => c.threadDir === shown[idx].threadDir)!;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
console.log(`\nOpening: ${selected.sessionFile}\n`);
|
|
339
|
+
|
|
340
|
+
// 4. Launch pi --resume <session>
|
|
341
|
+
const piArgs = ["--resume", selected.sessionFile];
|
|
342
|
+
const child = spawn("pi", piArgs, { stdio: "inherit" });
|
|
343
|
+
|
|
344
|
+
child.on("error", (err) => {
|
|
345
|
+
if ((err as any).code === "ENOENT") {
|
|
346
|
+
console.error("'pi' not found in PATH. Install pi first.");
|
|
347
|
+
} else {
|
|
348
|
+
console.error("Failed to launch pi:", err.message);
|
|
349
|
+
}
|
|
350
|
+
process.exit(1);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
child.on("exit", (code) => {
|
|
354
|
+
process.exit(code ?? 0);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function printHelp() {
|
|
359
|
+
console.log(`
|
|
360
|
+
roundhouse — Multi-platform chat gateway for AI agents
|
|
361
|
+
|
|
362
|
+
Usage:
|
|
363
|
+
roundhouse <command>
|
|
364
|
+
|
|
365
|
+
Commands:
|
|
366
|
+
start Start the gateway (foreground)
|
|
367
|
+
tui [thread] Open agent TUI on a gateway session
|
|
368
|
+
install Install as a systemd daemon (requires sudo)
|
|
369
|
+
uninstall Remove the systemd daemon
|
|
370
|
+
update Update from npm + restart daemon
|
|
371
|
+
status Show daemon status
|
|
372
|
+
logs Tail daemon logs
|
|
373
|
+
stop Stop the daemon
|
|
374
|
+
restart Restart the daemon
|
|
375
|
+
config Show config path and contents
|
|
376
|
+
|
|
377
|
+
Config:
|
|
378
|
+
~/.config/roundhouse/gateway.config.json
|
|
379
|
+
|
|
380
|
+
Environment:
|
|
381
|
+
TELEGRAM_BOT_TOKEN Telegram bot token
|
|
382
|
+
ANTHROPIC_API_KEY API key for pi agent
|
|
383
|
+
ALLOWED_USERS Comma-separated usernames
|
|
384
|
+
`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Main ────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
const command = process.argv[2];
|
|
390
|
+
|
|
391
|
+
switch (command) {
|
|
392
|
+
case "start":
|
|
393
|
+
cmdStart();
|
|
394
|
+
break;
|
|
395
|
+
case "install":
|
|
396
|
+
cmdInstall();
|
|
397
|
+
break;
|
|
398
|
+
case "uninstall":
|
|
399
|
+
cmdUninstall();
|
|
400
|
+
break;
|
|
401
|
+
case "update":
|
|
402
|
+
cmdUpdate();
|
|
403
|
+
break;
|
|
404
|
+
case "status":
|
|
405
|
+
cmdStatus();
|
|
406
|
+
break;
|
|
407
|
+
case "logs":
|
|
408
|
+
cmdLogs();
|
|
409
|
+
break;
|
|
410
|
+
case "stop":
|
|
411
|
+
cmdStop();
|
|
412
|
+
break;
|
|
413
|
+
case "restart":
|
|
414
|
+
cmdRestart();
|
|
415
|
+
break;
|
|
416
|
+
case "config":
|
|
417
|
+
cmdConfig();
|
|
418
|
+
break;
|
|
419
|
+
case "tui":
|
|
420
|
+
cmdTui();
|
|
421
|
+
break;
|
|
422
|
+
default:
|
|
423
|
+
printHelp();
|
|
424
|
+
break;
|
|
425
|
+
}
|
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gateway.ts — Roundhouse gateway
|
|
3
|
+
*
|
|
4
|
+
* Owns the Vercel Chat SDK instance and wires all platform events
|
|
5
|
+
* through the agent router.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Chat } from "chat";
|
|
9
|
+
import { createMemoryState } from "@chat-adapter/state-memory";
|
|
10
|
+
import type { AgentRouter, GatewayConfig } from "./types";
|
|
11
|
+
import { splitMessage, isAllowed, startTypingLoop } from "./util";
|
|
12
|
+
|
|
13
|
+
// ── Chat SDK adapter factories ───────────────────────
|
|
14
|
+
// Lazy-imported so we don't crash if an adapter package isn't installed.
|
|
15
|
+
|
|
16
|
+
async function buildChatAdapters(
|
|
17
|
+
config: GatewayConfig["chat"]["adapters"]
|
|
18
|
+
): Promise<Record<string, unknown>> {
|
|
19
|
+
const adapters: Record<string, unknown> = {};
|
|
20
|
+
|
|
21
|
+
if (config.telegram) {
|
|
22
|
+
const { createTelegramAdapter } = await import("@chat-adapter/telegram");
|
|
23
|
+
adapters.telegram = createTelegramAdapter({
|
|
24
|
+
mode: (config.telegram.mode as "auto" | "polling" | "webhook") ?? "auto",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Future:
|
|
29
|
+
// if (config.slack) { ... }
|
|
30
|
+
// if (config.discord) { ... }
|
|
31
|
+
|
|
32
|
+
return adapters;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Gateway ──────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export class Gateway {
|
|
38
|
+
private chat!: Chat;
|
|
39
|
+
private router: AgentRouter;
|
|
40
|
+
private config: GatewayConfig;
|
|
41
|
+
|
|
42
|
+
constructor(router: AgentRouter, config: GatewayConfig) {
|
|
43
|
+
this.router = router;
|
|
44
|
+
this.config = config;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async start() {
|
|
48
|
+
const chatAdapters = await buildChatAdapters(this.config.chat.adapters);
|
|
49
|
+
|
|
50
|
+
if (Object.keys(chatAdapters).length === 0) {
|
|
51
|
+
throw new Error("No chat adapters configured. Add at least one in config.chat.adapters.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.chat = new Chat({
|
|
55
|
+
userName: this.config.chat.botUsername,
|
|
56
|
+
adapters: chatAdapters as any,
|
|
57
|
+
state: createMemoryState(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const allowedUsers = (this.config.chat.allowedUsers ?? []).map((u) =>
|
|
61
|
+
u.toLowerCase()
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// ── Unified handler ────────────────────────────
|
|
65
|
+
const handle = async (thread: any, message: any) => {
|
|
66
|
+
const userText = message.text ?? "";
|
|
67
|
+
const authorName = message.author?.userName ?? message.author?.userId ?? "?";
|
|
68
|
+
|
|
69
|
+
console.log(
|
|
70
|
+
`[roundhouse] ${thread.id} @${authorName}: "${userText.slice(0, 120)}"`
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (!isAllowed(message, allowedUsers)) {
|
|
74
|
+
console.log(`[roundhouse] blocked @${authorName} (not in allowlist)`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!userText.trim() || userText === "/start") return;
|
|
79
|
+
|
|
80
|
+
const agent = this.router.resolve(thread.id);
|
|
81
|
+
console.log(`[roundhouse] → ${agent.name} | thread=${thread.id}`);
|
|
82
|
+
|
|
83
|
+
const stopTyping = startTypingLoop(thread);
|
|
84
|
+
|
|
85
|
+
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);
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
await thread.post("(empty response)");
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error(`[roundhouse] agent error:`, err);
|
|
96
|
+
try {
|
|
97
|
+
await thread.post("⚠️ Something went wrong.");
|
|
98
|
+
} catch {}
|
|
99
|
+
} finally {
|
|
100
|
+
stopTyping();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// ── Wire Chat SDK events ───────────────────────
|
|
105
|
+
this.chat.onDirectMessage(async (thread, message) => {
|
|
106
|
+
await thread.subscribe();
|
|
107
|
+
await handle(thread, message);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
this.chat.onNewMention(async (thread, message) => {
|
|
111
|
+
await thread.subscribe();
|
|
112
|
+
await handle(thread, message);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
this.chat.onSubscribedMessage(async (thread, message) => {
|
|
116
|
+
await handle(thread, message);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await this.chat.initialize();
|
|
120
|
+
|
|
121
|
+
const platforms = Object.keys(this.config.chat.adapters).join(", ");
|
|
122
|
+
console.log(`[roundhouse] gateway ready (platforms: ${platforms})`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async stop() {
|
|
126
|
+
await this.router.dispose();
|
|
127
|
+
console.log("[roundhouse] stopped");
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.ts — Roundhouse entry point
|
|
3
|
+
*
|
|
4
|
+
* Loads config, creates the agent + router + gateway, starts up.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* TELEGRAM_BOT_TOKEN=... npm start
|
|
8
|
+
* TELEGRAM_BOT_TOKEN=... npm start -- --config ./my-config.json
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
import type { GatewayConfig } from "./types";
|
|
15
|
+
import { getAgentFactory } from "./agents/registry";
|
|
16
|
+
import { SingleAgentRouter } from "./router";
|
|
17
|
+
import { Gateway } from "./gateway";
|
|
18
|
+
|
|
19
|
+
// ── Crash protection ─────────────────────────────────
|
|
20
|
+
process.on("uncaughtException", (err) => {
|
|
21
|
+
console.error("[roundhouse] uncaughtException:", err);
|
|
22
|
+
});
|
|
23
|
+
process.on("unhandledRejection", (reason) => {
|
|
24
|
+
console.error("[roundhouse] unhandledRejection:", reason);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ── Default config ───────────────────────────────────
|
|
28
|
+
const DEFAULT_CONFIG: GatewayConfig = {
|
|
29
|
+
agent: {
|
|
30
|
+
type: "pi",
|
|
31
|
+
cwd: process.cwd(),
|
|
32
|
+
},
|
|
33
|
+
chat: {
|
|
34
|
+
botUsername: process.env.BOT_USERNAME ?? "roundhouse_bot",
|
|
35
|
+
allowedUsers: process.env.ALLOWED_USERS
|
|
36
|
+
? process.env.ALLOWED_USERS.split(",").map((u) => u.trim())
|
|
37
|
+
: [],
|
|
38
|
+
adapters: {
|
|
39
|
+
telegram: { mode: "polling" },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
async function loadConfig(): Promise<GatewayConfig> {
|
|
45
|
+
// Check for ROUNDHOUSE_CONFIG env var (set by CLI/daemon)
|
|
46
|
+
const envConfig = process.env.ROUNDHOUSE_CONFIG;
|
|
47
|
+
if (envConfig) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = await readFile(resolve(envConfig), "utf8");
|
|
50
|
+
console.log(`[roundhouse] loaded config from ${envConfig}`);
|
|
51
|
+
return JSON.parse(raw) as GatewayConfig;
|
|
52
|
+
} catch {
|
|
53
|
+
// Fall through to other methods
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check for --config flag
|
|
58
|
+
const configIdx = process.argv.indexOf("--config");
|
|
59
|
+
if (configIdx !== -1 && process.argv[configIdx + 1]) {
|
|
60
|
+
const configPath = resolve(process.argv[configIdx + 1]);
|
|
61
|
+
console.log(`[roundhouse] loading config from ${configPath}`);
|
|
62
|
+
const raw = await readFile(configPath, "utf8");
|
|
63
|
+
return JSON.parse(raw) as GatewayConfig;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Try gateway.config.json in cwd
|
|
67
|
+
try {
|
|
68
|
+
const raw = await readFile(
|
|
69
|
+
resolve(process.cwd(), "gateway.config.json"),
|
|
70
|
+
"utf8"
|
|
71
|
+
);
|
|
72
|
+
console.log("[roundhouse] loaded gateway.config.json");
|
|
73
|
+
return JSON.parse(raw) as GatewayConfig;
|
|
74
|
+
} catch {
|
|
75
|
+
// Fall back to defaults + env vars
|
|
76
|
+
console.log("[roundhouse] using default config + env vars");
|
|
77
|
+
return DEFAULT_CONFIG;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function main() {
|
|
82
|
+
const config = await loadConfig();
|
|
83
|
+
|
|
84
|
+
// ── Validate ───────────────────────────────────────
|
|
85
|
+
const hasTelegram = config.chat.adapters.telegram;
|
|
86
|
+
if (hasTelegram && !process.env.TELEGRAM_BOT_TOKEN) {
|
|
87
|
+
console.error("TELEGRAM_BOT_TOKEN is required for Telegram adapter.");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Create agent ───────────────────────────────────
|
|
92
|
+
const { type, ...agentConfig } = config.agent;
|
|
93
|
+
const factory = getAgentFactory(type);
|
|
94
|
+
const agent = factory(agentConfig);
|
|
95
|
+
console.log(`[roundhouse] agent: ${agent.name}`);
|
|
96
|
+
|
|
97
|
+
// ── Create router (single agent for now) ───────────
|
|
98
|
+
const router = new SingleAgentRouter(agent);
|
|
99
|
+
|
|
100
|
+
// ── Create gateway ─────────────────────────────────
|
|
101
|
+
const gateway = new Gateway(router, config);
|
|
102
|
+
|
|
103
|
+
// ── Graceful shutdown ──────────────────────────────
|
|
104
|
+
const shutdown = async (signal: string) => {
|
|
105
|
+
console.log(`\n[roundhouse] received ${signal}, shutting down…`);
|
|
106
|
+
await gateway.stop();
|
|
107
|
+
process.exit(0);
|
|
108
|
+
};
|
|
109
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
110
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
111
|
+
|
|
112
|
+
// ── Start ──────────────────────────────────────────
|
|
113
|
+
console.log("[roundhouse] starting…");
|
|
114
|
+
await gateway.start();
|
|
115
|
+
console.log("[roundhouse] running. Press Ctrl+C to stop.");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main().catch((err) => {
|
|
119
|
+
console.error("[roundhouse] fatal:", err);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
});
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router.ts — Agent routing
|
|
3
|
+
*
|
|
4
|
+
* Today: SingleAgentRouter — all threads go to one agent.
|
|
5
|
+
* Future: swap in MultiAgentRouter, UserChoiceRouter, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AgentAdapter, AgentRouter } from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Routes every thread to the single configured agent.
|
|
12
|
+
* This is the default and only router for now.
|
|
13
|
+
*/
|
|
14
|
+
export class SingleAgentRouter implements AgentRouter {
|
|
15
|
+
constructor(private agent: AgentAdapter) {}
|
|
16
|
+
|
|
17
|
+
resolve(_threadId: string): AgentAdapter {
|
|
18
|
+
return this.agent;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async dispose(): Promise<void> {
|
|
22
|
+
await this.agent.dispose();
|
|
23
|
+
}
|
|
24
|
+
}
|