@inceptionstack/roundhouse 0.3.17 → 0.3.18

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.17",
3
+ "version": "0.3.18",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "chat": "^4.26.0",
43
43
  "croner": "^10.0.1",
44
44
  "p-queue": "^9.2.0",
45
+ "qrcode-terminal": "^0.12.0",
45
46
  "tsx": "^4.0.0"
46
47
  },
47
48
  "devDependencies": {
@@ -1,33 +1,126 @@
1
1
  /**
2
2
  * agents/registry.ts — Agent adapter registry
3
3
  *
4
- * Maps agent type names to their factory functions.
5
- * Add new agents here.
4
+ * Maps agent type names to their definitions including factory, install
5
+ * requirements, config defaults, and doctor checks.
6
6
  */
7
7
 
8
8
  import type { AgentAdapterFactory } from "../types";
9
9
  import { createPiAgentAdapter } from "./pi";
10
+ import { homedir } from "node:os";
11
+ import { resolve } from "node:path";
10
12
 
11
- const registry = new Map<string, AgentAdapterFactory>();
12
- const sdkPackages = new Map<string, string>();
13
+ // ── Types ────────────────────────────────────────────
13
14
 
14
- registry.set("pi", createPiAgentAdapter);
15
- sdkPackages.set("pi", "@mariozechner/pi-coding-agent");
16
- // registry.set("kiro", createKiroAgentAdapter);
17
- // sdkPackages.set("kiro", "@kiro/...");
15
+ export interface AgentPackageRequirement {
16
+ /** Human-readable label (defaults to packageName) */
17
+ name?: string;
18
+ /** npm package to install */
19
+ packageName: string;
20
+ /** Install scope */
21
+ install: "global" | "local";
22
+ /** Executable that proves the package is installed */
23
+ binary?: string;
24
+ }
25
+
26
+ export interface AgentSetupContext {
27
+ provider: string;
28
+ model: string;
29
+ cwd: string;
30
+ force: boolean;
31
+ psst: boolean;
32
+ extensions: string[];
33
+ }
34
+
35
+ export interface AgentDefinition {
36
+ /** Stable config/CLI type, e.g. "pi" */
37
+ type: string;
38
+ /** Display name, e.g. "Pi" */
39
+ name: string;
40
+ /** Runtime adapter factory */
41
+ factory?: AgentAdapterFactory;
42
+ /** Can users select this today? */
43
+ available: boolean;
44
+ /** Packages setup should install */
45
+ packages: AgentPackageRequirement[];
46
+ /** Package used for version display */
47
+ sdkPackage?: string;
48
+ /** Default config merged into gatewayConfig.agent */
49
+ configDefaults: Record<string, unknown>;
50
+ /** Dirs to create during preflight */
51
+ configDirs?: string[];
52
+ /** Agent-specific config writer */
53
+ configure?: (ctx: AgentSetupContext) => Promise<void>;
54
+ /** Agent-specific extension installer */
55
+ installExtension?: (ext: string) => Promise<void>;
56
+ /** Agent-specific doctor checks (future: loaded dynamically by doctor runner) */
57
+ doctorChecks?: unknown[];
58
+ }
59
+
60
+ // ── Pi Definition ────────────────────────────────────
61
+
62
+
63
+ const piDefinition: AgentDefinition = {
64
+ type: "pi",
65
+ name: "Pi",
66
+ factory: createPiAgentAdapter,
67
+ available: true,
68
+ packages: [
69
+ {
70
+ name: "Pi coding agent",
71
+ packageName: "@mariozechner/pi-coding-agent",
72
+ install: "global",
73
+ binary: "pi",
74
+ },
75
+ ],
76
+ sdkPackage: "@mariozechner/pi-coding-agent",
77
+ configDefaults: {},
78
+ configDirs: [resolve(homedir(), ".pi", "agent")],
79
+ // configure and installExtension are set by setup.ts since they need
80
+ // setup-specific helpers (execOrFail, atomicWriteJson, etc.)
81
+ };
82
+
83
+ // ── Registry ─────────────────────────────────────────
84
+
85
+ const definitions = new Map<string, AgentDefinition>();
86
+ definitions.set("pi", piDefinition);
87
+
88
+ // Future:
89
+ // definitions.set("kiro", kiroDefinition);
90
+
91
+ // ── Public API ───────────────────────────────────────
92
+
93
+ export function getAgentDefinition(type: string): AgentDefinition {
94
+ const def = definitions.get(type);
95
+ if (!def) {
96
+ const available = listAvailableAgentTypes().join(", ");
97
+ throw new Error(`Unknown agent type "${type}". Available: ${available}`);
98
+ }
99
+ if (!def.available) {
100
+ throw new Error(`Agent type "${type}" is not yet available.`);
101
+ }
102
+ return def;
103
+ }
104
+
105
+ export function listAvailableAgentTypes(): string[] {
106
+ return [...definitions.values()].filter(d => d.available).map(d => d.type);
107
+ }
108
+
109
+ /** Check if an agent type is registered (for future plugin validation) */
110
+ export function isKnownAgentType(type: string): boolean {
111
+ return definitions.has(type);
112
+ }
18
113
 
114
+ /** Get the runtime adapter factory for an agent type */
19
115
  export function getAgentFactory(type: string): AgentAdapterFactory {
20
- const factory = registry.get(type);
21
- if (!factory) {
22
- const available = [...registry.keys()].join(", ");
23
- throw new Error(
24
- `Unknown agent type "${type}". Available: ${available}`
25
- );
116
+ const def = getAgentDefinition(type);
117
+ if (!def.factory) {
118
+ throw new Error(`Agent type "${type}" has no runtime adapter.`);
26
119
  }
27
- return factory;
120
+ return def.factory;
28
121
  }
29
122
 
30
123
  /** Get the npm package name for an agent type's SDK (for version display) */
31
124
  export function getAgentSdkPackage(type: string): string | undefined {
32
- return sdkPackages.get(type);
125
+ return definitions.get(type)?.sdkPackage;
33
126
  }
package/src/cli/qr.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * cli/qr.ts — Terminal QR code rendering.
3
+ * Wraps qrcode-terminal with graceful fallback.
4
+ */
5
+
6
+ export type QrMode = "auto" | "always" | "never";
7
+
8
+ /**
9
+ * Print a QR code to stdout if conditions allow.
10
+ * Falls back silently if the terminal can't render it.
11
+ */
12
+ export function printQr(url: string, mode: QrMode = "auto"): void {
13
+ if (mode === "never") return;
14
+ if (mode === "auto" && !process.stdout.isTTY) return;
15
+
16
+ try {
17
+ // Dynamic import to keep it optional
18
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
19
+ const qrcode = require("qrcode-terminal") as { generate: (text: string, opts: { small: boolean }, cb?: (code: string) => void) => void };
20
+ qrcode.generate(url, { small: true });
21
+ } catch {
22
+ // Package not available — skip silently
23
+ }
24
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * cli/setup-logger.ts — Structured logging for setup.
3
+ *
4
+ * Interactive mode: human-friendly text with step numbers and emoji.
5
+ * Headless mode: JSON lines for SSM/cloud-init/Docker log parsing.
6
+ */
7
+
8
+ export interface SetupLogger {
9
+ step(n: number, total: number, event: string, message: string, context?: Record<string, unknown>): void;
10
+ info(event: string, message: string, context?: Record<string, unknown>): void;
11
+ warn(event: string, message: string, context?: Record<string, unknown>): void;
12
+ error(event: string, message: string, context?: Record<string, unknown>): void;
13
+ ok(message: string): void;
14
+ fail(message: string): void;
15
+ }
16
+
17
+ const STEP_EMOJI = ["①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨", "⑩"];
18
+
19
+ function stepLabel(n: number): string {
20
+ return STEP_EMOJI[n - 1] ?? `(${n})`;
21
+ }
22
+
23
+ function ts(): string {
24
+ return new Date().toISOString();
25
+ }
26
+
27
+ /**
28
+ * Redact token-like strings from messages and context values.
29
+ * Catches patterns like "123456:AAH..." (Telegram bot tokens).
30
+ */
31
+ function redact(s: string): string {
32
+ return s.replace(/\d{8,}:[A-Za-z0-9_-]{20,}/g, (m) => {
33
+ const parts = m.split(":");
34
+ return `${parts[0].slice(0, 4)}...${parts[1]?.slice(-4) ?? ""}`;
35
+ });
36
+ }
37
+
38
+ function redactContext(ctx?: Record<string, unknown>): Record<string, unknown> | undefined {
39
+ if (!ctx) return undefined;
40
+ return JSON.parse(redact(JSON.stringify(ctx)));
41
+ }
42
+
43
+ export function createTextLogger(): SetupLogger {
44
+ return {
45
+ step(n, _total, _event, message) {
46
+ console.log(`\n${stepLabel(n)} ${redact(message)}`);
47
+ },
48
+ info(_event, message) {
49
+ console.log(` ${redact(message)}`);
50
+ },
51
+ warn(_event, message) {
52
+ console.log(` ⚠ ${redact(message)}`);
53
+ },
54
+ error(_event, message) {
55
+ console.error(` ❌ ${redact(message)}`);
56
+ },
57
+ ok(message) {
58
+ console.log(` ✓ ${redact(message)}`);
59
+ },
60
+ fail(message) {
61
+ console.error(` ✗ ${redact(message)}`);
62
+ },
63
+ };
64
+ }
65
+
66
+ export function createJsonLogger(): SetupLogger {
67
+ function emit(level: string, event: string, message: string, extra?: Record<string, unknown>) {
68
+ const line: Record<string, unknown> = {
69
+ ts: ts(),
70
+ level,
71
+ event,
72
+ message: redact(message),
73
+ ...redactContext(extra),
74
+ };
75
+ console.log(JSON.stringify(line));
76
+ }
77
+
78
+ return {
79
+ step(n, total, event, message, context) {
80
+ emit("info", event, message, { step: n, total, ...context });
81
+ },
82
+ info(event, message, context) {
83
+ emit("info", event, message, context);
84
+ },
85
+ warn(event, message, context) {
86
+ emit("warn", event, message, context);
87
+ },
88
+ error(event, message, context) {
89
+ emit("error", event, message, context);
90
+ },
91
+ ok(message) {
92
+ emit("info", "ok", message);
93
+ },
94
+ fail(message) {
95
+ emit("error", "fail", message);
96
+ },
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Collect diagnostic info for error output.
102
+ */
103
+ export interface SetupDiagnostics {
104
+ node: string;
105
+ platform: string;
106
+ arch: string;
107
+ cwd: string;
108
+ roundhouseDir: string;
109
+ configExists: boolean;
110
+ envExists: boolean;
111
+ pairingStatus: string;
112
+ serviceState: string;
113
+ error: { name: string; message: string; stack?: string };
114
+ }
115
+
116
+ export function printDiagnosticError(diag: SetupDiagnostics, headless: boolean): void {
117
+ if (headless) {
118
+ console.error(JSON.stringify({
119
+ ts: ts(),
120
+ level: "error",
121
+ event: "setup.failed",
122
+ diagnostics: {
123
+ ...diag,
124
+ error: {
125
+ ...diag.error,
126
+ message: redact(diag.error.message),
127
+ stack: diag.error.stack ? redact(diag.error.stack) : undefined,
128
+ },
129
+ },
130
+ }));
131
+ } else {
132
+ console.error(`\n━━━━━━━━━━━━━━━━━━━`);
133
+ console.error(`❌ Setup failed: ${redact(diag.error.message)}`);
134
+ console.error(`\nDiagnostics:`);
135
+ console.error(` Node: ${diag.node}`);
136
+ console.error(` Platform: ${diag.platform} ${diag.arch}`);
137
+ console.error(` Config: ${diag.configExists ? "exists" : "missing"}`);
138
+ console.error(` Env file: ${diag.envExists ? "exists" : "missing"}`);
139
+ console.error(` Pairing: ${diag.pairingStatus}`);
140
+ console.error(` Service: ${diag.serviceState}`);
141
+ }
142
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * cli/setup-prompts.ts — Interactive prompts using only Node built-in readline.
3
+ * No external dependencies.
4
+ */
5
+ import { createInterface, Interface } from "node:readline";
6
+
7
+ /**
8
+ * Prompt the user for text input with optional default.
9
+ */
10
+ export async function promptText(
11
+ question: string,
12
+ options?: { defaultValue?: string },
13
+ ): Promise<string> {
14
+ const suffix = options?.defaultValue ? ` (${options.defaultValue})` : "";
15
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
16
+ try {
17
+ return await new Promise<string>((resolve, reject) => {
18
+ let settled = false;
19
+ rl.question(`${question}${suffix}: `, (answer) => {
20
+ settled = true;
21
+ resolve(answer.trim() || options?.defaultValue || "");
22
+ });
23
+ rl.on("close", () => { if (!settled) reject(new Error("Input cancelled")); });
24
+ });
25
+ } finally {
26
+ rl.close();
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Prompt for secret input — characters are not echoed to the terminal.
32
+ * Uses readline with _writeToOutput override to suppress echo.
33
+ */
34
+ export async function promptMasked(question: string): Promise<string> {
35
+ const rl = createInterface({
36
+ input: process.stdin,
37
+ output: process.stdout,
38
+ terminal: true,
39
+ });
40
+
41
+ // Suppress character echo after the prompt is written
42
+ let prompted = false;
43
+ const originalWrite = (rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput;
44
+ (rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = function (s: string) {
45
+ if (!prompted) {
46
+ originalWrite.call(rl, s);
47
+ if (s.includes(question)) prompted = true;
48
+ }
49
+ // After prompt is shown, swallow all output (no echo)
50
+ };
51
+
52
+ try {
53
+ return await new Promise<string>((resolve, reject) => {
54
+ let settled = false;
55
+ rl.question(`${question}: `, (answer) => {
56
+ settled = true;
57
+ process.stdout.write("\n");
58
+ resolve(answer.trim());
59
+ });
60
+ rl.on("close", () => { if (!settled) reject(new Error("Input cancelled")); });
61
+ });
62
+ } finally {
63
+ rl.close();
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Prompt for yes/no confirmation. Returns true for yes.
69
+ */
70
+ export async function promptConfirm(
71
+ question: string,
72
+ options?: { defaultYes?: boolean },
73
+ ): Promise<boolean> {
74
+ const hint = options?.defaultYes ? "[Y/n]" : "[y/N]";
75
+ const answer = await promptText(`${question} ${hint}`);
76
+ if (!answer) return !!options?.defaultYes;
77
+ return answer.toLowerCase().startsWith("y");
78
+ }
@@ -24,12 +24,12 @@ export interface PairResult {
24
24
 
25
25
  // ── API helper ───────────────────────────────────────
26
26
 
27
- function redactToken(token: string): string {
27
+ export function redactToken(token: string): string {
28
28
  if (token.length < 10) return "***";
29
29
  return token.slice(0, 4) + "..." + token.slice(-4);
30
30
  }
31
31
 
32
- async function telegramApi(
32
+ export async function telegramApi(
33
33
  token: string,
34
34
  method: string,
35
35
  params?: Record<string, unknown>,
@@ -78,6 +78,9 @@ export async function sendMessage(token: string, chatId: number, text: string):
78
78
  /**
79
79
  * Pair with a Telegram user — wait for them to send /start <nonce>.
80
80
  * Returns the user's chat ID and numeric user ID, or null on timeout.
81
+ *
82
+ * If opts.nonce is provided, use it instead of generating a new one.
83
+ * If opts.showLink is false, skip printing the pairing instructions.
81
84
  */
82
85
  export async function pairTelegram(
83
86
  token: string,
@@ -85,8 +88,9 @@ export async function pairTelegram(
85
88
  allowedUsers: string[],
86
89
  timeoutMs = 300_000,
87
90
  log: (msg: string) => void = console.log,
91
+ opts?: { nonce?: string; showLink?: boolean },
88
92
  ): Promise<PairResult | null> {
89
- const nonce = `rh-${randomBytes(3).toString("hex")}`;
93
+ const nonce = opts?.nonce ?? `rh-${randomBytes(8).toString("hex")}`;
90
94
  const normalizedUsers = allowedUsers.map((u) => u.replace(/^@/, "").toLowerCase());
91
95
 
92
96
  // Clear stale updates — advance offset past existing
@@ -101,8 +105,11 @@ export async function pairTelegram(
101
105
  // If getUpdates fails, start from 0
102
106
  }
103
107
 
104
- log(` Open https://t.me/${botUsername} and send: /start ${nonce}`);
105
- log(` Waiting... (Ctrl+C to skip)\n`);
108
+ if (opts?.showLink !== false) {
109
+ log(` Open https://t.me/${botUsername}?start=${nonce} and tap Start`);
110
+ log(` Or send /start ${nonce} to @${botUsername}`);
111
+ log(` Waiting... (Ctrl+C to skip)\n`);
112
+ }
106
113
 
107
114
  const deadline = Date.now() + timeoutMs;
108
115
  while (Date.now() < deadline) {