@inceptionstack/roundhouse 0.3.28 → 0.4.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/README.md CHANGED
@@ -52,6 +52,30 @@ npm install -g @inceptionstack/roundhouse
52
52
 
53
53
  See [architecture.md](architecture.md) for full system diagrams, data flow, config model, and module dependency graph.
54
54
 
55
+ ## Bundle
56
+
57
+ When you run `roundhouse setup`, the following are installed automatically:
58
+
59
+ - **30+ Skills** (agent knowledge): Synced from [loki-skills](https://github.com/inceptionstack/loki-skills) (AWS, infrastructure, DevOps patterns)
60
+ - **CLI Tools**: `mcporter` (MCP server bridge), `@playwright/cli` (browser automation), `uv`/`uvx` (Python package runner)
61
+ - **Extensions** (shipped in npm, auto-discovered by pi): `web-search` (Tavily API integration)
62
+ - **Config**: MCP server definitions copied to `~/.mcporter/mcporter.json`
63
+
64
+ This gives the agent access to:
65
+ - 15K+ AWS APIs via `mcporter call aws-mcp.*`
66
+ - AWS documentation, CDK patterns, pricing data
67
+ - Browser automation: navigate pages, fill forms, take screenshots
68
+ - Real-time web search
69
+ - All skills auto-discovered at session start
70
+
71
+ ### Setup time
72
+
73
+ Full setup takes ~5-10 minutes on first run (includes Chromium download ~186MB). Subsequent runs are faster (skills re-sync only).
74
+
75
+ ### Skills location
76
+
77
+ All skills are synced to `~/.pi/agent/skills/`. Your agent can reference them directly by name (e.g., "use the aws-mcp skill to...").
78
+
55
79
  ### Design decisions
56
80
 
57
81
  - **One gateway = one agent target.** The `agent` block in config picks the type and its settings. All chat inputs route to this single agent instance.
package/architecture.md CHANGED
@@ -272,9 +272,10 @@ cli/cli.ts
272
272
  ├── cli/systemd.ts (resolveExecStart, generateUnit, writeServiceUnit, systemctl, etc.)
273
273
  ├── cli/doctor.ts → cli/doctor/runner.ts → cli/doctor/checks/*
274
274
  ├── cli/cron.ts → cron/store.ts, cron/runner.ts, cron/helpers.ts
275
- └── cli/setup.ts → cli/env-file.ts, cli/systemd.ts, cli/setup-telegram.ts
275
+ └── cli/setup.ts → cli/env-file.ts, cli/systemd.ts, cli/setup-telegram.ts, bundle.ts
276
276
 
277
277
  gateway.ts also imports:
278
+ → commands/update.ts → bundle.ts (bundle provisioning)
278
279
  → cli/doctor/runner.ts for /doctor command
279
280
  → cron/scheduler.ts → cron/runner.ts → cron/store.ts
280
281
  → cron/helpers.ts, cron/format.ts
@@ -283,3 +284,4 @@ gateway.ts also imports:
283
284
 
284
285
  No circular dependencies. `types.ts` and `config.ts` are pure leaf modules.
285
286
  `util.ts` is a leaf module with runtime helpers (`node:crypto` for attachment IDs).
287
+ `bundle.ts` is a pure leaf module (only `node:*` imports) consumed by `cli/setup.ts` and `commands/update.ts`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.3.28",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -33,7 +33,8 @@
33
33
  "bin/",
34
34
  "LICENSE",
35
35
  "README.md",
36
- "architecture.md"
36
+ "architecture.md",
37
+ "pi/"
37
38
  ],
38
39
  "dependencies": {
39
40
  "@chat-adapter/state-memory": "^4.26.0",
@@ -47,5 +48,10 @@
47
48
  },
48
49
  "devDependencies": {
49
50
  "vitest": "^4.1.5"
51
+ },
52
+ "pi": {
53
+ "extensions": [
54
+ "./pi/extensions"
55
+ ]
50
56
  }
51
57
  }
@@ -0,0 +1,33 @@
1
+ {
2
+ "mcpServers": {
3
+ "aws-mcp": {
4
+ "command": "uvx",
5
+ "args": ["mcp-proxy-for-aws@latest", "https://aws-mcp.us-east-1.api.aws/mcp"]
6
+ },
7
+ "aws-knowledge": {
8
+ "url": "https://knowledge-mcp.global.api.aws"
9
+ },
10
+ "aws-documentation": {
11
+ "command": "uvx",
12
+ "args": ["awslabs.aws-documentation-mcp-server@latest"],
13
+ "env": {
14
+ "FASTMCP_LOG_LEVEL": "ERROR",
15
+ "AWS_DOCUMENTATION_PARTITION": "aws"
16
+ }
17
+ },
18
+ "aws-iac": {
19
+ "command": "uvx",
20
+ "args": ["awslabs.aws-iac-mcp-server@latest"],
21
+ "env": {
22
+ "FASTMCP_LOG_LEVEL": "ERROR"
23
+ }
24
+ },
25
+ "aws-pricing": {
26
+ "command": "uvx",
27
+ "args": ["awslabs.aws-pricing-mcp-server@latest"],
28
+ "env": {
29
+ "FASTMCP_LOG_LEVEL": "ERROR"
30
+ }
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,99 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+
4
+ export default function (pi: ExtensionAPI) {
5
+ pi.registerTool({
6
+ name: "web_search",
7
+ label: "Web Search",
8
+ description:
9
+ "Search the web using Tavily. Returns JSON results with title, url, and content for each result. Use this when you need current information from the internet.",
10
+ parameters: Type.Object({
11
+ query: Type.String({ description: "Search query" }),
12
+ num_results: Type.Optional(
13
+ Type.Number({ description: "Number of results (1-20, default 5)", minimum: 1, maximum: 20 })
14
+ ),
15
+ include_answer: Type.Optional(
16
+ Type.Boolean({ description: "Include AI-generated answer summary (default false)" })
17
+ ),
18
+ }),
19
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
20
+ const apiKey = process.env.TAVILY_API_KEY || "";
21
+ if (!apiKey) {
22
+ return {
23
+ content: [{ type: "text", text: "TAVILY_API_KEY environment variable not set. Set it to use web search." }],
24
+ details: { error: "missing_api_key" },
25
+ };
26
+ }
27
+
28
+ const maxResults = Math.min(Math.max(params.num_results ?? 5, 1), 20);
29
+ const includeAnswer = params.include_answer ?? false;
30
+
31
+ const startTime = Date.now();
32
+ const controller = new AbortController();
33
+ const timeout = setTimeout(() => controller.abort(), 30_000);
34
+ if (signal?.aborted) controller.abort();
35
+ else if (signal) signal.addEventListener("abort", () => controller.abort(), { once: true });
36
+
37
+ let response: Response;
38
+ try {
39
+ response = await fetch("https://api.tavily.com/search", {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({
43
+ query: params.query,
44
+ max_results: maxResults,
45
+ include_answer: includeAnswer,
46
+ api_key: apiKey,
47
+ }),
48
+ signal: controller.signal,
49
+ });
50
+ } catch (err: any) {
51
+ clearTimeout(timeout);
52
+ const msg = err.name === "AbortError" ? "Request timed out or was cancelled" : err.message;
53
+ return {
54
+ content: [{ type: "text", text: `Web search failed: ${msg}` }],
55
+ details: { query: params.query, error: msg },
56
+ };
57
+ } finally {
58
+ clearTimeout(timeout);
59
+ }
60
+
61
+ if (!response.ok) {
62
+ const errorText = await response.text();
63
+ return {
64
+ content: [{ type: "text", text: `Tavily API error (${response.status}): ${errorText}` }],
65
+ details: { query: params.query, error: response.status },
66
+ };
67
+ }
68
+
69
+ const data = (await response.json()) as {
70
+ answer?: string;
71
+ results?: Array<{ title: string; url: string; content: string }>;
72
+ };
73
+ const responseTime = Date.now() - startTime;
74
+
75
+ const results = data.results ?? [];
76
+ const parts: string[] = [];
77
+
78
+ if (includeAnswer && data.answer) {
79
+ parts.push(`**Answer:** ${data.answer}\n`);
80
+ }
81
+
82
+ for (let i = 0; i < results.length; i++) {
83
+ const r = results[i];
84
+ parts.push(`${i + 1}. **${r.title}**\n ${r.url}\n ${r.content}`);
85
+ }
86
+
87
+ const text = parts.length > 0 ? parts.join("\n\n") : "No results found.";
88
+
89
+ return {
90
+ content: [{ type: "text", text }],
91
+ details: {
92
+ query: params.query,
93
+ resultCount: results.length,
94
+ responseTime: `${responseTime}ms`,
95
+ },
96
+ };
97
+ },
98
+ });
99
+ }
package/src/bundle.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * bundle.ts — Shared bundle provisioning logic
3
+ *
4
+ * Used by both setup.ts (initial install) and gateway.ts (upgrade path).
5
+ * All operations are non-fatal — failures are logged but don't throw.
6
+ */
7
+
8
+ import { homedir } from "node:os";
9
+ import { resolve, dirname } from "node:path";
10
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync } from "node:fs";
11
+ import { execFileSync } from "node:child_process";
12
+ import { randomBytes } from "node:crypto";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ export const SKILLS_REPO = "https://github.com/inceptionstack/loki-skills.git";
16
+ export const SKILLS_DIR = resolve(homedir(), ".pi", "agent", "skills");
17
+
18
+ export interface ProvisionLog {
19
+ info(msg: string): void;
20
+ warn(msg: string): void;
21
+ ok(msg: string): void;
22
+ }
23
+
24
+ const consoleLog: ProvisionLog = {
25
+ info: (msg) => console.log(`[roundhouse] ${msg}`),
26
+ warn: (msg) => console.warn(`[roundhouse] ${msg}`),
27
+ ok: (msg) => console.log(`[roundhouse] ✓ ${msg}`),
28
+ };
29
+
30
+ export interface ProvisionOpts {
31
+ force?: boolean;
32
+ log?: ProvisionLog;
33
+ }
34
+
35
+ function which(cmd: string): boolean {
36
+ try {
37
+ execFileSync("which", [cmd], { stdio: "pipe", timeout: 5_000 });
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Sync skills from loki-skills repo (additive — never deletes custom skills).
46
+ * Removes existing skill dirs before copy to prevent nesting.
47
+ * Returns number of skills synced.
48
+ */
49
+ export function syncSkillsFromRepo(opts: ProvisionOpts = {}): number {
50
+ const log = opts.log ?? consoleLog;
51
+
52
+ if (!which("git")) {
53
+ log.warn("git not found — skipping skill sync");
54
+ return 0;
55
+ }
56
+
57
+ log.info("Syncing skills from inceptionstack/loki-skills...");
58
+ const tmpDir = `/tmp/loki-skills-${randomBytes(4).toString("hex")}`;
59
+ try {
60
+ mkdirSync(SKILLS_DIR, { recursive: true });
61
+ execFileSync("git", ["clone", "--depth", "1", "--quiet", SKILLS_REPO, tmpDir], {
62
+ stdio: "pipe", timeout: 60_000,
63
+ });
64
+
65
+ const entries = readdirSync(tmpDir, { withFileTypes: true })
66
+ .filter(e => e.isDirectory() && !e.name.startsWith("."));
67
+
68
+ let count = 0;
69
+ for (const entry of entries) {
70
+ const src = resolve(tmpDir, entry.name);
71
+ const dest = resolve(SKILLS_DIR, entry.name);
72
+ // Defense-in-depth: ensure dest stays within SKILLS_DIR
73
+ if (!dest.startsWith(SKILLS_DIR + "/")) continue;
74
+ try {
75
+ execFileSync("rm", ["-rf", dest], { stdio: "pipe", timeout: 10_000 });
76
+ execFileSync("cp", ["-r", src, dest], { stdio: "pipe", timeout: 30_000 });
77
+ count++;
78
+ } catch (e: any) {
79
+ log.warn(`Failed to copy skill '${entry.name}': ${e.message}`);
80
+ }
81
+ }
82
+ log.ok(`${count} skills synced to ~/.pi/agent/skills/`);
83
+ return count;
84
+ } catch (err: any) {
85
+ log.warn(`Skill sync failed: ${err.message}`);
86
+ return 0;
87
+ } finally {
88
+ try { execFileSync("rm", ["-rf", tmpDir], { stdio: "pipe" }); } catch {}
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Install mcporter globally via npm.
94
+ */
95
+ export function provisionMcporter(opts: ProvisionOpts = {}): void {
96
+ const log = opts.log ?? consoleLog;
97
+ if (which("mcporter") && !opts.force) {
98
+ log.ok("mcporter (already installed)");
99
+ return;
100
+ }
101
+ log.info("Installing mcporter...");
102
+ try {
103
+ execFileSync("npm", ["install", "-g", "mcporter"], { stdio: "pipe", timeout: 120_000 });
104
+ log.ok("mcporter");
105
+ } catch (err: any) {
106
+ log.warn(`mcporter install failed: ${err.message}`);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Install @playwright/cli globally and download Chromium.
112
+ */
113
+ export function provisionPlaywright(opts: ProvisionOpts = {}): void {
114
+ const log = opts.log ?? consoleLog;
115
+ const alreadyInstalled = which("playwright-cli");
116
+ if (alreadyInstalled && !opts.force) {
117
+ // Ensure Chromium is downloaded (idempotent — fast no-op if present)
118
+ try {
119
+ execFileSync("playwright-cli", ["install"], { stdio: "pipe", timeout: 300_000 });
120
+ } catch {
121
+ log.warn("Chromium may be missing — run 'playwright-cli install' manually");
122
+ }
123
+ log.ok("playwright-cli (already installed)");
124
+ return;
125
+ }
126
+ log.info("Installing @playwright/cli...");
127
+ try {
128
+ execFileSync("npm", ["install", "-g", "@playwright/cli"], { stdio: "pipe", timeout: 120_000 });
129
+ log.info("Downloading Chromium (one-time, ~186MB)...");
130
+ try {
131
+ execFileSync("playwright-cli", ["install"], { stdio: "pipe", timeout: 300_000 });
132
+ log.ok("playwright-cli + Chromium");
133
+ } catch {
134
+ log.warn("Chromium download failed — run 'playwright-cli install' manually");
135
+ }
136
+ } catch (err: any) {
137
+ log.warn(`playwright-cli install failed: ${err.message}`);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Install uv/uvx via official installer.
143
+ */
144
+ export function provisionUvx(opts: ProvisionOpts = {}): void {
145
+ const log = opts.log ?? consoleLog;
146
+ const uvxPath = resolve(homedir(), ".local", "bin", "uvx");
147
+ if ((which("uvx") || existsSync(uvxPath)) && !opts.force) {
148
+ log.ok("uv/uvx (already installed)");
149
+ return;
150
+ }
151
+ log.info("Installing uv/uvx...");
152
+ try {
153
+ execFileSync("bash", ["-c", "curl -fsSL https://astral.sh/uv/install.sh | sh"], {
154
+ stdio: "pipe", timeout: 120_000,
155
+ env: { ...process.env, HOME: homedir() },
156
+ });
157
+ log.ok("uv/uvx");
158
+ } catch (err: any) {
159
+ log.warn(`uv install failed: ${err.message}`);
160
+ log.warn("Install manually: curl -LsSf https://astral.sh/uv/install.sh | sh");
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Copy bundled mcporter.json to ~/.mcporter/ if missing or forced.
166
+ */
167
+ export function provisionMcporterConfig(opts: ProvisionOpts = {}): void {
168
+ const log = opts.log ?? consoleLog;
169
+ const mcporterDir = resolve(homedir(), ".mcporter");
170
+ const mcporterConfig = resolve(mcporterDir, "mcporter.json");
171
+ if (existsSync(mcporterConfig) && !opts.force) {
172
+ log.ok("~/.mcporter/mcporter.json (exists, keeping)");
173
+ return;
174
+ }
175
+ try {
176
+ const bundled = resolve(dirname(fileURLToPath(import.meta.url)), "..", "pi", "config", "mcporter.json");
177
+ mkdirSync(mcporterDir, { recursive: true });
178
+ writeFileSync(mcporterConfig, readFileSync(bundled, "utf8"), { mode: 0o644 });
179
+ log.ok("~/.mcporter/mcporter.json");
180
+ } catch (err: any) {
181
+ log.warn(`mcporter config copy failed: ${err.message}`);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Provision all bundle dependencies (skills + CLI tools + config).
187
+ * Non-fatal — logs warnings on failure but never throws.
188
+ */
189
+ export function provisionBundle(opts: ProvisionOpts = {}): void {
190
+ syncSkillsFromRepo(opts);
191
+ provisionMcporter(opts);
192
+ provisionPlaywright(opts);
193
+ provisionUvx(opts);
194
+ provisionMcporterConfig(opts);
195
+ }
package/src/cli/cli.ts CHANGED
@@ -9,6 +9,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
9
9
  import { readdirSync, statSync } from "node:fs";
10
10
  import { execSync, execFileSync, spawn } from "node:child_process";
11
11
  import { fileURLToPath } from "node:url";
12
+ import { performUpdate } from "../commands/update";
12
13
 
13
14
  import {
14
15
  CONFIG_DIR,
@@ -48,7 +49,8 @@ const __dirname = dirname(__filename);
48
49
  */
49
50
  function run(cmd: string, opts?: { silent?: boolean }): string {
50
51
  try {
51
- return execSync(cmd, { encoding: "utf8", stdio: opts?.silent ? "pipe" : "inherit" }).trim();
52
+ const out = execSync(cmd, { encoding: "utf8", stdio: opts?.silent ? "pipe" : "inherit" });
53
+ return (out ?? "").trim();
52
54
  } catch (e: any) {
53
55
  if (opts?.silent) return "";
54
56
  throw e;
@@ -157,8 +159,15 @@ async function cmdUninstall() {
157
159
  }
158
160
 
159
161
  async function cmdUpdate() {
160
- console.log("[roundhouse] Updating to latest version...\n");
161
- run("npm update -g roundhouse");
162
+ const progress = { update: async (msg: string) => console.log(msg) };
163
+ const result = await performUpdate(progress);
164
+
165
+ if (result.action === "already-latest") {
166
+ console.log(`[roundhouse] Already on latest (v${result.currentVersion})`);
167
+ return;
168
+ }
169
+
170
+ console.log(`[roundhouse] Updated to v${result.latestVersion}`);
162
171
  console.log("\n[roundhouse] Restarting daemon...");
163
172
  try {
164
173
  systemctl("restart", "Updated and restarted.");
package/src/cli/setup.ts CHANGED
@@ -15,6 +15,7 @@ import { readFile, writeFile, mkdir, rename, unlink, realpath, stat } from "node
15
15
  import { execFileSync } from "node:child_process";
16
16
  import { randomBytes } from "node:crypto";
17
17
  import { BOT_COMMANDS } from "../commands";
18
+ import { provisionBundle, type ProvisionLog } from "../bundle";
18
19
  import {
19
20
  ROUNDHOUSE_DIR,
20
21
  CONFIG_PATH,
@@ -131,10 +132,14 @@ function resolveAgentForSetup(opts: SetupOptions): AgentDefinition {
131
132
  // Ensure packages array exists
132
133
  if (!Array.isArray(settings.packages)) settings.packages = [];
133
134
 
135
+ // Add roundhouse itself (ships extensions via pi.extensions in package.json)
136
+ const selfPkg = "npm:@inceptionstack/roundhouse";
137
+ const pkgs = settings.packages as string[];
138
+ if (!pkgs.includes(selfPkg)) pkgs.push(selfPkg);
139
+
134
140
  // Add pi-psst if using psst
135
141
  if (ctx.psst) {
136
142
  const psstPkg = "npm:@miclivs/pi-psst";
137
- const pkgs = settings.packages as string[];
138
143
  if (!pkgs.includes(psstPkg)) pkgs.push(psstPkg);
139
144
  }
140
145
 
@@ -621,6 +626,20 @@ async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<v
621
626
  }
622
627
  }
623
628
 
629
+ // ── Bundle install ──────────────────────────────────────────────────
630
+
631
+ async function stepInstallBundle(opts: SetupOptions): Promise<void> {
632
+ step("⑥b", "Installing bundle (skills + CLI tools)...");
633
+
634
+ const bundleLog: ProvisionLog = {
635
+ info: (msg) => log(` ${msg}`),
636
+ warn: (msg) => warn(msg),
637
+ ok: (msg) => ok(msg),
638
+ };
639
+
640
+ provisionBundle({ force: opts.force, log: bundleLog });
641
+ }
642
+
624
643
  async function stepConfigure(
625
644
  opts: SetupOptions,
626
645
  botInfo: BotInfo,
@@ -921,6 +940,9 @@ async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
921
940
  // Step 5: Install packages
922
941
  await stepInstallPackages(opts, agent);
923
942
 
943
+ // Step 5b: Install bundle (skills + CLI tools)
944
+ await stepInstallBundle(opts);
945
+
924
946
  // Step 6: Pair via Telegram
925
947
  step("⑥", "Pairing with Telegram...");
926
948
  const nonce = createPairingNonce();
@@ -1014,6 +1036,9 @@ async function runHeadlessTelegramSetup(opts: SetupOptions): Promise<void> {
1014
1036
  await stepInstallPackages(opts, agent);
1015
1037
  logger.ok("Packages installed");
1016
1038
 
1039
+ // Step 4b: Install bundle
1040
+ await stepInstallBundle(opts);
1041
+
1017
1042
  // Step 5: Create pending pairing
1018
1043
  logger.step(5, 9, "pairing.pending", "Creating pending pairing");
1019
1044
  let nonce: string;
@@ -1153,6 +1178,9 @@ export async function cmdSetup(argv: string[]): Promise<void> {
1153
1178
  // Phase 2: Install packages
1154
1179
  await stepInstallPackages(opts, agent);
1155
1180
 
1181
+ // Phase 2b: Install bundle (skills + CLI tools)
1182
+ await stepInstallBundle(opts);
1183
+
1156
1184
  // Phase 3: Pair (before secrets/config, so paired username is included)
1157
1185
  const pairResult = await stepPair(opts, botInfo);
1158
1186
 
@@ -132,7 +132,7 @@ export function generateUnit(opts: UnitOptions): string {
132
132
  const user = opts.user || process.env.USER || "root";
133
133
  const envFilePath = opts.envFilePath || ENV_FILE_PATH;
134
134
  const home = homedir();
135
- const pathValue = `${opts.nodeBinDir}:/usr/local/bin:/usr/bin:/bin`;
135
+ const pathValue = `${opts.nodeBinDir}:${home}/.local/bin:/usr/local/bin:/usr/bin:/bin`;
136
136
 
137
137
  // Validate all interpolated values before generating the unit
138
138
  for (const [label, value] of Object.entries({
@@ -0,0 +1,69 @@
1
+ /**
2
+ * commands/update.ts — Handle the /update command
3
+ *
4
+ * Transport-agnostic: receives a ProgressReporter interface,
5
+ * not a Telegram-specific thread object.
6
+ */
7
+
8
+ import { homedir } from "node:os";
9
+ import { execSync } from "node:child_process";
10
+ import { readFileSync, writeFileSync } from "node:fs";
11
+ import { provisionBundle } from "../bundle";
12
+
13
+ export interface UpdateProgress {
14
+ update(text: string): Promise<void>;
15
+ }
16
+
17
+ export interface UpdateResult {
18
+ action: "already-latest" | "updated";
19
+ currentVersion: string;
20
+ latestVersion?: string;
21
+ }
22
+
23
+ /**
24
+ * Check for updates, install if newer, provision bundle, patch settings.
25
+ * Returns the result — caller decides how to present it and whether to restart.
26
+ */
27
+ export async function performUpdate(progress: UpdateProgress): Promise<UpdateResult> {
28
+ // Get current version
29
+ const pkg = await import("../../package.json", { with: { type: "json" } });
30
+ const currentVersion = pkg.default?.version ?? "unknown";
31
+
32
+ // Check latest version on npm
33
+ const latestVersion = execSync("npm view @inceptionstack/roundhouse version 2>/dev/null", {
34
+ timeout: 30_000,
35
+ encoding: "utf8",
36
+ }).trim();
37
+
38
+ if (!latestVersion || latestVersion === currentVersion) {
39
+ return { action: "already-latest", currentVersion };
40
+ }
41
+
42
+ await progress.update(`📦 Updating v${currentVersion} → v${latestVersion}...`);
43
+
44
+ execSync("npm install -g @inceptionstack/roundhouse@latest 2>&1", {
45
+ timeout: 120_000,
46
+ encoding: "utf8",
47
+ });
48
+
49
+ // Provision bundle (skills sync + CLI tools + config)
50
+ try {
51
+ provisionBundle();
52
+ } catch (e) {
53
+ console.warn("[roundhouse] bundle provisioning failed:", e instanceof Error ? e.message : e);
54
+ }
55
+
56
+ // Ensure settings.json includes roundhouse package (for pre-bundle upgrades)
57
+ try {
58
+ const settingsPath = `${homedir()}/.pi/agent/settings.json`;
59
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
60
+ const selfPkg = "npm:@inceptionstack/roundhouse";
61
+ if (!Array.isArray(settings.packages)) settings.packages = [];
62
+ if (!settings.packages.includes(selfPkg)) {
63
+ settings.packages.push(selfPkg);
64
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
65
+ }
66
+ } catch { /* settings.json may not exist yet — fine, setup will create it */ }
67
+
68
+ return { action: "updated", currentVersion, latestVersion };
69
+ }
package/src/commands.ts CHANGED
@@ -16,6 +16,7 @@ export const BOT_COMMANDS: BotCommand[] = [
16
16
  { command: "verbose", description: "Toggle verbose tool output" },
17
17
  { command: "stop", description: "Stop the current agent run" },
18
18
  { command: "restart", description: "Restart agent process" },
19
+ { command: "update", description: "Update roundhouse and restart" },
19
20
  { command: "status", description: "Show system status" },
20
21
  { command: "doctor", description: "Run diagnostics" },
21
22
  { command: "crons", description: "List scheduled cron jobs" },
package/src/gateway.ts CHANGED
@@ -48,7 +48,6 @@ function isCommandWithArgs(text: string, cmd: string): boolean {
48
48
  return suffix.toLowerCase() === _botUsername.toLowerCase();
49
49
  }
50
50
  import { hostname, loadavg, totalmem, freemem, cpus } from "node:os";
51
- import { homedir } from "node:os";
52
51
 
53
52
  /** Get system resource info */
54
53
  function getSystemResources() {
@@ -478,6 +477,35 @@ export class Gateway {
478
477
  return;
479
478
  }
480
479
 
480
+ // Handle /update command — update roundhouse then restart
481
+ if (isCommand(userText.trim(), "/update")) {
482
+ if (allowedUsers.length === 0 && allowedUserIds.length === 0) {
483
+ await thread.post("⚠️ /update requires an allowlist to be configured.");
484
+ return;
485
+ }
486
+ console.log(`[roundhouse] /update requested by @${authorName} in thread=${thread.id}`);
487
+ const progress = await createProgressMessage(thread, "📦 Checking for updates...");
488
+ try {
489
+ const { performUpdate } = await import("./commands/update");
490
+ const result = await performUpdate(progress);
491
+ if (result.action === "already-latest") {
492
+ await progress.update(`✅ Already on latest (v${result.currentVersion})`);
493
+ } else if (result.action === "updated") {
494
+ await progress.update(`✅ Updated v${result.currentVersion} → v${result.latestVersion}. Restarting...`);
495
+ console.log(`[roundhouse] updated ${result.currentVersion} -> ${result.latestVersion}, restarting`);
496
+ setTimeout(async () => {
497
+ try { await this.stop(); } catch (e) { console.error("[roundhouse] stop error:", e); }
498
+ process.exit(75);
499
+ }, 1500);
500
+ }
501
+ } catch (err) {
502
+ const msg = err instanceof Error ? err.message : String(err);
503
+ await progress.update(`⚠️ Update failed: ${msg.slice(0, 200)}`);
504
+ console.error(`[roundhouse] /update failed:`, msg);
505
+ }
506
+ return;
507
+ }
508
+
481
509
  // Handle /compact command — flush memory then compact session context
482
510
  // Routed through the per-thread lock to prevent concurrent agent access
483
511
  if (isCommand(userText.trim(), "/compact")) {