@inceptionstack/roundhouse 0.3.9 → 0.3.11
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 +1 -1
- package/src/agents/pi.ts +17 -3
- package/src/cli/cli.ts +13 -9
- package/src/cli/doctor/checks/agent.ts +18 -8
- package/src/cli/setup.ts +6 -2
- package/src/cron/scheduler.ts +4 -1
- package/src/gateway.ts +37 -8
- package/src/memory/state.ts +13 -1
- package/src/util.ts +13 -5
package/package.json
CHANGED
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
|
-
|
|
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(
|
|
44
|
-
|
|
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(
|
|
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(
|
|
155
|
-
runSudo(
|
|
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(
|
|
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
|
|
|
@@ -12,18 +12,28 @@ export const agentChecks: DoctorCheck[] = [
|
|
|
12
12
|
{
|
|
13
13
|
id: "pi-sdk", category: "agent", name: "Pi SDK",
|
|
14
14
|
async run() {
|
|
15
|
+
const PI_PKG = join("@mariozechner", "pi-coding-agent", "package.json");
|
|
16
|
+
const searchPaths = [
|
|
17
|
+
join(process.cwd(), "node_modules", PI_PKG),
|
|
18
|
+
];
|
|
19
|
+
// Also check global npm root
|
|
15
20
|
try {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const { execFileSync } = await import("node:child_process");
|
|
22
|
+
const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim();
|
|
23
|
+
searchPaths.push(join(globalRoot, PI_PKG));
|
|
24
|
+
} catch {}
|
|
25
|
+
for (const pkgPath of searchPaths) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await readFile(pkgPath, "utf8");
|
|
28
|
+
const ver = JSON.parse(raw).version;
|
|
29
|
+
return { id: "pi-sdk", category: "agent", name: "Pi SDK", status: "pass" as const, summary: `v${ver}` };
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
id: "pi-sdk", category: "agent", name: "Pi SDK", status: "fail" as const, summary: "not found",
|
|
23
34
|
details: ["@mariozechner/pi-coding-agent not installed"],
|
|
24
35
|
fix: { description: "Install pi SDK", command: "npm install @mariozechner/pi-coding-agent" },
|
|
25
36
|
};
|
|
26
|
-
}
|
|
27
37
|
},
|
|
28
38
|
},
|
|
29
39
|
|
package/src/cli/setup.ts
CHANGED
|
@@ -181,7 +181,7 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
// Validate
|
|
184
|
-
if (!opts.botToken) {
|
|
184
|
+
if (!opts.botToken && !opts.dryRun) {
|
|
185
185
|
throw new Error(
|
|
186
186
|
"Bot token required. Provide via:\n" +
|
|
187
187
|
" TELEGRAM_BOT_TOKEN=... roundhouse setup --user USERNAME\n" +
|
|
@@ -451,7 +451,11 @@ async function stepInstallPackages(opts: SetupOptions): Promise<void> {
|
|
|
451
451
|
}
|
|
452
452
|
|
|
453
453
|
async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<void> {
|
|
454
|
-
if (!opts.psst)
|
|
454
|
+
if (!opts.psst) {
|
|
455
|
+
step("⑥", "Storing secrets...");
|
|
456
|
+
ok("Skipped (--no-psst)");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
455
459
|
|
|
456
460
|
step("⑥", "Storing secrets in psst...");
|
|
457
461
|
|
package/src/cron/scheduler.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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}`);
|
package/src/memory/state.ts
CHANGED
|
@@ -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
|
-
//
|
|
101
|
-
// "
|
|
100
|
+
// Injective filesystem-safe encoding:
|
|
101
|
+
// "_" → "__", ":" → "_c", other special → "_xNN" (hex code)
|
|
102
102
|
return threadId
|
|
103
|
-
.replace(/_/g, "
|
|
104
|
-
.replace(/:/g, "_c") // encode colons
|
|
105
|
-
.replace(/[^a-zA-Z0-9_-]/g,
|
|
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
|
/**
|