@inceptionstack/roundhouse 0.4.3 → 0.4.5

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/src/bundle.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { homedir } from "node:os";
9
9
  import { resolve, dirname } from "node:path";
10
- import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync } from "node:fs";
10
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, copyFileSync } from "node:fs";
11
11
  import { execFileSync } from "node:child_process";
12
12
  import { randomBytes } from "node:crypto";
13
13
  import { fileURLToPath } from "node:url";
@@ -192,14 +192,54 @@ export function provisionBundle(opts: ProvisionOpts = {}): void {
192
192
  provisionPlaywright(opts);
193
193
  provisionUvx(opts);
194
194
  provisionMcporterConfig(opts);
195
+ provisionExtensionFiles(opts);
195
196
  provisionExtensions(opts);
196
197
  }
197
198
 
199
+ /**
200
+ * Copy shipped extension files to ~/.pi/agent/extensions/ if not already present.
201
+ * Does NOT overwrite existing files — user's version always wins.
202
+ */
203
+ export function provisionExtensionFiles(opts: ProvisionOpts = {}): void {
204
+ const { log = consoleLog } = opts;
205
+ const shippedDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "pi", "extensions");
206
+ const targetDir = resolve(homedir(), ".pi", "agent", "extensions");
207
+
208
+ try {
209
+ if (!existsSync(shippedDir)) return;
210
+ const files = readdirSync(shippedDir).filter(f => f.endsWith(".ts"));
211
+ if (files.length === 0) return;
212
+
213
+ mkdirSync(targetDir, { recursive: true });
214
+
215
+ let installed = 0;
216
+ let skipped = 0;
217
+ for (const file of files) {
218
+ const target = resolve(targetDir, file);
219
+ if (existsSync(target)) {
220
+ skipped++;
221
+ } else {
222
+ copyFileSync(resolve(shippedDir, file), target);
223
+ installed++;
224
+ }
225
+ }
226
+
227
+ if (installed > 0) {
228
+ log.ok(`${installed} extension(s) installed to ~/.pi/agent/extensions/`);
229
+ }
230
+ if (skipped > 0 && installed === 0) {
231
+ log.ok(`extensions (${skipped} already present, not overwritten)`);
232
+ }
233
+ } catch (err: any) {
234
+ log.warn(`extension file provisioning failed: ${err.message}`);
235
+ }
236
+ }
237
+
198
238
  /**
199
239
  * Ensure core extensions are listed in ~/.pi/agent/settings.json packages array.
200
240
  */
201
241
  export function provisionExtensions(opts: ProvisionOpts = {}): void {
202
- const { log = defaultLog } = opts;
242
+ const { log = consoleLog } = opts;
203
243
  const settingsPath = resolve(homedir(), ".pi", "agent", "settings.json");
204
244
 
205
245
  const coreExtensions = [
package/src/cli/cli.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  } from "../config";
25
25
  import { getAgentSdkPackage } from "../agents/registry";
26
26
  import { threadIdToDir } from "../util";
27
- import { parseEnvFile, serializeEnvFile, envQuote } from "./env-file";
27
+ import { parseEnvFile, serializeEnvFile, envQuote, unquoteEnvValue } from "./env-file";
28
28
  import {
29
29
  SERVICE_PATH,
30
30
  systemctl,
@@ -72,14 +72,33 @@ async function cmdStart() {
72
72
  return;
73
73
  }
74
74
 
75
- // No systemd service — fall back to foreground
75
+ // No systemd service — fall back to foreground. Check config before launching.
76
+ if (!(await fileExists(CONFIG_PATH))) {
77
+ console.error("No config found. Run 'roundhouse setup --telegram' first.");
78
+ process.exit(1);
79
+ }
80
+
76
81
  console.log("No systemd service found. Running in foreground (use Ctrl+C to stop)...");
77
- console.log(" Tip: run 'roundhouse install' to set up the systemd daemon.\n");
82
+ if (process.platform !== "darwin") {
83
+ console.log(" Tip: run 'roundhouse install' to set up the systemd daemon.\n");
84
+ } else {
85
+ console.log("");
86
+ }
78
87
  await cmdRun();
79
88
  }
80
89
 
81
90
  async function cmdRun() {
91
+ // Guard: check config exists before launching gateway
92
+ if (!(await fileExists(CONFIG_PATH))) {
93
+ console.error("No config found. Run 'roundhouse setup --telegram' first.");
94
+ process.exit(1);
95
+ }
96
+
82
97
  process.env.ROUNDHOUSE_CONFIG = CONFIG_PATH;
98
+
99
+ // Load .env file so secrets (TELEGRAM_BOT_TOKEN, etc.) are available
100
+ await loadEnvFile();
101
+
83
102
  const indexPath = resolve(__dirname, "..", "index.ts");
84
103
  const jsPath = resolve(__dirname, "..", "dist", "index.js");
85
104
 
@@ -94,7 +113,35 @@ async function cmdRun() {
94
113
  }
95
114
  }
96
115
 
116
+ /**
117
+ * Load the roundhouse .env file into process.env.
118
+ * Does NOT override existing env vars (explicit env takes precedence).
119
+ */
120
+ async function loadEnvFile(): Promise<void> {
121
+ const envPath = await resolveEnvFilePath();
122
+ if (!(await fileExists(envPath))) return;
123
+ try {
124
+ const entries = parseEnvFile(await readFile(envPath, "utf8"));
125
+ for (const [key, raw] of entries) {
126
+ if (!process.env[key]) {
127
+ process.env[key] = unquoteEnvValue(raw);
128
+ }
129
+ }
130
+ } catch (e: any) {
131
+ console.warn(`[roundhouse] warning: failed to load ${envPath}: ${e.message}`);
132
+ }
133
+ }
134
+
97
135
  async function cmdInstall() {
136
+ if (process.platform === "darwin") {
137
+ console.log("[roundhouse] macOS detected — systemd is not available.\n");
138
+ console.log(" On macOS, use 'roundhouse start' to run in foreground,");
139
+ console.log(" or set up a launchd plist manually.\n");
140
+ console.log(" Tip: run 'roundhouse setup --telegram' to configure first.");
141
+ process.exitCode = 1;
142
+ return;
143
+ }
144
+
98
145
  console.log("[roundhouse] Installing as systemd daemon...\n");
99
146
 
100
147
  await mkdir(CONFIG_DIR, { recursive: true });
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { readFile } from "node:fs/promises";
7
- import { parseEnvFile } from "../../env-file";
7
+ import { parseEnvFile, unquoteEnvValue } from "../../env-file";
8
8
  import type { DoctorCheck, DoctorContext } from "../types";
9
9
 
10
10
  /**
@@ -17,7 +17,7 @@ export async function resolveToken(ctx: DoctorContext): Promise<string | null> {
17
17
  try {
18
18
  const entries = parseEnvFile(await readFile(ctx.envFilePath, "utf8"));
19
19
  const raw = entries.get("TELEGRAM_BOT_TOKEN");
20
- if (raw) token = raw.replace(/^["']|["']$/g, "");
20
+ if (raw) token = unquoteEnvValue(raw);
21
21
  } catch {}
22
22
  }
23
23
  return token || null;
@@ -38,3 +38,28 @@ export function envQuote(value: string): string {
38
38
  .replace(/\n/g, "\\n");
39
39
  return `"${escaped}"`;
40
40
  }
41
+
42
+ /**
43
+ * Reverse of envQuote: strip surrounding quotes and unescape interior sequences.
44
+ */
45
+ export function unquoteEnvValue(raw: string): string {
46
+ // Strip surrounding double quotes (only if matched pair)
47
+ let value = raw;
48
+ if (value.startsWith('"') && value.endsWith('"')) {
49
+ value = value.slice(1, -1);
50
+ } else if (value.startsWith("'") && value.endsWith("'")) {
51
+ value = value.slice(1, -1);
52
+ }
53
+ // Single-pass unescape to handle \\ correctly (avoids double-replacement)
54
+ value = value.replace(/\\([\\"$`n])/g, (_match, ch) => {
55
+ switch (ch) {
56
+ case "n": return "\n";
57
+ case "\\": return "\\";
58
+ case '"': return '"';
59
+ case "$": return "$";
60
+ case "`": return "`";
61
+ default: return ch;
62
+ }
63
+ });
64
+ return value;
65
+ }
package/src/cli/setup.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  ENV_FILE_PATH as ENV_PATH,
23
23
  fileExists,
24
24
  } from "../config";
25
- import { envQuote, parseEnvFile } from "./env-file";
25
+ import { envQuote, parseEnvFile, unquoteEnvValue } from "./env-file";
26
26
  import {
27
27
  whichSync,
28
28
  systemctl,
@@ -132,10 +132,12 @@ function resolveAgentForSetup(opts: SetupOptions): AgentDefinition {
132
132
  // Ensure packages array exists
133
133
  if (!Array.isArray(settings.packages)) settings.packages = [];
134
134
 
135
- // Add roundhouse itself (ships extensions via pi.extensions in package.json)
136
- const selfPkg = "npm:@inceptionstack/roundhouse";
137
135
  const pkgs = settings.packages as string[];
138
- if (!pkgs.includes(selfPkg)) pkgs.push(selfPkg);
136
+
137
+ // Remove stale self-reference (roundhouse no longer ships pi extensions)
138
+ const selfPkg = "npm:@inceptionstack/roundhouse";
139
+ const selfIdx = pkgs.indexOf(selfPkg);
140
+ if (selfIdx !== -1) pkgs.splice(selfIdx, 1);
139
141
 
140
142
  // Add code review + branch protection extensions
141
143
  const coreExtensions = [
@@ -441,7 +443,7 @@ async function stepValidateToken(opts: SetupOptions): Promise<BotInfo> {
441
443
  }
442
444
 
443
445
  async function stepStopGateway(): Promise<void> {
444
- step("", "Checking for running gateway...");
446
+ step("", "Checking for running gateway...");
445
447
 
446
448
  if (platform() !== "linux") {
447
449
  ok("Not Linux — skipping service check");
@@ -462,7 +464,7 @@ async function stepStopGateway(): Promise<void> {
462
464
  }
463
465
 
464
466
  async function stepInstallPackages(opts: SetupOptions, agent: AgentDefinition): Promise<void> {
465
- step("", "Installing packages...");
467
+ step("", "Installing packages...");
466
468
 
467
469
  // Roundhouse
468
470
  const rhInstalled = whichSync("roundhouse");
@@ -594,12 +596,12 @@ async function stepInstallPackages(opts: SetupOptions, agent: AgentDefinition):
594
596
 
595
597
  async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<void> {
596
598
  if (!opts.psst) {
597
- step("", "Storing secrets...");
599
+ step("", "Storing secrets...");
598
600
  ok("Skipped (default — use --with-psst to enable)");
599
601
  return;
600
602
  }
601
603
 
602
- step("", "Storing secrets in psst...");
604
+ step("", "Storing secrets in psst...");
603
605
 
604
606
  const secrets: [string, string][] = [
605
607
  ["TELEGRAM_BOT_TOKEN", opts.botToken],
@@ -638,7 +640,7 @@ async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<v
638
640
  // ── Bundle install ──────────────────────────────────────────────────
639
641
 
640
642
  async function stepInstallBundle(opts: SetupOptions): Promise<void> {
641
- step("⑥b", "Installing bundle (skills + CLI tools)...");
643
+ step("⑥", "Installing bundle (skills + CLI tools)...");
642
644
 
643
645
  const bundleLog: ProvisionLog = {
644
646
  info: (msg) => log(` ${msg}`),
@@ -655,7 +657,7 @@ async function stepConfigure(
655
657
  pairResult: PairResult | null,
656
658
  agent: AgentDefinition,
657
659
  ): Promise<void> {
658
- step("", "Configuring...");
660
+ step("", "Configuring...");
659
661
 
660
662
  await mkdir(ROUNDHOUSE_DIR, { recursive: true });
661
663
 
@@ -764,7 +766,7 @@ async function stepConfigure(
764
766
  }
765
767
 
766
768
  async function stepPair(opts: SetupOptions, botInfo: BotInfo): Promise<PairResult | null> {
767
- step("", "Pairing with Telegram...");
769
+ step("", "Pairing with Telegram...");
768
770
 
769
771
  // Skip if chat IDs already known
770
772
  if (opts.notifyChatIds.length > 0) {
@@ -820,13 +822,13 @@ async function stepPair(opts: SetupOptions, botInfo: BotInfo): Promise<PairResul
820
822
  }
821
823
 
822
824
  async function stepRegisterCommands(opts: SetupOptions): Promise<void> {
823
- step("", "Registering bot commands...");
825
+ step("", "Registering bot commands...");
824
826
  await registerBotCommands(opts.botToken);
825
827
  ok(`${BOT_COMMANDS.length} commands registered with Telegram`);
826
828
  }
827
829
 
828
830
  async function stepInstallSystemd(opts: SetupOptions): Promise<void> {
829
- step("", "Installing systemd service...");
831
+ step("⑩b", "Installing systemd service...");
830
832
 
831
833
  if (!opts.systemd) {
832
834
  ok("Skipped (--no-systemd)");
@@ -871,7 +873,7 @@ async function stepInstallSystemd(opts: SetupOptions): Promise<void> {
871
873
  }
872
874
 
873
875
  async function stepPostflight(): Promise<void> {
874
- step("", "Postflight checks...");
876
+ step("", "Postflight checks...");
875
877
 
876
878
  if (platform() === "linux") {
877
879
  if (isServiceActive()) {
@@ -892,6 +894,11 @@ async function stepPostflight(): Promise<void> {
892
894
  if (!whichSync("ffmpeg")) {
893
895
  warn("ffmpeg not found (install for voice support)");
894
896
  }
897
+
898
+ if (!process.env.TAVILY_API_KEY) {
899
+ warn("TAVILY_API_KEY not set — web search extension won't work");
900
+ log(" Get a free key at https://tavily.com and add to ~/.roundhouse/.env");
901
+ }
895
902
  }
896
903
 
897
904
  // ── BotFather Guide ──────────────────────────────────
@@ -949,11 +956,11 @@ async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
949
956
  // Step 5: Install packages
950
957
  await stepInstallPackages(opts, agent);
951
958
 
952
- // Step 5b: Install bundle (skills + CLI tools)
959
+ // Step 6: Install bundle (skills + CLI tools)
953
960
  await stepInstallBundle(opts);
954
961
 
955
- // Step 6: Pair via Telegram
956
- step("", "Pairing with Telegram...");
962
+ // Step 7: Pair via Telegram
963
+ step("", "Pairing with Telegram...");
957
964
  const nonce = createPairingNonce();
958
965
  const pairingLink = createPairingLink(botInfo.username, nonce);
959
966
  log(`\n Open this link to pair:\n`);
@@ -962,6 +969,16 @@ async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
962
969
  log(` Or send /start ${nonce} to @${botInfo.username}`);
963
970
  log("");
964
971
 
972
+ // Auto-open the pairing link on macOS
973
+ if (process.platform === "darwin") {
974
+ try {
975
+ execFileSync("open", [pairingLink], { stdio: "ignore" });
976
+ log(" (Opened in Telegram — switch to the app to complete pairing)");
977
+ } catch { /* ignore if open fails */ }
978
+ }
979
+
980
+ log(" Waiting for you to tap the link in Telegram...");
981
+
965
982
  const pairResult = await pairTelegram(
966
983
  opts.botToken, botInfo.username, opts.users,
967
984
  300_000, log, { nonce, showLink: false },
@@ -975,17 +992,17 @@ async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
975
992
  }
976
993
  }
977
994
 
978
- // Step 7: Store secrets
995
+ // Step 8: Store secrets
979
996
  await stepStoreSecrets(opts, botInfo);
980
997
 
981
- // Step 8: Write config
998
+ // Step 9: Write config
982
999
  await stepConfigure(opts, botInfo, pairResult, agent);
983
1000
 
984
- // Step 9: Register commands + install service
1001
+ // Step 10: Register commands + install service
985
1002
  await stepRegisterCommands(opts);
986
1003
  await stepInstallSystemd(opts);
987
1004
 
988
- // Step 10: Verify
1005
+ // Step 11: Verify
989
1006
  await stepPostflight();
990
1007
 
991
1008
  // Done!
@@ -1255,7 +1272,7 @@ export async function cmdPair(argv: string[]): Promise<void> {
1255
1272
  try {
1256
1273
  const entries = parseEnvFile(await readFile(ENV_PATH, "utf8"));
1257
1274
  const raw = entries.get("TELEGRAM_BOT_TOKEN");
1258
- if (raw) token = raw.replace(/^["']|["']$/g, "");
1275
+ if (raw) token = unquoteEnvValue(raw);
1259
1276
  } catch {}
1260
1277
  }
1261
1278
 
package/src/config.ts CHANGED
@@ -8,8 +8,21 @@
8
8
  import { homedir } from "node:os";
9
9
  import { resolve } from "node:path";
10
10
  import { readFile, access } from "node:fs/promises";
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
11
14
  import type { GatewayConfig } from "./types";
12
15
 
16
+ // ── Version ──────────────────────────────────────────
17
+
18
+ const __configDir = dirname(fileURLToPath(import.meta.url));
19
+
20
+ /** Roundhouse package version (read from package.json at startup) */
21
+ export const ROUNDHOUSE_VERSION: string = (() => {
22
+ try { return JSON.parse(readFileSync(join(__configDir, "..", "package.json"), "utf8")).version; }
23
+ catch { return "unknown"; }
24
+ })();
25
+
13
26
  // ── Path constants ───────────────────────────────────
14
27
 
15
28
  /** New canonical config root */
package/src/gateway.ts CHANGED
@@ -13,7 +13,7 @@ import { isTelegramThread, postTelegramHtml, handleTelegramHtmlStream } from "./
13
13
  import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from "./voice/stt-service";
14
14
  import { sendTelegramToMany } from "./notify/telegram";
15
15
  import { runDoctor, formatDoctorTelegram, createDoctorContext } from "./cli/doctor/runner";
16
- import { ROUNDHOUSE_DIR } from "./config";
16
+ import { ROUNDHOUSE_DIR, ROUNDHOUSE_VERSION } from "./config";
17
17
  import { CronSchedulerService } from "./cron/scheduler";
18
18
  import { isBuiltinJob } from "./cron/helpers";
19
19
  import { formatSchedule, formatRunCounts, jobEnabledIcon } from "./cron/format";
@@ -65,10 +65,6 @@ import { join, dirname, basename } from "node:path";
65
65
  import { fileURLToPath } from "node:url";
66
66
 
67
67
  const __gatewayDir = dirname(fileURLToPath(import.meta.url));
68
- const ROUNDHOUSE_VERSION: string = (() => {
69
- try { return JSON.parse(readFileSync(join(__gatewayDir, "..", "package.json"), "utf8")).version; }
70
- catch { return "unknown"; }
71
- })();
72
68
 
73
69
  function telegramChatIdFromThreadId(threadId: unknown): number | null {
74
70
  if (typeof threadId !== "string") return null;
package/src/types.ts CHANGED
@@ -45,41 +45,87 @@ export type AgentStreamEvent =
45
45
  | { type: "agent_end" }
46
46
  | { type: "custom_message"; customType: string; content: string };
47
47
 
48
+ // ── AdapterInfo ──────────────────────────────────────
49
+
50
+ /**
51
+ * Information returned by getInfo(). All fields optional.
52
+ * Consumers (gateway /status, memory lifecycle) read these keys.
53
+ */
54
+ export interface AdapterInfo {
55
+ /** Agent SDK/CLI version string */
56
+ version?: string;
57
+ /** Currently active model identifier */
58
+ model?: string;
59
+ /** Working directory the agent operates in */
60
+ cwd?: string;
61
+ /** Number of active sessions managed by this adapter */
62
+ activeSessions?: number;
63
+
64
+ // ── Context usage (drives memory pressure detection) ─
65
+
66
+ /** Current token count in context */
67
+ contextTokens?: number | null;
68
+ /** Maximum context window size in tokens */
69
+ contextWindow?: number | null;
70
+ /** Percentage of context used (0-100) */
71
+ contextPercent?: number | null;
72
+
73
+ // ── Memory system integration ──────────────────────
74
+
75
+ /** Whether agent has its own memory extension (determines roundhouse memory mode) */
76
+ hasMemoryExtension?: boolean;
77
+ /** Names of memory-related tools the agent exposes */
78
+ memoryTools?: string[];
79
+
80
+ // ── Extensions / capabilities ──────────────────────
81
+
82
+ /** List of loaded extension paths/names */
83
+ extensions?: string[];
84
+
85
+ /** Additional adapter-specific fields */
86
+ [key: string]: unknown;
87
+ }
88
+
89
+ /**
90
+ * AgentAdapter interface — the contract between gateway and adapters.
91
+ *
92
+ * New adapters should extend BaseAdapter (from ./agents/base-adapter.ts)
93
+ * which provides default implementations for optional methods.
94
+ */
48
95
  export interface AgentAdapter {
49
96
  /** Unique agent name, e.g. "pi", "kiro" */
50
- name: string;
97
+ readonly name: string;
98
+
99
+ // ── Required ─────────────────────────────────────────
51
100
 
52
101
  /** Send a user message and return the full assistant response */
53
102
  prompt(threadId: string, message: AgentMessage): Promise<AgentResponse>;
54
103
 
55
- /**
56
- * Send a prompt using a specific model (for maintenance turns like memory flush).
57
- * Falls back to prompt() if not implemented or model unavailable.
58
- */
59
- promptWithModel?(threadId: string, message: AgentMessage, modelId: string): Promise<AgentResponse>;
104
+ /** Send a user message and stream back events in real time. */
105
+ promptStream(threadId: string, message: AgentMessage): AsyncIterable<AgentStreamEvent>;
106
+
107
+ /** Tear down all sessions and release resources. */
108
+ dispose(): Promise<void>;
109
+
110
+ // ── Optional (have defaults in BaseAdapter) ──────────
60
111
 
61
- /**
62
- * Send a user message and stream back events in real time.
63
- * Falls back to prompt() if not implemented.
64
- */
65
- promptStream?(threadId: string, message: AgentMessage): AsyncIterable<AgentStreamEvent>;
112
+ /** Prompt using a specific model (e.g. Haiku for memory flush). */
113
+ promptWithModel?(threadId: string, message: AgentMessage, modelId: string): Promise<AgentResponse>;
66
114
 
67
- /** Dispose the session for a thread and start fresh on next prompt */
115
+ /** Dispose the session for a thread and start fresh on next prompt. */
68
116
  restart?(threadId: string): Promise<void>;
69
117
 
70
- /** Compact the session context for a thread */
118
+ /** Compact the session context for a thread. */
71
119
  compact?(threadId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null>;
72
- /** Compact with a specific model (avoids restoring to default between flush and compact) */
120
+
121
+ /** Compact with a specific model. */
73
122
  compactWithModel?(threadId: string, modelId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null>;
74
123
 
75
- /** Abort the current agent run for a thread */
124
+ /** Abort the current agent run for a thread. */
76
125
  abort?(threadId: string): Promise<void>;
77
126
 
78
- /** Return runtime info about the agent (model, version, etc.) */
79
- getInfo?(threadId?: string): Record<string, unknown>;
80
-
81
- /** Tear down all sessions */
82
- dispose(): Promise<void>;
127
+ /** Return runtime info about the agent (model, version, etc.). */
128
+ getInfo?(threadId?: string): AdapterInfo;
83
129
  }
84
130
 
85
131
  export interface AgentResponse {