@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 +2 -2
- package/pi/extensions/web-search.ts +1 -1
- package/src/agents/pi/message-format.ts +1 -1
- package/src/agents/pi/pi-adapter.ts +3 -3
- package/src/agents/registry.ts +2 -2
- package/src/cli/doctor/checks/agent.ts +3 -3
- package/src/cli/update.ts +75 -41
- package/src/cron/runner.ts +11 -6
- package/src/cron/scheduler.ts +2 -2
- package/src/gateway/gateway.ts +23 -32
- package/src/gateway/later-command.ts +57 -0
- package/src/gateway/model-command.ts +186 -0
- package/src/gateway/tools.md +8 -0
- package/src/ipc/handler.ts +39 -0
- package/src/ipc/index.ts +1 -0
- package/src/transports/telegram/bot-commands.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inceptionstack/roundhouse",
|
|
3
|
-
"version": "0.5.
|
|
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
|
-
"@
|
|
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",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { AgentMessage } from "../../types";
|
|
9
|
-
import type { AgentSessionEvent } from "@
|
|
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 "@
|
|
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
|
-
// @
|
|
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", "@
|
|
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
|
|
package/src/agents/registry.ts
CHANGED
|
@@ -69,12 +69,12 @@ const piDefinition: AgentDefinition = {
|
|
|
69
69
|
packages: [
|
|
70
70
|
{
|
|
71
71
|
name: "Pi coding agent",
|
|
72
|
-
packageName: "@
|
|
72
|
+
packageName: "@earendil-works/pi-coding-agent",
|
|
73
73
|
install: "global",
|
|
74
74
|
binary: "pi",
|
|
75
75
|
},
|
|
76
76
|
],
|
|
77
|
-
sdkPackage: "@
|
|
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("@
|
|
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: ["@
|
|
35
|
-
fix: { description: "Install pi SDK", command: "npm install @
|
|
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
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/cron/runner.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
159
|
+
const defaultChatIds = this.defaultChatIds ?? [];
|
|
160
|
+
if (notify && defaultChatIds.length > 0) {
|
|
156
161
|
try {
|
|
157
|
-
await
|
|
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
|
}
|
package/src/cron/scheduler.ts
CHANGED
|
@@ -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
|
}
|
package/src/gateway/gateway.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/gateway/tools.md
CHANGED
|
@@ -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
|
@@ -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" },
|