@inceptionstack/roundhouse 0.2.2 → 0.3.1
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/README.md +321 -9
- package/architecture.md +77 -8
- package/package.json +9 -6
- package/src/agents/pi.ts +433 -26
- package/src/agents/registry.ts +8 -0
- package/src/cli/cli.ts +384 -189
- package/src/cli/cron.ts +296 -0
- package/src/cli/doctor/checks/agent.ts +68 -0
- package/src/cli/doctor/checks/config.ts +88 -0
- package/src/cli/doctor/checks/credentials.ts +62 -0
- package/src/cli/doctor/checks/disk.ts +69 -0
- package/src/cli/doctor/checks/stt.ts +76 -0
- package/src/cli/doctor/checks/system.ts +86 -0
- package/src/cli/doctor/checks/systemd.ts +76 -0
- package/src/cli/doctor/output.ts +58 -0
- package/src/cli/doctor/runner.ts +142 -0
- package/src/cli/doctor/shell.ts +33 -0
- package/src/cli/doctor/types.ts +44 -0
- package/src/cli/doctor.ts +48 -0
- package/src/cli/setup-telegram.ts +148 -0
- package/src/cli/setup.ts +936 -0
- package/src/commands.ts +23 -0
- package/src/config.ts +188 -0
- package/src/cron/constants.ts +54 -0
- package/src/cron/durations.ts +33 -0
- package/src/cron/format.ts +139 -0
- package/src/cron/helpers.ts +30 -0
- package/src/cron/runner.ts +148 -0
- package/src/cron/schedule.ts +101 -0
- package/src/cron/scheduler.ts +295 -0
- package/src/cron/store.ts +125 -0
- package/src/cron/template.ts +89 -0
- package/src/cron/types.ts +76 -0
- package/src/gateway.ts +927 -18
- package/src/index.ts +1 -58
- package/src/memory/bootstrap.ts +98 -0
- package/src/memory/files.ts +100 -0
- package/src/memory/inject.ts +41 -0
- package/src/memory/lifecycle.ts +245 -0
- package/src/memory/policy.ts +122 -0
- package/src/memory/prompts.ts +42 -0
- package/src/memory/state.ts +43 -0
- package/src/memory/types.ts +90 -0
- package/src/notify/telegram.ts +48 -0
- package/src/types.ts +68 -1
- package/src/util.ts +28 -2
- package/src/voice/providers/whisper.ts +339 -0
- package/src/voice/stt-service.ts +284 -0
- package/src/voice/types.ts +63 -0
package/src/cli/cron.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/cron.ts — roundhouse cron CLI subcommands
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CronStore, validateJobId } from "../cron/store";
|
|
6
|
+
import { isBuiltinJob } from "../cron/helpers";
|
|
7
|
+
import { CronRunner } from "../cron/runner";
|
|
8
|
+
import { validateSchedule } from "../cron/schedule";
|
|
9
|
+
import { validateTemplate } from "../cron/template";
|
|
10
|
+
import { parseDuration } from "../cron/durations";
|
|
11
|
+
import type { CronJobConfig, CronSchedule } from "../cron/types";
|
|
12
|
+
import { DEFAULT_TIMEOUT_MS, DEFAULT_TIMEZONE, VALID_NOTIFY_ON, DEFAULT_RUNS_LIMIT } from "../cron/constants";
|
|
13
|
+
import { formatSchedule, formatRunCounts, formatJobSummary, formatJobDetail, formatRunLine, runStatusIcon, jobEnabledIcon } from "../cron/format";
|
|
14
|
+
|
|
15
|
+
function rejectBuiltin(id: string): void {
|
|
16
|
+
if (isBuiltinJob(id)) {
|
|
17
|
+
console.error(`Job ID "${id}" is reserved for built-in jobs.`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateNotifyOn(value?: string): "always" | "success" | "failure" {
|
|
23
|
+
const v = value ?? "always";
|
|
24
|
+
if (!(VALID_NOTIFY_ON as readonly string[]).includes(v)) {
|
|
25
|
+
console.error(`Invalid --notify-on: "${v}". Use ${VALID_NOTIFY_ON.join(", ")}.`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
return v as "always" | "success" | "failure";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseArgs(args: string[]): { positional: string[]; flags: Record<string, string> } {
|
|
32
|
+
const positional: string[] = [];
|
|
33
|
+
const flags: Record<string, string> = {};
|
|
34
|
+
for (let i = 0; i < args.length; i++) {
|
|
35
|
+
if (args[i].startsWith("--") && i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
36
|
+
flags[args[i].slice(2)] = args[i + 1];
|
|
37
|
+
i++;
|
|
38
|
+
} else if (args[i].startsWith("--")) {
|
|
39
|
+
flags[args[i].slice(2)] = "true";
|
|
40
|
+
} else {
|
|
41
|
+
positional.push(args[i]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { positional, flags };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function cmdCron(args: string[]): Promise<void> {
|
|
48
|
+
const { positional, flags } = parseArgs(args);
|
|
49
|
+
const sub = positional[0];
|
|
50
|
+
|
|
51
|
+
const store = new CronStore();
|
|
52
|
+
await store.ensureDirs();
|
|
53
|
+
|
|
54
|
+
switch (sub) {
|
|
55
|
+
case "add": {
|
|
56
|
+
const id = positional[1];
|
|
57
|
+
if (!id) { console.error("Usage: roundhouse cron add <id> --prompt '...' --cron '...' --tz '...'"); process.exit(1); }
|
|
58
|
+
validateJobId(id);
|
|
59
|
+
rejectBuiltin(id);
|
|
60
|
+
|
|
61
|
+
const existing = await store.getJob(id);
|
|
62
|
+
if (existing && !flags.replace) {
|
|
63
|
+
console.error(`Job "${id}" already exists. Use --replace to overwrite.`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const prompt = flags.prompt;
|
|
68
|
+
if (!prompt) { console.error("--prompt is required"); process.exit(1); }
|
|
69
|
+
|
|
70
|
+
// Parse schedule — only one allowed
|
|
71
|
+
const schedCount = [flags.cron, flags.every, flags.at].filter(Boolean).length;
|
|
72
|
+
if (schedCount > 1) { console.error("Specify only one of --cron, --every, or --at"); process.exit(1); }
|
|
73
|
+
let schedule: CronSchedule;
|
|
74
|
+
if (flags.cron) {
|
|
75
|
+
schedule = { type: "cron", cron: flags.cron, tz: flags.tz ?? DEFAULT_TIMEZONE };
|
|
76
|
+
} else if (flags.every) {
|
|
77
|
+
schedule = { type: "interval", every: flags.every };
|
|
78
|
+
} else if (flags.at) {
|
|
79
|
+
schedule = { type: "once", at: flags.at, tz: flags.tz };
|
|
80
|
+
} else {
|
|
81
|
+
console.error("Schedule required: --cron '...', --every '...', or --at '...'");
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
validateSchedule(schedule);
|
|
86
|
+
|
|
87
|
+
// Parse vars
|
|
88
|
+
const vars: Record<string, string> = {};
|
|
89
|
+
if (flags.var) {
|
|
90
|
+
for (const v of flags.var.split(",")) {
|
|
91
|
+
const [k, ...rest] = v.split("=");
|
|
92
|
+
if (k && rest.length) vars[k.trim()] = rest.join("=").trim();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Validate template
|
|
97
|
+
const templateErrors = validateTemplate(prompt, new Set(Object.keys(vars)));
|
|
98
|
+
if (templateErrors.length) {
|
|
99
|
+
console.error("Template errors:");
|
|
100
|
+
templateErrors.forEach((e) => console.error(` ${e}`));
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Parse notify
|
|
105
|
+
const notify: CronJobConfig["notify"] = {};
|
|
106
|
+
if (flags.telegram) {
|
|
107
|
+
notify.telegram = {
|
|
108
|
+
chatIds: flags.telegram.split(",").map((s) => s.trim()),
|
|
109
|
+
onlyOn: validateNotifyOn(flags["notify-on"]),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const now = new Date().toISOString();
|
|
114
|
+
const job: CronJobConfig = {
|
|
115
|
+
id,
|
|
116
|
+
enabled: true,
|
|
117
|
+
description: flags.description,
|
|
118
|
+
createdAt: existing?.createdAt ?? now,
|
|
119
|
+
updatedAt: now,
|
|
120
|
+
schedule,
|
|
121
|
+
prompt,
|
|
122
|
+
vars: Object.keys(vars).length ? vars : undefined,
|
|
123
|
+
timeoutMs: flags.timeout ? parseDuration(flags.timeout) : undefined,
|
|
124
|
+
notify: Object.keys(notify).length ? notify : undefined,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
await store.writeJob(job);
|
|
128
|
+
console.log(`✅ Cron job "${id}" ${existing ? "updated" : "created"}.`);
|
|
129
|
+
if (flags.json) console.log(JSON.stringify(job, null, 2));
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case "list": {
|
|
134
|
+
const jobs = await store.listJobs();
|
|
135
|
+
if (jobs.length === 0) {
|
|
136
|
+
console.log("No cron jobs configured.");
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (flags.json) {
|
|
140
|
+
console.log(JSON.stringify(jobs, null, 2));
|
|
141
|
+
} else {
|
|
142
|
+
for (const j of jobs) {
|
|
143
|
+
const state = await store.getState(j.id);
|
|
144
|
+
console.log(` ${formatJobSummary(j, state)}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case "show": {
|
|
151
|
+
const id = positional[1];
|
|
152
|
+
if (!id) { console.error("Usage: roundhouse cron show <id>"); process.exit(1); }
|
|
153
|
+
const job = await store.getJob(id);
|
|
154
|
+
if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
|
|
155
|
+
const state = await store.getState(id);
|
|
156
|
+
const runs = await store.listRuns(id, 5);
|
|
157
|
+
if (flags.json) {
|
|
158
|
+
console.log(JSON.stringify({ job, state, recentRuns: runs }, null, 2));
|
|
159
|
+
} else {
|
|
160
|
+
console.log(`\n${formatJobDetail(job, state, runs)}`);
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case "trigger":
|
|
166
|
+
case "run": {
|
|
167
|
+
const id = positional[1];
|
|
168
|
+
if (!id) { console.error("Usage: roundhouse cron trigger <id>"); process.exit(1); }
|
|
169
|
+
rejectBuiltin(id);
|
|
170
|
+
const job = await store.getJob(id);
|
|
171
|
+
if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
|
|
172
|
+
console.log(`Triggering ${id}...`);
|
|
173
|
+
const runner = new CronRunner(store);
|
|
174
|
+
const record = await runner.runJob(job, new Date(), "manual");
|
|
175
|
+
console.log(`\nResult: ${record.status} (${record.durationMs}ms)`);
|
|
176
|
+
if (record.responseText) console.log(`\n${record.responseText.slice(0, 2000)}`);
|
|
177
|
+
if (record.error) console.log(`\nError: ${record.error}`);
|
|
178
|
+
process.exit(record.status === "completed" ? 0 : 1);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
case "runs": {
|
|
183
|
+
const id = positional[1];
|
|
184
|
+
if (!id) { console.error("Usage: roundhouse cron runs <id>"); process.exit(1); }
|
|
185
|
+
const runs = await store.listRuns(id, parseInt(flags.limit ?? String(DEFAULT_RUNS_LIMIT), 10));
|
|
186
|
+
if (runs.length === 0) {
|
|
187
|
+
console.log(`No runs for ${id}.`);
|
|
188
|
+
} else if (flags.json) {
|
|
189
|
+
console.log(JSON.stringify(runs, null, 2));
|
|
190
|
+
} else {
|
|
191
|
+
for (const r of runs) {
|
|
192
|
+
console.log(` ${formatRunLine(r)} (${r.kind})`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case "pause": {
|
|
199
|
+
const id = positional[1];
|
|
200
|
+
if (!id) { console.error("Usage: roundhouse cron pause <id>"); process.exit(1); }
|
|
201
|
+
rejectBuiltin(id);
|
|
202
|
+
const job = await store.getJob(id);
|
|
203
|
+
if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
|
|
204
|
+
job.enabled = false;
|
|
205
|
+
job.updatedAt = new Date().toISOString();
|
|
206
|
+
await store.writeJob(job);
|
|
207
|
+
console.log(`⏸️ Job "${id}" paused.`);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case "resume": {
|
|
212
|
+
const id = positional[1];
|
|
213
|
+
if (!id) { console.error("Usage: roundhouse cron resume <id>"); process.exit(1); }
|
|
214
|
+
rejectBuiltin(id);
|
|
215
|
+
const job = await store.getJob(id);
|
|
216
|
+
if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
|
|
217
|
+
job.enabled = true;
|
|
218
|
+
job.updatedAt = new Date().toISOString();
|
|
219
|
+
await store.writeJob(job);
|
|
220
|
+
console.log(`▶️ Job "${id}" resumed.`);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case "edit": {
|
|
225
|
+
const id = positional[1];
|
|
226
|
+
if (!id) { console.error("Usage: roundhouse cron edit <id> [--prompt '...'] [--cron '...'] ..."); process.exit(1); }
|
|
227
|
+
rejectBuiltin(id);
|
|
228
|
+
const job = await store.getJob(id);
|
|
229
|
+
if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
|
|
230
|
+
|
|
231
|
+
if (flags.prompt) job.prompt = flags.prompt;
|
|
232
|
+
if (flags.description) job.description = flags.description;
|
|
233
|
+
if (flags.timeout) job.timeoutMs = parseDuration(flags.timeout);
|
|
234
|
+
// Reject multiple schedule flags
|
|
235
|
+
const editSchedCount = [flags.cron, flags.every, flags.at].filter(Boolean).length;
|
|
236
|
+
if (editSchedCount > 1) { console.error("Specify only one of --cron, --every, or --at"); process.exit(1); }
|
|
237
|
+
if (flags.cron) job.schedule = { type: "cron", cron: flags.cron, tz: flags.tz ?? (job.schedule.type === "cron" ? job.schedule.tz : DEFAULT_TIMEZONE) };
|
|
238
|
+
if (flags.every) job.schedule = { type: "interval", every: flags.every };
|
|
239
|
+
if (flags.at) job.schedule = { type: "once", at: flags.at, tz: flags.tz };
|
|
240
|
+
if (flags.telegram) {
|
|
241
|
+
job.notify = { ...job.notify, telegram: { chatIds: flags.telegram.split(",").map((s) => s.trim()), onlyOn: validateNotifyOn(flags["notify-on"]) } };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
validateSchedule(job.schedule);
|
|
245
|
+
// Validate template after edit
|
|
246
|
+
const editVars = new Set(Object.keys(job.vars ?? {}));
|
|
247
|
+
const editErrors = validateTemplate(job.prompt, editVars);
|
|
248
|
+
if (editErrors.length) {
|
|
249
|
+
console.error("Template errors:");
|
|
250
|
+
editErrors.forEach((e) => console.error(` ${e}`));
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
job.updatedAt = new Date().toISOString();
|
|
254
|
+
await store.writeJob(job);
|
|
255
|
+
console.log(`✅ Job "${id}" updated.`);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
case "delete": {
|
|
260
|
+
const id = positional[1];
|
|
261
|
+
if (!id) { console.error("Usage: roundhouse cron delete <id>"); process.exit(1); }
|
|
262
|
+
rejectBuiltin(id);
|
|
263
|
+
const job = await store.getJob(id);
|
|
264
|
+
if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
|
|
265
|
+
await store.deleteJob(id);
|
|
266
|
+
console.log(`🗑️ Job "${id}" deleted.`);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
default:
|
|
271
|
+
console.log(`roundhouse cron <command>
|
|
272
|
+
|
|
273
|
+
Commands:
|
|
274
|
+
add <id> [flags] Create a cron job
|
|
275
|
+
list List all jobs
|
|
276
|
+
show <id> Show job details
|
|
277
|
+
trigger <id> Run job now
|
|
278
|
+
runs <id> Show run history
|
|
279
|
+
edit <id> [flags] Edit a job
|
|
280
|
+
pause <id> Disable a job
|
|
281
|
+
resume <id> Enable a job
|
|
282
|
+
delete <id> Delete a job
|
|
283
|
+
|
|
284
|
+
Flags for add/edit:
|
|
285
|
+
--prompt "..." Prompt template (required for add)
|
|
286
|
+
--cron "..." Cron expression (e.g. "0 8 * * *")
|
|
287
|
+
--every "..." Interval (e.g. "6h")
|
|
288
|
+
--at "..." One-shot time (e.g. "30m" or ISO date)
|
|
289
|
+
--tz "..." Timezone (e.g. "Asia/Jerusalem")
|
|
290
|
+
--telegram "..." Telegram chat IDs (comma-separated)
|
|
291
|
+
--var "k=v,..." Template variables (comma-separated)
|
|
292
|
+
--timeout "..." Timeout (e.g. "30m")
|
|
293
|
+
--description "..." Job description
|
|
294
|
+
--json JSON output`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent checks (pi-specific)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import type { DoctorCheck } from "../types";
|
|
9
|
+
import { which } from "../shell";
|
|
10
|
+
|
|
11
|
+
export const agentChecks: DoctorCheck[] = [
|
|
12
|
+
{
|
|
13
|
+
id: "pi-sdk", category: "agent", name: "Pi SDK",
|
|
14
|
+
async run() {
|
|
15
|
+
try {
|
|
16
|
+
const pkgPath = join(process.cwd(), "node_modules", "@mariozechner", "pi-coding-agent", "package.json");
|
|
17
|
+
const raw = await readFile(pkgPath, "utf8");
|
|
18
|
+
const ver = JSON.parse(raw).version;
|
|
19
|
+
return { id: "pi-sdk", category: "agent", name: "Pi SDK", status: "pass", summary: `v${ver}` };
|
|
20
|
+
} catch {
|
|
21
|
+
return {
|
|
22
|
+
id: "pi-sdk", category: "agent", name: "Pi SDK", status: "fail", summary: "not found",
|
|
23
|
+
details: ["@mariozechner/pi-coding-agent not installed"],
|
|
24
|
+
fix: { description: "Install pi SDK", command: "npm install @mariozechner/pi-coding-agent" },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
id: "pi-cli", category: "agent", name: "Pi CLI",
|
|
32
|
+
async run() {
|
|
33
|
+
const path = await which("pi");
|
|
34
|
+
return {
|
|
35
|
+
id: "pi-cli", category: "agent", name: "Pi CLI",
|
|
36
|
+
status: path ? "pass" : "warn", summary: path ?? "not found",
|
|
37
|
+
details: !path ? ["pi CLI needed for roundhouse tui"] : undefined,
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
id: "pi-settings", category: "agent", name: "Pi settings",
|
|
44
|
+
async run() {
|
|
45
|
+
const settingsPath = join(homedir(), ".pi", "agent", "settings.json");
|
|
46
|
+
try {
|
|
47
|
+
const raw = await readFile(settingsPath, "utf8");
|
|
48
|
+
const settings = JSON.parse(raw);
|
|
49
|
+
const model = settings.defaultModel ? `${settings.defaultProvider}/${settings.defaultModel}` : "not configured";
|
|
50
|
+
const issues: string[] = [];
|
|
51
|
+
if (!settings.defaultProvider) issues.push("No defaultProvider set");
|
|
52
|
+
if (!settings.defaultModel) issues.push("No defaultModel set");
|
|
53
|
+
return {
|
|
54
|
+
id: "pi-settings", category: "agent", name: "Pi settings",
|
|
55
|
+
status: issues.length ? "warn" : "pass",
|
|
56
|
+
summary: issues.length ? `${issues.length} issue(s)` : `model: ${model}`,
|
|
57
|
+
details: issues.length ? issues : undefined,
|
|
58
|
+
};
|
|
59
|
+
} catch {
|
|
60
|
+
return {
|
|
61
|
+
id: "pi-settings", category: "agent", name: "Pi settings",
|
|
62
|
+
status: "warn", summary: "not found",
|
|
63
|
+
details: [`${settingsPath} does not exist`],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
];
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration checks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFile, access, mkdir } from "node:fs/promises";
|
|
6
|
+
import { writeFile } from "node:fs/promises";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import type { DoctorCheck, DoctorContext, DoctorCheckResult } from "../types";
|
|
9
|
+
import { DEFAULT_CONFIG } from "../../../config";
|
|
10
|
+
|
|
11
|
+
export const configChecks: DoctorCheck[] = [
|
|
12
|
+
{
|
|
13
|
+
id: "config-dir", category: "config", name: "Config directory",
|
|
14
|
+
async run(ctx) {
|
|
15
|
+
const configDir = dirname(ctx.configPath);
|
|
16
|
+
try {
|
|
17
|
+
await access(configDir);
|
|
18
|
+
return { id: "config-dir", category: "config", name: "Config directory", status: "pass", summary: configDir };
|
|
19
|
+
} catch {
|
|
20
|
+
return {
|
|
21
|
+
id: "config-dir", category: "config", name: "Config directory", status: "warn", summary: "missing",
|
|
22
|
+
details: [`${configDir} does not exist`],
|
|
23
|
+
fix: {
|
|
24
|
+
description: "Create config directory",
|
|
25
|
+
command: `mkdir -p ${configDir}`,
|
|
26
|
+
run: async () => { await mkdir(configDir, { recursive: true }); return true; },
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
id: "config-file", category: "config", name: "Config file",
|
|
35
|
+
async run(ctx) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = await readFile(ctx.configPath, "utf8");
|
|
38
|
+
try {
|
|
39
|
+
JSON.parse(raw);
|
|
40
|
+
return { id: "config-file", category: "config", name: "Config file", status: "pass", summary: ctx.configPath };
|
|
41
|
+
} catch {
|
|
42
|
+
return { id: "config-file", category: "config", name: "Config file", status: "fail", summary: "invalid JSON", details: [`${ctx.configPath} is not valid JSON`] };
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
return {
|
|
46
|
+
id: "config-file", category: "config", name: "Config file", status: "warn", summary: "not found (defaults will be used)",
|
|
47
|
+
details: [`${ctx.configPath} does not exist`],
|
|
48
|
+
fix: {
|
|
49
|
+
description: "Create default config",
|
|
50
|
+
command: `roundhouse install`,
|
|
51
|
+
run: async () => {
|
|
52
|
+
const configDir = dirname(ctx.configPath);
|
|
53
|
+
await mkdir(configDir, { recursive: true });
|
|
54
|
+
await writeFile(ctx.configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
|
|
55
|
+
return true;
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
id: "config-schema", category: "config", name: "Config schema",
|
|
65
|
+
async run(ctx) {
|
|
66
|
+
try {
|
|
67
|
+
const raw = await readFile(ctx.configPath, "utf8");
|
|
68
|
+
const cfg = JSON.parse(raw);
|
|
69
|
+
const issues: string[] = [];
|
|
70
|
+
if (!cfg.agent?.type) issues.push("Missing agent.type");
|
|
71
|
+
if (!cfg.chat?.botUsername) issues.push("Missing chat.botUsername");
|
|
72
|
+
if (!cfg.chat?.adapters || Object.keys(cfg.chat.adapters).length === 0) issues.push("No chat adapters configured");
|
|
73
|
+
if (!cfg.chat?.allowedUsers?.length) issues.push("allowedUsers is empty (anyone can message the bot)");
|
|
74
|
+
|
|
75
|
+
if (issues.length === 0) {
|
|
76
|
+
return { id: "config-schema", category: "config", name: "Config schema", status: "pass", summary: "valid" };
|
|
77
|
+
}
|
|
78
|
+
const hasError = issues.some((i) => i.startsWith("Missing"));
|
|
79
|
+
return {
|
|
80
|
+
id: "config-schema", category: "config", name: "Config schema",
|
|
81
|
+
status: hasError ? "fail" : "warn", summary: `${issues.length} issue(s)`, details: issues,
|
|
82
|
+
};
|
|
83
|
+
} catch {
|
|
84
|
+
return { id: "config-schema", category: "config", name: "Config schema", status: "info", summary: "skipped (no config file)" };
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential checks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DoctorCheck } from "../types";
|
|
6
|
+
|
|
7
|
+
export const credentialChecks: DoctorCheck[] = [
|
|
8
|
+
{
|
|
9
|
+
id: "telegram-token", category: "credentials", name: "Telegram bot token",
|
|
10
|
+
async run(ctx) {
|
|
11
|
+
// Check both process env and the systemd env file
|
|
12
|
+
let token = ctx.env.TELEGRAM_BOT_TOKEN;
|
|
13
|
+
if (!token) {
|
|
14
|
+
try {
|
|
15
|
+
const { readFile } = await import("node:fs/promises");
|
|
16
|
+
const envContent = await readFile(ctx.envFilePath, "utf8");
|
|
17
|
+
const match = envContent.match(/^TELEGRAM_BOT_TOKEN=(.+)$/m);
|
|
18
|
+
if (match) token = match[1].trim().replace(/^["']|["']$/g, "");
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
if (!token) {
|
|
22
|
+
return {
|
|
23
|
+
id: "telegram-token", category: "credentials", name: "Telegram bot token",
|
|
24
|
+
status: "fail", summary: "TELEGRAM_BOT_TOKEN not set",
|
|
25
|
+
details: ["Set TELEGRAM_BOT_TOKEN in your environment or ~/.roundhouse/env"],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (!/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
|
|
29
|
+
return {
|
|
30
|
+
id: "telegram-token", category: "credentials", name: "Telegram bot token",
|
|
31
|
+
status: "fail", summary: "invalid format",
|
|
32
|
+
details: ["Token should match pattern: digits:alphanumeric"],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// Try getMe
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, {
|
|
38
|
+
signal: AbortSignal.timeout(10000),
|
|
39
|
+
});
|
|
40
|
+
if (res.ok) {
|
|
41
|
+
const data = await res.json() as any;
|
|
42
|
+
const username = data.result?.username ?? "unknown";
|
|
43
|
+
return {
|
|
44
|
+
id: "telegram-token", category: "credentials", name: "Telegram bot token",
|
|
45
|
+
status: "pass", summary: `@${username}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
id: "telegram-token", category: "credentials", name: "Telegram bot token",
|
|
50
|
+
status: "fail", summary: `API returned ${res.status}`,
|
|
51
|
+
details: ["Token may be invalid or revoked"],
|
|
52
|
+
};
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return {
|
|
55
|
+
id: "telegram-token", category: "credentials", name: "Telegram bot token",
|
|
56
|
+
status: "warn", summary: "cannot reach Telegram API",
|
|
57
|
+
details: [(err as Error).message],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk and directory checks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { access, stat, readdir, constants } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { mkdirSync } from "node:fs";
|
|
9
|
+
import type { DoctorCheck } from "../types";
|
|
10
|
+
import { run } from "../shell";
|
|
11
|
+
|
|
12
|
+
const INCOMING_DIR = process.env.ROUNDHOUSE_INCOMING_DIR ?? join(homedir(), ".roundhouse", "incoming");
|
|
13
|
+
const SESSIONS_DIR = join(homedir(), ".pi", "agent", "gateway-sessions");
|
|
14
|
+
|
|
15
|
+
export const diskChecks: DoctorCheck[] = [
|
|
16
|
+
{
|
|
17
|
+
id: "incoming-dir", category: "disk", name: "Incoming directory",
|
|
18
|
+
async run(ctx) {
|
|
19
|
+
try {
|
|
20
|
+
await access(INCOMING_DIR, constants.W_OK);
|
|
21
|
+
return { id: "incoming-dir", category: "disk", name: "Incoming directory", status: "pass", summary: INCOMING_DIR };
|
|
22
|
+
} catch {
|
|
23
|
+
return {
|
|
24
|
+
id: "incoming-dir", category: "disk", name: "Incoming directory",
|
|
25
|
+
status: "warn", summary: "missing or not writable",
|
|
26
|
+
details: [INCOMING_DIR],
|
|
27
|
+
fix: {
|
|
28
|
+
description: "Create incoming directory",
|
|
29
|
+
run: async () => { mkdirSync(INCOMING_DIR, { recursive: true }); return true; },
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
id: "sessions-dir", category: "disk", name: "Sessions directory",
|
|
38
|
+
async run() {
|
|
39
|
+
try {
|
|
40
|
+
await access(SESSIONS_DIR);
|
|
41
|
+
const threads = await readdir(SESSIONS_DIR);
|
|
42
|
+
return {
|
|
43
|
+
id: "sessions-dir", category: "disk", name: "Sessions directory",
|
|
44
|
+
status: "pass", summary: `${threads.length} thread(s)`,
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return {
|
|
48
|
+
id: "sessions-dir", category: "disk", name: "Sessions directory",
|
|
49
|
+
status: "info", summary: "not created yet (first message will create it)",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
id: "disk-space", category: "disk", name: "Free disk space",
|
|
57
|
+
async run() {
|
|
58
|
+
const df = await run("df", ["-BM", "--output=avail", homedir()]);
|
|
59
|
+
if (!df) return { id: "disk-space", category: "disk", name: "Free disk space", status: "info", summary: "cannot check" };
|
|
60
|
+
const lines = df.split("\n").filter(Boolean);
|
|
61
|
+
const avail = parseInt(lines[lines.length - 1]);
|
|
62
|
+
if (isNaN(avail)) return { id: "disk-space", category: "disk", name: "Free disk space", status: "info", summary: "cannot parse" };
|
|
63
|
+
const gb = (avail / 1024).toFixed(1);
|
|
64
|
+
if (avail < 100) return { id: "disk-space", category: "disk", name: "Free disk space", status: "fail", summary: `${gb} GB`, details: ["Less than 100 MB free"] };
|
|
65
|
+
if (avail < 1024) return { id: "disk-space", category: "disk", name: "Free disk space", status: "warn", summary: `${gb} GB`, details: ["Less than 1 GB free"] };
|
|
66
|
+
return { id: "disk-space", category: "disk", name: "Free disk space", status: "pass", summary: `${gb} GB` };
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STT / Voice checks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { access, readdir } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import type { DoctorCheck } from "../types";
|
|
9
|
+
|
|
10
|
+
export const sttChecks: DoctorCheck[] = [
|
|
11
|
+
{
|
|
12
|
+
id: "whisper-model", category: "stt", name: "Whisper model cache",
|
|
13
|
+
async run() {
|
|
14
|
+
const modelDir = join(homedir(), ".cache", "whisper");
|
|
15
|
+
try {
|
|
16
|
+
const files = await readdir(modelDir);
|
|
17
|
+
const models = files.filter((f) => f.endsWith(".pt"));
|
|
18
|
+
if (models.length === 0) {
|
|
19
|
+
return {
|
|
20
|
+
id: "whisper-model", category: "stt", name: "Whisper model cache",
|
|
21
|
+
status: "warn", summary: "no models downloaded",
|
|
22
|
+
details: ["Model will download on first voice message (~461MB for small)"],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
id: "whisper-model", category: "stt", name: "Whisper model cache",
|
|
27
|
+
status: "pass", summary: models.join(", "),
|
|
28
|
+
};
|
|
29
|
+
} catch {
|
|
30
|
+
return {
|
|
31
|
+
id: "whisper-model", category: "stt", name: "Whisper model cache",
|
|
32
|
+
status: "info", summary: "cache dir not found",
|
|
33
|
+
details: [`${modelDir} does not exist yet`],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
id: "whisper-tmp", category: "stt", name: "Whisper temp dirs",
|
|
41
|
+
async run() {
|
|
42
|
+
const tmpDir = join(homedir(), ".roundhouse", "whisper-tmp");
|
|
43
|
+
try {
|
|
44
|
+
const entries = await readdir(tmpDir);
|
|
45
|
+
if (entries.length === 0) {
|
|
46
|
+
return { id: "whisper-tmp", category: "stt", name: "Whisper temp dirs", status: "pass", summary: "clean" };
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
id: "whisper-tmp", category: "stt", name: "Whisper temp dirs",
|
|
50
|
+
status: "warn", summary: `${entries.length} orphaned dir(s)`,
|
|
51
|
+
details: [`Leftover temp directories in ${tmpDir}`],
|
|
52
|
+
fix: {
|
|
53
|
+
description: "Clean orphaned temp dirs older than 1 hour",
|
|
54
|
+
run: async () => {
|
|
55
|
+
const { rm, stat: fsStat } = await import("node:fs/promises");
|
|
56
|
+
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
57
|
+
let cleaned = 0;
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
try {
|
|
60
|
+
const s = await fsStat(join(tmpDir, entry));
|
|
61
|
+
if (s.mtimeMs < cutoff) {
|
|
62
|
+
await rm(join(tmpDir, entry), { recursive: true });
|
|
63
|
+
cleaned++;
|
|
64
|
+
}
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
return cleaned > 0;
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
} catch {
|
|
72
|
+
return { id: "whisper-tmp", category: "stt", name: "Whisper temp dirs", status: "pass", summary: "no temp dir" };
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
];
|