@inceptionstack/roundhouse 0.3.8 → 0.3.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
package/src/agents/pi.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * ~/.pi/agent/gateway-sessions/<thread_id>/<session>.jsonl
7
7
  */
8
8
 
9
- import { mkdir } from "node:fs/promises";
9
+ import { mkdir, stat } from "node:fs/promises";
10
10
  import { readFileSync } from "node:fs";
11
11
  import { join, dirname } from "node:path";
12
12
  import { homedir } from "node:os";
@@ -24,7 +24,7 @@ import {
24
24
  } from "@mariozechner/pi-coding-agent";
25
25
 
26
26
  import type { AgentAdapter, AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent } from "../types";
27
- import { DEBUG_STREAM, threadIdToDir } from "../util";
27
+ import { DEBUG_STREAM, threadIdToDir, threadIdToDirLegacy } from "../util";
28
28
 
29
29
  interface SessionEntry {
30
30
  session: AgentSession;
@@ -159,7 +159,21 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
159
159
  }
160
160
 
161
161
  async function createSession(threadId: string): Promise<SessionEntry> {
162
- const threadDir = join(sessionsDir, threadIdToDir(threadId));
162
+ let dirName = threadIdToDir(threadId);
163
+ const newPath = join(sessionsDir, dirName);
164
+ const legacyPath = join(sessionsDir, threadIdToDirLegacy(threadId));
165
+ // Migrate: if legacy dir exists but new doesn't, use legacy
166
+ if (dirName !== threadIdToDirLegacy(threadId)) {
167
+ try {
168
+ await stat(legacyPath);
169
+ // Legacy exists — check if new exists
170
+ try { await stat(newPath); } catch {
171
+ // New doesn't exist, use legacy to preserve session history
172
+ dirName = threadIdToDirLegacy(threadId);
173
+ }
174
+ } catch { /* legacy doesn't exist either, use new */ }
175
+ }
176
+ const threadDir = join(sessionsDir, dirName);
163
177
  await mkdir(threadDir, { recursive: true });
164
178
 
165
179
  let sessionManager: InstanceType<typeof SessionManager>;
package/src/cli/cli.ts CHANGED
@@ -9,7 +9,7 @@ import { homedir } from "node:os";
9
9
  import { readFile, writeFile, mkdir, mkdtemp } from "node:fs/promises";
10
10
  import { tmpdir } from "node:os";
11
11
  import { readdirSync, statSync } from "node:fs";
12
- import { execSync, spawn } from "node:child_process";
12
+ import { execSync, execFileSync, spawnSync, spawn } from "node:child_process";
13
13
  import { fileURLToPath } from "node:url";
14
14
 
15
15
  import {
@@ -40,12 +40,16 @@ function run(cmd: string, opts?: { silent?: boolean }): string {
40
40
  }
41
41
  }
42
42
 
43
- function runSudo(cmd: string): void {
44
- execSync(`sudo ${cmd}`, { stdio: "inherit" });
43
+ function runSudo(...args: string[]): void {
44
+ // Try non-interactive first, fall back to interactive for password prompts
45
+ const result = spawnSync("sudo", ["-n", ...args], { stdio: "inherit" });
46
+ if (result.status !== 0) {
47
+ execFileSync("sudo", args, { stdio: "inherit" });
48
+ }
45
49
  }
46
50
 
47
51
  function systemctl(verb: string, message?: string): void {
48
- runSudo(`systemctl ${verb} ${SERVICE_NAME}`);
52
+ runSudo("systemctl", verb, SERVICE_NAME);
49
53
  if (message) console.log(` ✅ ${message}`);
50
54
  }
51
55
 
@@ -151,9 +155,9 @@ WantedBy=multi-user.target
151
155
  const tmpDir = await mkdtemp(resolve(tmpdir(), "roundhouse-"));
152
156
  const tmpUnit = resolve(tmpDir, `${SERVICE_NAME}.service`);
153
157
  await writeFile(tmpUnit, unit, { mode: 0o600 });
154
- runSudo(`cp ${tmpUnit} ${SERVICE_PATH}`);
155
- runSudo(`rm -rf -- ${tmpDir}`);
156
- runSudo("systemctl daemon-reload");
158
+ runSudo("cp", tmpUnit, SERVICE_PATH);
159
+ runSudo("rm", "-rf", "--", tmpDir);
160
+ runSudo("systemctl", "daemon-reload");
157
161
  systemctl("enable");
158
162
  systemctl("start", "Daemon installed and started.");
159
163
 
@@ -174,8 +178,8 @@ async function cmdUninstall() {
174
178
  console.log("[roundhouse] Removing systemd daemon...");
175
179
  try { systemctl("stop"); } catch {}
176
180
  try { systemctl("disable"); } catch {}
177
- try { runSudo(`rm -f ${SERVICE_PATH}`); } catch {}
178
- runSudo("systemctl daemon-reload");
181
+ try { runSudo("rm", "-f", SERVICE_PATH); } catch {}
182
+ runSudo("systemctl", "daemon-reload");
179
183
  console.log(" ✅ Daemon removed. Config preserved at:", CONFIG_PATH);
180
184
  }
181
185
 
package/src/cli/setup.ts CHANGED
@@ -189,7 +189,10 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
189
189
  );
190
190
  }
191
191
  if (opts.users.length === 0) {
192
- throw new Error("At least one --user USERNAME is required.");
192
+ throw new Error(
193
+ "At least one --user USERNAME is required.\n" +
194
+ "This is your Telegram username (without @).",
195
+ );
193
196
  }
194
197
  for (const ext of opts.extensions) {
195
198
  if (!EXTENSION_NAME_RE.test(ext)) {
@@ -254,9 +257,23 @@ async function stepPreflight(opts: SetupOptions): Promise<void> {
254
257
  const hasAws =
255
258
  process.env.AWS_ACCESS_KEY_ID ||
256
259
  process.env.AWS_PROFILE ||
257
- await fileExists(resolve(homedir(), ".aws", "credentials"));
260
+ await fileExists(resolve(homedir(), ".aws", "credentials")) ||
261
+ await fileExists(resolve(homedir(), ".aws", "config"));
262
+
263
+ // Also check instance metadata (EC2 IAM role)
264
+ let hasInstanceRole = false;
265
+ if (!hasAws) {
266
+ try {
267
+ const result = execSafe("curl", ["-sf", "--max-time", "2",
268
+ "http://169.254.169.254/latest/meta-data/iam/security-credentials/"], { silent: true });
269
+ hasInstanceRole = result.length > 0;
270
+ } catch {}
271
+ }
272
+
258
273
  if (hasAws) {
259
274
  ok("AWS credentials found");
275
+ } else if (hasInstanceRole) {
276
+ ok("AWS credentials found (instance IAM role)");
260
277
  } else {
261
278
  warn("AWS credentials not found — configure before first use");
262
279
  }
@@ -436,7 +453,7 @@ async function stepInstallPackages(opts: SetupOptions): Promise<void> {
436
453
  async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<void> {
437
454
  if (!opts.psst) return;
438
455
 
439
- step("", "Storing secrets in psst...");
456
+ step("", "Storing secrets in psst...");
440
457
 
441
458
  const secrets: [string, string][] = [
442
459
  ["TELEGRAM_BOT_TOKEN", opts.botToken],
@@ -477,7 +494,7 @@ async function stepConfigure(
477
494
  botInfo: BotInfo,
478
495
  pairResult: PairResult | null,
479
496
  ): Promise<void> {
480
- step("", "Configuring...");
497
+ step("", "Configuring...");
481
498
 
482
499
  await mkdir(ROUNDHOUSE_DIR, { recursive: true });
483
500
  await mkdir(dirname(PI_SETTINGS_PATH), { recursive: true });
@@ -602,6 +619,10 @@ async function stepConfigure(
602
619
  if (!envLines.some((l) => l.startsWith("AWS_DEFAULT_REGION="))) {
603
620
  envLines.push(`AWS_DEFAULT_REGION=${existingEnv.AWS_DEFAULT_REGION ?? '"us-east-1"'}`);
604
621
  }
622
+ // Pi agent requires AWS_REGION (not just AWS_DEFAULT_REGION) to discover Bedrock models
623
+ if (!envLines.some((l) => l.startsWith("AWS_REGION="))) {
624
+ envLines.push(`AWS_REGION=${existingEnv.AWS_REGION ?? existingEnv.AWS_DEFAULT_REGION ?? '"us-east-1"'}`);
625
+ }
605
626
  }
606
627
 
607
628
  await atomicWriteText(ENV_PATH, envLines.join("\n") + "\n");
@@ -609,7 +630,7 @@ async function stepConfigure(
609
630
  }
610
631
 
611
632
  async function stepPair(opts: SetupOptions, botInfo: BotInfo): Promise<PairResult | null> {
612
- step("", "Pairing with Telegram...");
633
+ step("", "Pairing with Telegram...");
613
634
 
614
635
  // Skip if chat IDs already known
615
636
  if (opts.notifyChatIds.length > 0) {
@@ -651,6 +672,11 @@ async function stepPair(opts: SetupOptions, botInfo: BotInfo): Promise<PairResul
651
672
 
652
673
  if (result) {
653
674
  ok(`Paired with @${result.username} (user id: ${result.userId}, chat: ${result.chatId})`);
675
+ // Add paired username to allowedUsers if not already present
676
+ const lcUsername = result.username.toLowerCase();
677
+ if (!opts.users.some((u) => u.toLowerCase() === lcUsername)) {
678
+ opts.users.push(result.username);
679
+ }
654
680
  return result;
655
681
  }
656
682
 
@@ -814,12 +840,12 @@ export async function cmdSetup(argv: string[]): Promise<void> {
814
840
  // Phase 2: Install packages
815
841
  await stepInstallPackages(opts);
816
842
 
817
- // Phase 3: Store secrets
818
- await stepStoreSecrets(opts, botInfo);
819
-
820
- // Phase 4: Pair (before config, so we can include chat ID)
843
+ // Phase 3: Pair (before secrets/config, so paired username is included)
821
844
  const pairResult = await stepPair(opts, botInfo);
822
845
 
846
+ // Phase 4: Store secrets (after pairing, so ALLOWED_USERS includes paired user)
847
+ await stepStoreSecrets(opts, botInfo);
848
+
823
849
  // Phase 5: Write config (includes pair data)
824
850
  await stepConfigure(opts, botInfo, pairResult);
825
851
 
@@ -951,18 +977,20 @@ function printDryRun(opts: SetupOptions): void {
951
977
  log(`Would install: npm install -g psst-cli`);
952
978
  log(`Would initialize psst vault`);
953
979
  log(`Would install: pi-psst extension`);
954
- log(`Would store TELEGRAM_BOT_TOKEN, BOT_USERNAME, ALLOWED_USERS in psst`);
955
980
  }
956
981
  for (const ext of opts.extensions) log(`Would install extension: ${ext}`);
982
+ if (!opts.nonInteractive && opts.notifyChatIds.length === 0) {
983
+ log(`Would pair via Telegram (interactive)`);
984
+ }
985
+ if (opts.psst) {
986
+ log(`Would store TELEGRAM_BOT_TOKEN, BOT_USERNAME, ALLOWED_USERS in psst`);
987
+ }
957
988
  log(`Would configure: ~/.pi/agent/settings.json`);
958
989
  log(` Set defaultProvider: ${opts.provider}`);
959
990
  log(` Set defaultModel: ${opts.model}`);
960
991
  log(`Would write: ~/.roundhouse/gateway.config.json`);
961
992
  log(`Would write: ~/.roundhouse/env${opts.psst ? " (non-secret config only)" : ""}`);
962
993
  log(`Would register ${BOT_COMMANDS.length} bot commands`);
963
- if (!opts.nonInteractive && opts.notifyChatIds.length === 0) {
964
- log(`Would pair via Telegram (interactive)`);
965
- }
966
994
  if (opts.systemd) log(`Would install systemd service`);
967
995
  log("\nNo changes made.\n");
968
996
  }
package/src/config.ts CHANGED
@@ -39,7 +39,7 @@ export const DEFAULT_CONFIG: GatewayConfig = {
39
39
  cwd: homedir(),
40
40
  },
41
41
  chat: {
42
- botUsername: "roundhouse_bot",
42
+ botUsername: "",
43
43
  allowedUsers: [],
44
44
  adapters: {
45
45
  telegram: { mode: "polling" },
@@ -276,7 +276,10 @@ export class CronSchedulerService {
276
276
  // Skip if already queued — don't write state that could race with the runner
277
277
  if (this.queuedJobIds.has(job.id)) continue;
278
278
 
279
- // Update lastScheduledAt to prevent re-queueing next tick
279
+ // Update lastScheduledAt to prevent re-queueing next tick.
280
+ // NOTE: If the process crashes between here and run completion, this
281
+ // occurrence won't be retried on restart. Acceptable for periodic jobs;
282
+ // critical one-shot jobs should use external orchestration.
280
283
  state.lastScheduledAt = dueAt.toISOString();
281
284
  await this.store.writeState(state);
282
285
 
package/src/gateway.ts CHANGED
@@ -22,13 +22,26 @@ import { maxPressure } from "./memory/policy";
22
22
  import type { PressureLevel } from "./memory/types";
23
23
 
24
24
  /** Match a Telegram command, handling optional @botname suffix */
25
+ /** Bot username for command suffix validation (set during gateway init) */
26
+ let _botUsername = "";
27
+
25
28
  function isCommand(text: string, cmd: string): boolean {
26
- return text === cmd || text.startsWith(`${cmd}@`);
29
+ if (text === cmd) return true;
30
+ if (!text.startsWith(`${cmd}@`)) return false;
31
+ if (!_botUsername) return false; // no bot name configured, reject suffixed commands
32
+ const suffix = text.slice(cmd.length + 1).toLowerCase();
33
+ return suffix === _botUsername.toLowerCase();
27
34
  }
28
35
 
29
36
  /** Match a command that accepts subcommands (e.g. /crons trigger <id>) */
30
37
  function isCommandWithArgs(text: string, cmd: string): boolean {
31
- return text === cmd || text.startsWith(`${cmd}@`) || text.startsWith(`${cmd} `);
38
+ if (text === cmd || text.startsWith(`${cmd} `)) return true;
39
+ if (!text.startsWith(`${cmd}@`)) return false;
40
+ if (!_botUsername) return false;
41
+ const rest = text.slice(cmd.length + 1);
42
+ const spaceIdx = rest.indexOf(" ");
43
+ const suffix = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
44
+ return suffix.toLowerCase() === _botUsername.toLowerCase();
32
45
  }
33
46
  import { hostname, loadavg, totalmem, freemem, cpus } from "node:os";
34
47
  import { homedir } from "node:os";
@@ -166,10 +179,25 @@ async function saveAttachments(threadId: string, attachments: any[]): Promise<At
166
179
  continue;
167
180
  }
168
181
 
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());
182
+ // Convert to Buffer with size cap to prevent memory exhaustion
183
+ let buf: Buffer;
184
+ if (Buffer.isBuffer(data)) {
185
+ buf = data;
186
+ } else if (data instanceof Blob) {
187
+ // Stream blobs with size cap
188
+ if (data.size > MAX_FILE_SIZE) {
189
+ skipped.push(`${att.name ?? att.type} (${(data.size / 1024 / 1024).toFixed(1)} MB) exceeds size limit`);
190
+ continue;
191
+ }
192
+ buf = Buffer.from(await data.arrayBuffer());
193
+ } else if (ArrayBuffer.isView(data)) {
194
+ buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
195
+ } else if (data instanceof ArrayBuffer) {
196
+ buf = Buffer.from(data);
197
+ } else {
198
+ console.warn(`[roundhouse] unknown attachment data type, skipping: ${att.name ?? att.type}`);
199
+ continue;
200
+ }
173
201
 
174
202
  if (buf.length > MAX_FILE_SIZE) {
175
203
  const sizeMB = (buf.length / 1024 / 1024).toFixed(1);
@@ -220,6 +248,7 @@ export class Gateway {
220
248
  constructor(router: AgentRouter, config: GatewayConfig) {
221
249
  this.router = router;
222
250
  this.config = config;
251
+ _botUsername = config.chat.botUsername || "";
223
252
  }
224
253
 
225
254
  async start() {
@@ -305,8 +334,8 @@ export class Gateway {
305
334
  // Handle /restart command — restart the gateway process
306
335
  // Only available when an allowlist is configured (all allowed users can restart)
307
336
  if (isCommand(userText.trim(), "/restart")) {
308
- if (allowedUsers.length === 0) {
309
- await thread.post("⚠️ /restart requires an allowedUsers list to be configured.");
337
+ if (allowedUsers.length === 0 && allowedUserIds.length === 0) {
338
+ await thread.post("⚠️ /restart requires an allowlist (allowedUsers or allowedUserIds) to be configured.");
310
339
  return;
311
340
  }
312
341
  console.log(`[roundhouse] /restart requested by @${authorName} in thread=${thread.id}`);
@@ -9,7 +9,7 @@ import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
9
9
  import { resolve, dirname } from "node:path";
10
10
  import { randomBytes } from "node:crypto";
11
11
  import { ROUNDHOUSE_DIR } from "../config";
12
- import { threadIdToDir } from "../util";
12
+ import { threadIdToDir, threadIdToDirLegacy } from "../util";
13
13
  import type { ThreadMemoryState } from "./types";
14
14
 
15
15
  const STATE_DIR = resolve(ROUNDHOUSE_DIR, "memory-state");
@@ -18,12 +18,24 @@ function stateFilePath(threadId: string): string {
18
18
  return resolve(STATE_DIR, `${threadIdToDir(threadId)}.json`);
19
19
  }
20
20
 
21
+ function legacyStateFilePath(threadId: string): string {
22
+ return resolve(STATE_DIR, `${threadIdToDirLegacy(threadId)}.json`);
23
+ }
24
+
21
25
  /** Load per-thread memory state (returns empty state if none exists) */
22
26
  export async function loadThreadMemoryState(threadId: string): Promise<ThreadMemoryState> {
23
27
  try {
24
28
  const raw = await readFile(stateFilePath(threadId), "utf8");
25
29
  return JSON.parse(raw) as ThreadMemoryState;
26
30
  } catch {
31
+ // Fallback to legacy encoding for pre-v0.4 state files
32
+ try {
33
+ const legacyPath = legacyStateFilePath(threadId);
34
+ if (legacyPath !== stateFilePath(threadId)) {
35
+ const raw = await readFile(legacyPath, "utf8");
36
+ return JSON.parse(raw) as ThreadMemoryState;
37
+ }
38
+ } catch {}
27
39
  return {};
28
40
  }
29
41
  }
package/src/types.ts CHANGED
@@ -98,6 +98,8 @@ export interface AgentRouter {
98
98
  // ── Gateway config ───────────────────────────────────
99
99
 
100
100
  export interface GatewayConfig {
101
+ /** Config schema version for future migrations */
102
+ _version?: number;
101
103
  agent: {
102
104
  type: string;
103
105
  [key: string]: unknown;
package/src/util.ts CHANGED
@@ -97,12 +97,20 @@ export function startTypingLoop(
97
97
  * Uses a scheme that avoids collisions between different separators.
98
98
  */
99
99
  export function threadIdToDir(threadId: string): string {
100
- // Escape underscores first, then encode special chars:
101
- // ":" → "_c", "_" → "_u", everything else → "_x"
100
+ // Injective filesystem-safe encoding:
101
+ // "_" → "__", ":" → "_c", other special → "_xNN" (hex code)
102
102
  return threadId
103
- .replace(/_/g, "_u") // escape existing underscores first
104
- .replace(/:/g, "_c") // encode colons
105
- .replace(/[^a-zA-Z0-9_-]/g, "_x"); // encode everything else
103
+ .replace(/_/g, "__") // escape existing underscores first
104
+ .replace(/:/g, "_c") // encode colons (common in thread IDs)
105
+ .replace(/[^a-zA-Z0-9_-]/g, (ch) => `_x${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
106
+ }
107
+
108
+ /** Legacy encoding (v0.3.x) for migration fallback */
109
+ export function threadIdToDirLegacy(threadId: string): string {
110
+ return threadId
111
+ .replace(/_/g, "_u")
112
+ .replace(/:/g, "_c")
113
+ .replace(/[^a-zA-Z0-9_-]/g, "_x");
106
114
  }
107
115
 
108
116
  /**