@inceptionstack/roundhouse 0.5.9 ā 0.5.11
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 +23 -6
- package/src/cron/scheduler.ts +2 -2
- package/src/gateway/gateway.ts +21 -29
- package/src/gateway/later-command.ts +57 -0
- package/src/gateway/model-command.ts +96 -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.11",
|
|
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,6 +19,8 @@ export class CronRunner {
|
|
|
20
19
|
constructor(
|
|
21
20
|
private store: CronStore,
|
|
22
21
|
private agentConfig?: GatewayConfig["agent"],
|
|
22
|
+
private defaultChatIds?: number[],
|
|
23
|
+
private notifyFn?: (chatIds: number[], text: string) => Promise<void>,
|
|
23
24
|
) {}
|
|
24
25
|
|
|
25
26
|
async runJob(
|
|
@@ -127,6 +128,10 @@ export class CronRunner {
|
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
private async notify(job: CronJobConfig, record: CronRunRecord): Promise<void> {
|
|
131
|
+
// Apply onlyOn filter if configured (for both routes)
|
|
132
|
+
const tg = job.notify?.telegram;
|
|
133
|
+
if (tg?.onlyOn && !shouldNotify(tg.onlyOn, record.status)) return;
|
|
134
|
+
|
|
130
135
|
const icon = runStatusIcon(record.status);
|
|
131
136
|
const dur = `${(record.durationMs / 1000).toFixed(1)}s`;
|
|
132
137
|
const header = `${icon} Cron: ${job.id}\nStatus: ${record.status} (${dur})`;
|
|
@@ -140,19 +145,31 @@ export class CronRunner {
|
|
|
140
145
|
body = `${header}\nError: ${record.error.slice(0, NOTIFY_MAX_ERROR_CHARS)}`;
|
|
141
146
|
}
|
|
142
147
|
|
|
148
|
+
const notify = this.notifyFn;
|
|
149
|
+
|
|
143
150
|
// Route 1: Explicit Telegram chat IDs configured on the job
|
|
144
|
-
const tg = job.notify?.telegram;
|
|
145
151
|
if (tg?.chatIds?.length) {
|
|
146
|
-
if (
|
|
147
|
-
|
|
152
|
+
if (notify) {
|
|
153
|
+
await notify(tg.chatIds, body);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Route 2: Direct callback from gateway (no loopback socket)
|
|
159
|
+
const defaultChatIds = this.defaultChatIds ?? [];
|
|
160
|
+
if (notify && defaultChatIds.length > 0) {
|
|
161
|
+
try {
|
|
162
|
+
await notify(defaultChatIds, body);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.warn(`[cron] ${job.id} notify callback failed:`, (err as Error).message);
|
|
165
|
+
}
|
|
148
166
|
return;
|
|
149
167
|
}
|
|
150
168
|
|
|
151
|
-
// Route
|
|
169
|
+
// Route 3: Fallback to IPC (for CLI-triggered runs outside gateway)
|
|
152
170
|
try {
|
|
153
171
|
await sendIpc({ type: "notify", text: body });
|
|
154
172
|
} catch {
|
|
155
|
-
// Gateway not running or IPC unavailable ā log and continue
|
|
156
173
|
console.warn(`[cron] ${job.id} IPC notify failed (gateway not running?)`);
|
|
157
174
|
}
|
|
158
175
|
}
|
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[] }) {
|
|
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);
|
|
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 } 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
|
};
|
|
@@ -325,6 +339,11 @@ export class Gateway {
|
|
|
325
339
|
this.cronScheduler = new CronSchedulerService({
|
|
326
340
|
agentConfig: this.config.agent,
|
|
327
341
|
notifyChatIds: this.config.chat.notifyChatIds,
|
|
342
|
+
notifyFn: async (chatIds: number[], text: string) => {
|
|
343
|
+
if (chatIds.length && this.transport) {
|
|
344
|
+
await this.transport.notify(chatIds, text);
|
|
345
|
+
}
|
|
346
|
+
},
|
|
328
347
|
});
|
|
329
348
|
try {
|
|
330
349
|
await this.cronScheduler.start();
|
|
@@ -333,34 +352,7 @@ export class Gateway {
|
|
|
333
352
|
}
|
|
334
353
|
|
|
335
354
|
// Start IPC server for CLI ā gateway communication
|
|
336
|
-
this.ipcServer = new IpcServer(
|
|
337
|
-
if (req.type === "ping") return { ok: true };
|
|
338
|
-
if (req.type === "notify") {
|
|
339
|
-
const allChatIds = this.config.chat.notifyChatIds ?? [];
|
|
340
|
-
if (allChatIds.length === 0) return { ok: false, error: "No notifyChatIds configured" };
|
|
341
|
-
|
|
342
|
-
// Session routing:
|
|
343
|
-
// "main" = first notifyChatId (primary user chat)
|
|
344
|
-
// numeric string = that specific chat ID
|
|
345
|
-
// anything else / undefined = broadcast to all notifyChatIds
|
|
346
|
-
let targetIds: number[];
|
|
347
|
-
if (req.session === "main") {
|
|
348
|
-
targetIds = [allChatIds[0]];
|
|
349
|
-
} else if (req.session && /^-?\d+$/.test(req.session)) {
|
|
350
|
-
targetIds = [Number(req.session)];
|
|
351
|
-
} else {
|
|
352
|
-
targetIds = allChatIds; // broadcast to all
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
try {
|
|
356
|
-
await this.transport.notify(targetIds, req.text);
|
|
357
|
-
return { ok: true };
|
|
358
|
-
} catch (e: any) {
|
|
359
|
-
return { ok: false, error: e.message };
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
return { ok: false, error: "Unknown request type" };
|
|
363
|
-
});
|
|
355
|
+
this.ipcServer = new IpcServer(createIpcHandler(this.transport, () => this.config));
|
|
364
356
|
try {
|
|
365
357
|
await this.ipcServer.start();
|
|
366
358
|
} 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,96 @@
|
|
|
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
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
/** Known model aliases ā Bedrock model IDs */
|
|
13
|
+
const MODEL_ALIASES: Record<string, { provider: string; model: string; label: string }> = {
|
|
14
|
+
"opus": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-6", label: "Claude Opus 4.6" },
|
|
15
|
+
"opus-4.6": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-6", label: "Claude Opus 4.6" },
|
|
16
|
+
"opus-4.7": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-7", label: "Claude Opus 4.7" },
|
|
17
|
+
"sonnet": { provider: "amazon-bedrock", model: "us.anthropic.claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
|
18
|
+
"sonnet-4.6": { provider: "amazon-bedrock", model: "us.anthropic.claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
|
19
|
+
"haiku": { provider: "amazon-bedrock", model: "us.anthropic.claude-haiku-4-5", label: "Claude Haiku 4.5" },
|
|
20
|
+
"haiku-4.5": { provider: "amazon-bedrock", model: "us.anthropic.claude-haiku-4-5", label: "Claude Haiku 4.5" },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
|
|
24
|
+
|
|
25
|
+
export interface ModelCommandContext {
|
|
26
|
+
thread: any;
|
|
27
|
+
text: string;
|
|
28
|
+
postWithFallback: (thread: any, text: string) => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readSettings(): Record<string, any> {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(SETTINGS_PATH, "utf8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeSettings(settings: Record<string, any>): void {
|
|
40
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getCurrentModel(settings: Record<string, any>): string {
|
|
44
|
+
const provider = settings.defaultProvider ?? "unknown";
|
|
45
|
+
const model = settings.defaultModel ?? "unknown";
|
|
46
|
+
// Try to find a friendly label
|
|
47
|
+
for (const [alias, info] of Object.entries(MODEL_ALIASES)) {
|
|
48
|
+
if (info.provider === provider && info.model === model) return `${info.label} (${alias})`;
|
|
49
|
+
}
|
|
50
|
+
return `${provider}/${model}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function handleModel(ctx: ModelCommandContext): Promise<void> {
|
|
54
|
+
const { thread, text, postWithFallback } = ctx;
|
|
55
|
+
const parts = text.split(/\s+/).slice(1);
|
|
56
|
+
const target = parts[0]?.toLowerCase();
|
|
57
|
+
|
|
58
|
+
const settings = readSettings();
|
|
59
|
+
|
|
60
|
+
// No argument: show current model + available options
|
|
61
|
+
if (!target) {
|
|
62
|
+
const current = getCurrentModel(settings);
|
|
63
|
+
const aliases = Object.entries(MODEL_ALIASES)
|
|
64
|
+
.filter(([alias]) => !alias.includes(".")) // Show short aliases only
|
|
65
|
+
.map(([alias, info]) => ` \`${alias}\` ā ${info.label}`)
|
|
66
|
+
.join("\n");
|
|
67
|
+
|
|
68
|
+
await postWithFallback(thread, `š¤ *Current model:* ${current}\n\n*Available:*\n${aliases}\n\n_Usage:_ \`/model sonnet\``);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Resolve alias or use as raw model ID
|
|
73
|
+
const resolved = MODEL_ALIASES[target];
|
|
74
|
+
if (!resolved) {
|
|
75
|
+
// Check if it looks like a full model ID (contains a dot or slash)
|
|
76
|
+
if (target.includes(".") || target.includes("/")) {
|
|
77
|
+
// Use as-is with current provider
|
|
78
|
+
const provider = settings.defaultProvider ?? "amazon-bedrock";
|
|
79
|
+
settings.defaultModel = target;
|
|
80
|
+
settings.defaultProvider = provider;
|
|
81
|
+
writeSettings(settings);
|
|
82
|
+
await postWithFallback(thread, `ā
Model set to: \`${provider}/${target}\`\n\nā ļø Restart needed: \`/restart\``);
|
|
83
|
+
} else {
|
|
84
|
+
const aliases = Object.keys(MODEL_ALIASES).filter(a => !a.includes(".")).join(", ");
|
|
85
|
+
await postWithFallback(thread, `ā Unknown model: \`${target}\`\n\nAvailable: ${aliases}`);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
settings.defaultProvider = resolved.provider;
|
|
91
|
+
settings.defaultModel = resolved.model;
|
|
92
|
+
writeSettings(settings);
|
|
93
|
+
|
|
94
|
+
await postWithFallback(thread, `ā
Model switched to: *${resolved.label}*\n\nā ļø Takes effect on next agent turn (new sessions use new model).`);
|
|
95
|
+
console.log(`[roundhouse] /model: switched to ${resolved.provider}/${resolved.model}`);
|
|
96
|
+
}
|
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" },
|