@inceptionstack/roundhouse 0.3.29 → 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 +24 -0
- package/architecture.md +3 -1
- package/package.json +8 -2
- package/pi/config/mcporter.json +33 -0
- package/pi/extensions/web-search.ts +99 -0
- package/src/bundle.ts +195 -0
- package/src/cli/cli.ts +12 -3
- package/src/cli/setup.ts +29 -1
- package/src/cli/systemd.ts +1 -1
- package/src/commands/update.ts +69 -0
- package/src/gateway.ts +11 -25
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
|
+
"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
|
-
|
|
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(
|
|
161
|
-
|
|
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
|
|
package/src/cli/systemd.ts
CHANGED
|
@@ -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/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() {
|
|
@@ -487,31 +486,18 @@ export class Gateway {
|
|
|
487
486
|
console.log(`[roundhouse] /update requested by @${authorName} in thread=${thread.id}`);
|
|
488
487
|
const progress = await createProgressMessage(thread, "📦 Checking for updates...");
|
|
489
488
|
try {
|
|
490
|
-
const {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
return;
|
|
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);
|
|
502
500
|
}
|
|
503
|
-
await progress.update(`📦 Updating v${currentVersion} → v${latestVersion}...`);
|
|
504
|
-
execSync("npm install -g @inceptionstack/roundhouse@latest 2>&1", {
|
|
505
|
-
timeout: 120_000,
|
|
506
|
-
encoding: "utf8",
|
|
507
|
-
});
|
|
508
|
-
await progress.update(`✅ Updated v${currentVersion} → v${latestVersion}. Restarting...`);
|
|
509
|
-
console.log(`[roundhouse] updated ${currentVersion} -> ${latestVersion}, restarting`);
|
|
510
|
-
// Exit so systemd restarts with new code
|
|
511
|
-
setTimeout(async () => {
|
|
512
|
-
try { await this.stop(); } catch (e) { console.error("[roundhouse] stop error:", e); }
|
|
513
|
-
process.exit(75);
|
|
514
|
-
}, 1500);
|
|
515
501
|
} catch (err) {
|
|
516
502
|
const msg = err instanceof Error ? err.message : String(err);
|
|
517
503
|
await progress.update(`⚠️ Update failed: ${msg.slice(0, 200)}`);
|