@inceptionstack/roundhouse 0.5.10 → 0.5.12

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.5.10",
3
+ "version": "0.5.12",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -40,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "@chat-adapter/state-memory": "^4.26.0",
42
42
  "@chat-adapter/telegram": "^4.26.0",
43
- "@mariozechner/pi-coding-agent": "^0.69.0",
43
+ "@earendil-works/pi-coding-agent": "^0.74.0",
44
44
  "chat": "^4.26.0",
45
45
  "croner": "^10.0.1",
46
46
  "p-queue": "^9.2.0",
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
 
4
4
  export default function (pi: ExtensionAPI) {
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { AgentMessage } from "../../types";
9
- import type { AgentSessionEvent } from "@mariozechner/pi-coding-agent";
9
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
10
10
 
11
11
  /**
12
12
  * Convert custom message content (string or array of parts) to plain text.
@@ -24,7 +24,7 @@ import {
24
24
  SessionManager,
25
25
  type AgentSession,
26
26
  type AgentSessionEvent,
27
- } from "@mariozechner/pi-coding-agent";
27
+ } from "@earendil-works/pi-coding-agent";
28
28
 
29
29
  import type { AgentAdapter, AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent, MessageContext } from "../../types";
30
30
  import { formatMessage, extractCustomMessage, customContentToText } from "./message-format";
@@ -66,7 +66,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
66
66
  //
67
67
  // WARNING: _agentEventQueue is a private field of AgentSession (not part
68
68
  // of the public pi-coding-agent API). Tested against
69
- // @mariozechner/pi-coding-agent version bundled via `latest` in
69
+ // @earendil-works/pi-coding-agent version bundled via `latest` in
70
70
  // package.json at the time of this commit. If upstream renames or changes
71
71
  // this field, extension custom messages (e.g. pi-lgtm review bubbles)
72
72
  // will stop reaching Telegram. The `if (queue)` check fails silently
@@ -558,7 +558,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
558
558
  // Read agent version
559
559
  let version = "unknown";
560
560
  try {
561
- const piPkgPath = join(__piAdapterDir, "..", "..", "..", "node_modules", "@mariozechner", "pi-coding-agent", "package.json");
561
+ const piPkgPath = join(__piAdapterDir, "..", "..", "..", "node_modules", "@earendil-works", "pi-coding-agent", "package.json");
562
562
  version = JSON.parse(readFileSync(piPkgPath, "utf8")).version;
563
563
  } catch {}
564
564
 
@@ -69,12 +69,12 @@ const piDefinition: AgentDefinition = {
69
69
  packages: [
70
70
  {
71
71
  name: "Pi coding agent",
72
- packageName: "@mariozechner/pi-coding-agent",
72
+ packageName: "@earendil-works/pi-coding-agent",
73
73
  install: "global",
74
74
  binary: "pi",
75
75
  },
76
76
  ],
77
- sdkPackage: "@mariozechner/pi-coding-agent",
77
+ sdkPackage: "@earendil-works/pi-coding-agent",
78
78
  configDefaults: {},
79
79
  configDirs: [resolve(homedir(), ".pi", "agent")],
80
80
  // configure and installExtension are set by setup.ts since they need
@@ -12,7 +12,7 @@ export const agentChecks: DoctorCheck[] = [
12
12
  {
13
13
  id: "pi-sdk", category: "agent", name: "Pi SDK",
14
14
  async run() {
15
- const PI_PKG = join("@mariozechner", "pi-coding-agent", "package.json");
15
+ const PI_PKG = join("@earendil-works", "pi-coding-agent", "package.json");
16
16
  const searchPaths = [
17
17
  join(process.cwd(), "node_modules", PI_PKG),
18
18
  ];
@@ -31,8 +31,8 @@ export const agentChecks: DoctorCheck[] = [
31
31
  }
32
32
  return {
33
33
  id: "pi-sdk", category: "agent", name: "Pi SDK", status: "fail" as const, summary: "not found",
34
- details: ["@mariozechner/pi-coding-agent not installed"],
35
- fix: { description: "Install pi SDK", command: "npm install @mariozechner/pi-coding-agent" },
34
+ details: ["@earendil-works/pi-coding-agent not installed"],
35
+ fix: { description: "Install pi SDK", command: "npm install -g @earendil-works/pi-coding-agent" },
36
36
  };
37
37
  },
38
38
  },
package/src/cli/update.ts CHANGED
@@ -26,16 +26,84 @@ export interface UpdateResult {
26
26
  error?: string;
27
27
  }
28
28
 
29
+ export async function updateExtensions(progress: UpdateProgress): Promise<void> {
30
+ for (const extensionPackage of GLOBAL_PI_EXTENSION_PACKAGES) {
31
+ try {
32
+ // Check if already at latest
33
+ const installed = execSync(`npm list -g ${extensionPackage} --json 2>/dev/null`, {
34
+ timeout: 10_000,
35
+ encoding: "utf8",
36
+ });
37
+ const installedVersion = JSON.parse(installed)?.dependencies?.[extensionPackage]?.version ?? "";
38
+ const latestExtVersion = execSync(`npm view ${extensionPackage} version 2>/dev/null`, {
39
+ timeout: 10_000,
40
+ encoding: "utf8",
41
+ }).trim();
42
+
43
+ if (installedVersion && installedVersion === latestExtVersion) {
44
+ await progress.update(`✅ ${extensionPackage} already at v${installedVersion}`);
45
+ continue;
46
+ }
47
+ await progress.update(`📦 Updating ${extensionPackage} v${installedVersion || "?"} → v${latestExtVersion}...`);
48
+ } catch {
49
+ await progress.update(`📦 Updating extension: ${extensionPackage}...`);
50
+ }
51
+
52
+ try {
53
+ execSync(`npm install -g ${extensionPackage}@latest 2>&1`, {
54
+ timeout: 60_000,
55
+ encoding: "utf8",
56
+ });
57
+ await progress.update(`✅ ${extensionPackage} updated`);
58
+ } catch (e) {
59
+ const msg = e instanceof Error ? e.message : String(e);
60
+ console.warn(`[roundhouse] failed to update extension ${extensionPackage}:`, msg);
61
+ await progress.update(`⚠️ Failed to update ${extensionPackage}: ${msg.slice(0, 150)}`);
62
+ }
63
+ }
64
+ }
65
+
66
+ export async function updateSelf(
67
+ progress: UpdateProgress,
68
+ currentVersion: string,
69
+ latestVersion: string,
70
+ ): Promise<string | undefined> {
71
+ await progress.update(`📦 Updating v${currentVersion} → v${latestVersion}...`);
72
+
73
+ try {
74
+ execSync("npm install -g @inceptionstack/roundhouse@latest 2>&1", {
75
+ timeout: 120_000,
76
+ encoding: "utf8",
77
+ });
78
+ return undefined;
79
+ } catch (e) {
80
+ const msg = e instanceof Error ? e.message : String(e);
81
+ console.warn("[roundhouse] self-update failed:", msg);
82
+ return `Self-update failed: ${msg}`;
83
+ }
84
+ }
85
+
86
+ export function patchPiSettings(): void {
87
+ try {
88
+ const settingsPath = `${homedir()}/.pi/agent/settings.json`;
89
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
90
+ const selfPkg = "npm:@inceptionstack/roundhouse";
91
+ if (!Array.isArray(settings.packages)) settings.packages = [];
92
+ if (!settings.packages.includes(selfPkg)) {
93
+ settings.packages.push(selfPkg);
94
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
95
+ }
96
+ } catch { /* settings.json may not exist yet — fine, setup will create it */ }
97
+ }
98
+
29
99
  /**
30
100
  * Check for updates, install if newer, provision bundle, patch settings.
31
101
  * Returns the result — caller decides how to present it and whether to restart.
32
102
  */
33
103
  export async function performUpdate(progress: UpdateProgress): Promise<UpdateResult> {
34
- // Get current version
35
104
  const pkg = await import("../../package.json", { with: { type: "json" } });
36
105
  const currentVersion = pkg.default?.version ?? "unknown";
37
106
 
38
- // Check latest version on npm
39
107
  let latestVersion: string;
40
108
  try {
41
109
  latestVersion = execSync("npm view @inceptionstack/roundhouse version 2>/dev/null", {
@@ -48,25 +116,10 @@ export async function performUpdate(progress: UpdateProgress): Promise<UpdateRes
48
116
  console.warn("[roundhouse] npm view failed:", e instanceof Error ? e.message : e);
49
117
  }
50
118
 
51
- // Always update extensions (even if roundhouse is already latest)
52
119
  if (!latestVersion) {
53
120
  await progress.update(`⚠️ Version check failed — updating extensions only`);
54
121
  }
55
- for (const extensionPackage of GLOBAL_PI_EXTENSION_PACKAGES) {
56
- await progress.update(`📦 Updating extension: ${extensionPackage}...`);
57
-
58
- try {
59
- execSync(`npm install -g ${extensionPackage}@latest 2>&1`, {
60
- timeout: 60_000,
61
- encoding: "utf8",
62
- });
63
- await progress.update(`✅ ${extensionPackage} updated`);
64
- } catch (e) {
65
- const msg = e instanceof Error ? e.message : String(e);
66
- console.warn(`[roundhouse] failed to update extension ${extensionPackage}:`, msg);
67
- await progress.update(`⚠️ Failed to update ${extensionPackage}: ${msg.slice(0, 150)}`);
68
- }
69
- }
122
+ await updateExtensions(progress);
70
123
 
71
124
  if (!latestVersion) {
72
125
  return { action: "error", currentVersion, error: "Version check failed (extensions updated)" };
@@ -75,37 +128,18 @@ export async function performUpdate(progress: UpdateProgress): Promise<UpdateRes
75
128
  return { action: "already-latest", currentVersion };
76
129
  }
77
130
 
78
- await progress.update(`📦 Updating v${currentVersion} → v${latestVersion}...`);
79
-
80
- try {
81
- execSync("npm install -g @inceptionstack/roundhouse@latest 2>&1", {
82
- timeout: 120_000,
83
- encoding: "utf8",
84
- });
85
- } catch (e) {
86
- const msg = e instanceof Error ? e.message : String(e);
87
- console.warn("[roundhouse] self-update failed:", msg);
88
- return { action: "error", currentVersion, error: `Self-update failed: ${msg}` };
131
+ const updateError = await updateSelf(progress, currentVersion, latestVersion);
132
+ if (updateError) {
133
+ return { action: "error", currentVersion, error: updateError };
89
134
  }
90
135
 
91
- // Provision bundle (skills sync + CLI tools + config)
92
136
  try {
93
137
  provisionBundle();
94
138
  } catch (e) {
95
139
  console.warn("[roundhouse] bundle provisioning failed:", e instanceof Error ? e.message : e);
96
140
  }
97
141
 
98
- // Ensure settings.json includes roundhouse package (for pre-bundle upgrades)
99
- try {
100
- const settingsPath = `${homedir()}/.pi/agent/settings.json`;
101
- const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
102
- const selfPkg = "npm:@inceptionstack/roundhouse";
103
- if (!Array.isArray(settings.packages)) settings.packages = [];
104
- if (!settings.packages.includes(selfPkg)) {
105
- settings.packages.push(selfPkg);
106
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
107
- }
108
- } catch { /* settings.json may not exist yet — fine, setup will create it */ }
142
+ patchPiSettings();
109
143
 
110
144
  return { action: "updated", currentVersion, latestVersion };
111
145
  }
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import { getAgentFactory } from "../agents/registry";
9
- import { sendTelegramToMany } from "../transports/telegram/notify";
10
9
  import { sendIpc } from "../ipc";
11
10
  import { CronStore, generateRunId } from "./store";
12
11
  import { buildTemplateContext, renderTemplate } from "./template";
@@ -20,7 +19,8 @@ export class CronRunner {
20
19
  constructor(
21
20
  private store: CronStore,
22
21
  private agentConfig?: GatewayConfig["agent"],
23
- private notifyFn?: (text: string) => Promise<void>,
22
+ private defaultChatIds?: number[],
23
+ private notifyFn?: (chatIds: number[], text: string) => Promise<void>,
24
24
  ) {}
25
25
 
26
26
  async runJob(
@@ -145,16 +145,21 @@ export class CronRunner {
145
145
  body = `${header}\nError: ${record.error.slice(0, NOTIFY_MAX_ERROR_CHARS)}`;
146
146
  }
147
147
 
148
+ const notify = this.notifyFn;
149
+
148
150
  // Route 1: Explicit Telegram chat IDs configured on the job
149
151
  if (tg?.chatIds?.length) {
150
- await sendTelegramToMany(tg.chatIds, body);
151
- return;
152
+ if (notify) {
153
+ await notify(tg.chatIds, body);
154
+ return;
155
+ }
152
156
  }
153
157
 
154
158
  // Route 2: Direct callback from gateway (no loopback socket)
155
- if (this.notifyFn) {
159
+ const defaultChatIds = this.defaultChatIds ?? [];
160
+ if (notify && defaultChatIds.length > 0) {
156
161
  try {
157
- await this.notifyFn(body);
162
+ await notify(defaultChatIds, body);
158
163
  } catch (err) {
159
164
  console.warn(`[cron] ${job.id} notify callback failed:`, (err as Error).message);
160
165
  }
@@ -41,9 +41,9 @@ export class CronSchedulerService {
41
41
  private lastHeartbeatAt = 0; // 0 = fires on first tick after startup (intentional catch-up)
42
42
  private tickMs: number;
43
43
 
44
- constructor(private opts?: { tickMs?: number; agentConfig?: GatewayConfig["agent"]; notifyChatIds?: number[]; notifyFn?: (text: string) => Promise<void> }) {
44
+ constructor(private opts?: { tickMs?: number; agentConfig?: GatewayConfig["agent"]; notifyChatIds?: number[]; notifyFn?: (chatIds: number[], text: string) => Promise<void> }) {
45
45
  this.store = new CronStore();
46
- this.runner = new CronRunner(this.store, this.opts?.agentConfig, this.opts?.notifyFn);
46
+ this.runner = new CronRunner(this.store, this.opts?.agentConfig, this.opts?.notifyChatIds, this.opts?.notifyFn);
47
47
  this.queue = new PQueue({ concurrency: 1 });
48
48
  this.tickMs = this.opts?.tickMs ?? TICK_MS;
49
49
  }
@@ -13,7 +13,7 @@ import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from
13
13
  import { runDoctor, formatDoctorTelegram, createDoctorContext } from "../cli/doctor/runner";
14
14
  import { ROUNDHOUSE_DIR, ROUNDHOUSE_VERSION } from "../config";
15
15
  import { CronSchedulerService } from "../cron/scheduler";
16
- import { IpcServer, type IpcRequest } from "../ipc";
16
+ import { IpcServer, createIpcHandler } from "../ipc";
17
17
  import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact } from "../memory/lifecycle";
18
18
  import { maxPressure } from "../memory/policy";
19
19
  import type { PressureLevel } from "../memory/types";
@@ -23,6 +23,8 @@ import { isCommand as _isCmd, isCommandWithArgs as _isCmdArgs, resolveAgentThrea
23
23
  import { saveAttachments as _saveAttachments, type AttachmentResult } from "./attachments";
24
24
  import { handleStreaming as _handleStream } from "./streaming";
25
25
  import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, handleStop, handleVerbose, handleDoctor, handleCrons, type CommandContext } from "./commands";
26
+ import { handleModel, handleModelAction, MODEL_ACTION_ID } from "./model-command";
27
+ import { handleLater } from "./later-command";
26
28
  import { TelegramAdapter } from "../transports";
27
29
  import type { TransportAdapter } from "../transports";
28
30
  import { hostname } from "node:os";
@@ -264,6 +266,18 @@ export class Gateway {
264
266
  return;
265
267
  }
266
268
 
269
+ // Handle /model command
270
+ if (isCommandWithArgs(userText.trim(), "/model") || isCommand(userText.trim(), "/model")) {
271
+ await handleModel({ thread, text: userText.trim(), postWithFallback: (t, txt) => this.postWithFallback(t, txt) });
272
+ return;
273
+ }
274
+
275
+ // Handle /later command
276
+ if (isCommandWithArgs(userText.trim(), "/later") || isCommand(userText.trim(), "/later")) {
277
+ await handleLater({ thread, text: userText.trim(), postWithFallback: (t, txt) => this.postWithFallback(t, txt) });
278
+ return;
279
+ }
280
+
267
281
  // Dispatch to agent turn handler
268
282
  await this.handleAgentTurn(thread, agentThreadId, userText, rawAttachments, verboseThreads, threadLocks, abortControllers);
269
283
  };
@@ -313,6 +327,11 @@ export class Gateway {
313
327
  await handleOrAbort(thread, message);
314
328
  });
315
329
 
330
+ // ── Handle inline keyboard callbacks ───
331
+ this.chat.onAction(MODEL_ACTION_ID, async (event: any) => {
332
+ await handleModelAction({ value: event.value, thread: event.thread });
333
+ });
334
+
316
335
  await this.chat.initialize();
317
336
 
318
337
  const platforms = Object.keys(this.config.chat.adapters).join(", ");
@@ -325,9 +344,8 @@ export class Gateway {
325
344
  this.cronScheduler = new CronSchedulerService({
326
345
  agentConfig: this.config.agent,
327
346
  notifyChatIds: this.config.chat.notifyChatIds,
328
- notifyFn: async (text: string) => {
329
- const chatIds = this.config.chat.notifyChatIds;
330
- if (chatIds?.length && this.transport) {
347
+ notifyFn: async (chatIds: number[], text: string) => {
348
+ if (chatIds.length && this.transport) {
331
349
  await this.transport.notify(chatIds, text);
332
350
  }
333
351
  },
@@ -339,34 +357,7 @@ export class Gateway {
339
357
  }
340
358
 
341
359
  // Start IPC server for CLI → gateway communication
342
- this.ipcServer = new IpcServer(async (req: IpcRequest) => {
343
- if (req.type === "ping") return { ok: true };
344
- if (req.type === "notify") {
345
- const allChatIds = this.config.chat.notifyChatIds ?? [];
346
- if (allChatIds.length === 0) return { ok: false, error: "No notifyChatIds configured" };
347
-
348
- // Session routing:
349
- // "main" = first notifyChatId (primary user chat)
350
- // numeric string = that specific chat ID
351
- // anything else / undefined = broadcast to all notifyChatIds
352
- let targetIds: number[];
353
- if (req.session === "main") {
354
- targetIds = [allChatIds[0]];
355
- } else if (req.session && /^-?\d+$/.test(req.session)) {
356
- targetIds = [Number(req.session)];
357
- } else {
358
- targetIds = allChatIds; // broadcast to all
359
- }
360
-
361
- try {
362
- await this.transport.notify(targetIds, req.text);
363
- return { ok: true };
364
- } catch (e: any) {
365
- return { ok: false, error: e.message };
366
- }
367
- }
368
- return { ok: false, error: "Unknown request type" };
369
- });
360
+ this.ipcServer = new IpcServer(createIpcHandler(this.transport, () => this.config));
370
361
  try {
371
362
  await this.ipcServer.start();
372
363
  } catch (err) {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * gateway/later-command.ts — Handle the /later command
3
+ *
4
+ * Quickly capture ideas/notes to ~/.roundhouse/workspace/later.md
5
+ * without interrupting the current conversation flow.
6
+ */
7
+
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { appendFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
11
+
12
+ const WORKSPACE_DIR = join(homedir(), ".roundhouse", "workspace");
13
+ const LATER_PATH = join(WORKSPACE_DIR, "later.md");
14
+
15
+ export interface LaterCommandContext {
16
+ thread: any;
17
+ text: string;
18
+ postWithFallback: (thread: any, text: string) => Promise<void>;
19
+ }
20
+
21
+ function ensureWorkspace(): void {
22
+ if (!existsSync(WORKSPACE_DIR)) {
23
+ mkdirSync(WORKSPACE_DIR, { recursive: true });
24
+ }
25
+ }
26
+
27
+ function ensureLaterFile(): void {
28
+ ensureWorkspace();
29
+ if (!existsSync(LATER_PATH)) {
30
+ appendFileSync(LATER_PATH, "# Later\n\nIdeas, reminders, and things to get back to.\n\n");
31
+ }
32
+ }
33
+
34
+ export async function handleLater(ctx: LaterCommandContext): Promise<void> {
35
+ const { thread, text, postWithFallback } = ctx;
36
+ const idea = text.replace(/^\/later\s*/i, "").trim();
37
+
38
+ // No argument: show contents
39
+ if (!idea) {
40
+ ensureLaterFile();
41
+ const contents = readFileSync(LATER_PATH, "utf8").trim();
42
+ const lines = contents.split("\n").filter(l => l.startsWith("- "));
43
+ if (lines.length === 0) {
44
+ await postWithFallback(thread, "📋 *Later list is empty.*\n\n_Usage:_ `/later buy more coffee`");
45
+ } else {
46
+ await postWithFallback(thread, `📋 *Later* (${lines.length} items):\n\n${lines.join("\n")}\n\n_File:_ \`~/.roundhouse/workspace/later.md\``);
47
+ }
48
+ return;
49
+ }
50
+
51
+ // Append the idea
52
+ ensureLaterFile();
53
+ const timestamp = new Date().toISOString().slice(0, 10);
54
+ appendFileSync(LATER_PATH, `- ${idea} _(${timestamp})_\n`);
55
+
56
+ await postWithFallback(thread, `✅ Saved: "${idea}"`);
57
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * gateway/model-command.ts — Handle the /model command
3
+ *
4
+ * Allows switching the default AI model from Telegram.
5
+ * Reads/writes ~/.pi/agent/settings.json (defaultProvider + defaultModel).
6
+ *
7
+ * When called without arguments, shows an inline keyboard with model buttons.
8
+ * When a button is clicked, the onAction handler applies the selection.
9
+ */
10
+
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { readFileSync, writeFileSync } from "node:fs";
14
+
15
+ /** Known model aliases → Bedrock model IDs */
16
+ export const MODEL_ALIASES: Record<string, { provider: string; model: string; label: string }> = {
17
+ // Anthropic Claude
18
+ "opus-4.7": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-7", label: "Claude Opus 4.7" },
19
+ "opus": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-6", label: "Claude Opus 4.6" },
20
+ "sonnet": { provider: "amazon-bedrock", model: "us.anthropic.claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
21
+ "haiku": { provider: "amazon-bedrock", model: "us.anthropic.claude-haiku-4-5", label: "Claude Haiku 4.5" },
22
+ // DeepSeek
23
+ "deepseek": { provider: "amazon-bedrock", model: "us.deepseek.r1-v1:0", label: "DeepSeek R1" },
24
+ // Meta Llama
25
+ "llama": { provider: "amazon-bedrock", model: "us.meta.llama4-maverick-17b-instruct-v1:0", label: "Llama 4 Maverick" },
26
+ // Amazon Nova
27
+ "nova-pro": { provider: "amazon-bedrock", model: "us.amazon.nova-pro-v1:0", label: "Amazon Nova Pro" },
28
+ // Mistral
29
+ "mistral": { provider: "amazon-bedrock", model: "us.mistral.mistral-large-2411-v1:0", label: "Mistral Large" },
30
+ };
31
+
32
+ /** Models shown in the inline keyboard (max 8, ordered by preference) */
33
+ const KEYBOARD_MODELS = [
34
+ "opus-4.7", "opus", "sonnet", "haiku",
35
+ "deepseek", "llama", "nova-pro", "mistral",
36
+ ] as const;
37
+
38
+ /** Action ID for model selection callbacks */
39
+ export const MODEL_ACTION_ID = "model_select";
40
+
41
+ const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
42
+
43
+ /** Callback data prefix used by @chat-adapter/telegram (coupled: if adapter changes this, buttons break) */
44
+ const CALLBACK_PREFIX = "chat:";
45
+
46
+ export interface ModelCommandContext {
47
+ thread: any;
48
+ text: string;
49
+ postWithFallback: (thread: any, text: string) => Promise<void>;
50
+ }
51
+
52
+ function readSettings(): Record<string, any> {
53
+ try {
54
+ return JSON.parse(readFileSync(SETTINGS_PATH, "utf8"));
55
+ } catch {
56
+ return {};
57
+ }
58
+ }
59
+
60
+ function writeSettings(settings: Record<string, any>): void {
61
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
62
+ }
63
+
64
+ function getCurrentModel(settings: Record<string, any>): string {
65
+ const provider = settings.defaultProvider ?? "unknown";
66
+ const model = settings.defaultModel ?? "unknown";
67
+ for (const [alias, info] of Object.entries(MODEL_ALIASES)) {
68
+ if (info.provider === provider && info.model === model) return `${info.label}`;
69
+ }
70
+ return `${model}`;
71
+ }
72
+
73
+ function encodeCallbackData(actionId: string, value: string): string {
74
+ return `${CALLBACK_PREFIX}${JSON.stringify({ a: actionId, v: value })}`;
75
+ }
76
+
77
+ function buildInlineKeyboard(): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
78
+ // Layout: 2 buttons per row for compact display
79
+ const buttons = KEYBOARD_MODELS.map(alias => {
80
+ const info = MODEL_ALIASES[alias];
81
+ return {
82
+ text: info.label,
83
+ callback_data: encodeCallbackData(MODEL_ACTION_ID, alias),
84
+ };
85
+ });
86
+ const rows: Array<Array<{ text: string; callback_data: string }>> = [];
87
+ for (let i = 0; i < buttons.length; i += 2) {
88
+ rows.push(buttons.slice(i, i + 2));
89
+ }
90
+ return { inline_keyboard: rows };
91
+ }
92
+
93
+ export async function handleModel(ctx: ModelCommandContext): Promise<void> {
94
+ const { thread, text, postWithFallback } = ctx;
95
+ const parts = text.split(/\s+/).slice(1);
96
+ const target = parts[0]?.toLowerCase();
97
+
98
+ const settings = readSettings();
99
+
100
+ // No argument: show inline keyboard
101
+ if (!target) {
102
+ const current = getCurrentModel(settings);
103
+ const msgText = `🤖 Current model: <b>${current}</b>\n\nSelect a model:`;
104
+
105
+ // Try to send with inline keyboard via telegramFetch
106
+ const adapter = thread?.adapter;
107
+ if (adapter?.telegramFetch) {
108
+ const chatId = thread?.platformThreadId?.split(":")?.[1] ?? thread?.id?.split(":")?.[1];
109
+ if (chatId) {
110
+ try {
111
+ await adapter.telegramFetch("sendMessage", {
112
+ chat_id: chatId,
113
+ text: msgText,
114
+ parse_mode: "HTML",
115
+ reply_markup: buildInlineKeyboard(),
116
+ });
117
+ return;
118
+ } catch (err) {
119
+ console.warn("[roundhouse] /model inline keyboard failed, falling back:", (err as Error).message);
120
+ }
121
+ }
122
+ }
123
+
124
+ // Fallback: plain text
125
+ const aliases = KEYBOARD_MODELS.map(a => ` \`${a}\` → ${MODEL_ALIASES[a].label}`).join("\n");
126
+ await postWithFallback(thread, `🤖 *Current model:* ${current}\n\n*Available:*\n${aliases}\n\n_Usage:_ \`/model sonnet\``);
127
+ return;
128
+ }
129
+
130
+ // Resolve alias
131
+ await applyModelSelection(target, settings, thread, postWithFallback);
132
+ }
133
+
134
+ /**
135
+ * Apply a model selection (used by both /model <arg> and inline keyboard callback).
136
+ */
137
+ export async function applyModelSelection(
138
+ target: string,
139
+ settings: Record<string, any> | null,
140
+ thread: any,
141
+ postWithFallback: (thread: any, text: string) => Promise<void>,
142
+ ): Promise<void> {
143
+ if (!settings) settings = readSettings();
144
+
145
+ const resolved = MODEL_ALIASES[target];
146
+ if (!resolved) {
147
+ if (target.includes(".") || target.includes("/")) {
148
+ const provider = settings.defaultProvider ?? "amazon-bedrock";
149
+ settings.defaultModel = target;
150
+ settings.defaultProvider = provider;
151
+ writeSettings(settings);
152
+ await postWithFallback(thread, `✅ Model set to: \`${provider}/${target}\``);
153
+ } else {
154
+ const aliases = Object.keys(MODEL_ALIASES).join(", ");
155
+ await postWithFallback(thread, `❌ Unknown model: \`${target}\`\n\nAvailable: ${aliases}`);
156
+ }
157
+ return;
158
+ }
159
+
160
+ settings.defaultProvider = resolved.provider;
161
+ settings.defaultModel = resolved.model;
162
+ writeSettings(settings);
163
+
164
+ await postWithFallback(thread, `✅ Switched to *${resolved.label}*`);
165
+ console.log(`[roundhouse] /model: switched to ${resolved.provider}/${resolved.model}`);
166
+ }
167
+
168
+ /**
169
+ * Handle inline keyboard callback for model selection.
170
+ * Call this from chat.onAction(MODEL_ACTION_ID, ...).
171
+ */
172
+ export async function handleModelAction(event: {
173
+ value?: string;
174
+ thread: any;
175
+ }): Promise<void> {
176
+ const alias = event.value;
177
+ if (!alias || !MODEL_ALIASES[alias]) return;
178
+
179
+ const postFn = async (_t: any, text: string) => {
180
+ if (!event.thread) return;
181
+ try { await event.thread.post({ markdown: text }); }
182
+ catch { try { await event.thread.post(text); } catch {} }
183
+ };
184
+
185
+ await applyModelSelection(alias, null, event.thread, postFn);
186
+ }
@@ -2,6 +2,14 @@
2
2
 
3
3
  Available tools that can be invoked via shell commands during agent turns.
4
4
 
5
+ ## Workspace Directory
6
+
7
+ **All new files, scratch work, downloads, and artifacts you create go under `~/.roundhouse/workspace/`.**
8
+ Do NOT create files directly in `~/` or pollute the home directory. Use subdirectories as needed:
9
+ - `~/.roundhouse/workspace/` — default working directory for any task output
10
+ - `~/.roundhouse/workspace/later.md` — ideas saved via `/later`
11
+ - `~/.roundhouse/workspace/<project>/` — project-specific files if needed
12
+
5
13
  ## roundhouse cron add
6
14
 
7
15
  Schedule recurring or one-shot jobs. The user may ask you to "remind me", "check every X", "do Y later", or "schedule Z".
@@ -0,0 +1,39 @@
1
+ /**
2
+ * ipc/handler.ts — Gateway IPC request handler factory
3
+ */
4
+
5
+ import type { GatewayConfig } from "../types";
6
+ import type { TransportAdapter } from "../transports";
7
+ import type { IpcRequest, IpcResponse } from "./types";
8
+
9
+ export function createIpcHandler(
10
+ transport: TransportAdapter,
11
+ getConfig: () => GatewayConfig,
12
+ ): (req: IpcRequest) => Promise<IpcResponse> {
13
+ return async (req: IpcRequest): Promise<IpcResponse> => {
14
+ if (req.type === "ping") return { ok: true };
15
+
16
+ if (req.type === "notify") {
17
+ const allChatIds = getConfig().chat.notifyChatIds ?? [];
18
+ if (allChatIds.length === 0) return { ok: false, error: "No notifyChatIds configured" };
19
+
20
+ let targetIds: number[];
21
+ if (req.session === "main") {
22
+ targetIds = [allChatIds[0]];
23
+ } else if (req.session && /^-?\d+$/.test(req.session)) {
24
+ targetIds = [Number(req.session)];
25
+ } else {
26
+ targetIds = allChatIds;
27
+ }
28
+
29
+ try {
30
+ await transport.notify(targetIds, req.text);
31
+ return { ok: true };
32
+ } catch (e: any) {
33
+ return { ok: false, error: e.message };
34
+ }
35
+ }
36
+
37
+ return { ok: false, error: "Unknown request type" };
38
+ };
39
+ }
package/src/ipc/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { IpcServer, SOCKET_PATH } from "./server";
2
2
  export { sendIpc } from "./client";
3
+ export { createIpcHandler } from "./handler";
3
4
  export type { IpcRequest, IpcResponse } from "./types";
@@ -13,6 +13,8 @@ export interface BotCommand {
13
13
  export const BOT_COMMANDS: BotCommand[] = [
14
14
  { command: "new", description: "Start a fresh conversation" },
15
15
  { command: "compact", description: "Compact context window" },
16
+ { command: "model", description: "Show or switch AI model" },
17
+ { command: "later", description: "Save an idea for later" },
16
18
  { command: "verbose", description: "Toggle verbose tool output" },
17
19
  { command: "stop", description: "Stop the current agent run" },
18
20
  { command: "restart", description: "Restart agent process" },