@inceptionstack/roundhouse 0.3.9 → 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.9",
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
 
@@ -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/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
  /**