roger-roger 0.1.0
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/LICENSE +21 -0
- package/README.md +147 -0
- package/package.json +45 -0
- package/skills/roger-roger/SKILL.md +289 -0
- package/skills/roger-roger/herdr-plugin.toml +38 -0
- package/skills/roger-roger/scripts/agent.mjs +132 -0
- package/skills/roger-roger/scripts/audio.mjs +392 -0
- package/skills/roger-roger/scripts/client.mjs +121 -0
- package/skills/roger-roger/scripts/daemon.mjs +604 -0
- package/skills/roger-roger/scripts/decisions.mjs +158 -0
- package/skills/roger-roger/scripts/handlers.mjs +1151 -0
- package/skills/roger-roger/scripts/herdr.mjs +140 -0
- package/skills/roger-roger/scripts/hooks-codex.mjs +154 -0
- package/skills/roger-roger/scripts/hooks-opencode.mjs +167 -0
- package/skills/roger-roger/scripts/hooks.mjs +420 -0
- package/skills/roger-roger/scripts/inbox.mjs +381 -0
- package/skills/roger-roger/scripts/install.mjs +560 -0
- package/skills/roger-roger/scripts/lib.mjs +1133 -0
- package/skills/roger-roger/scripts/names.mjs +84 -0
- package/skills/roger-roger/scripts/progress.mjs +91 -0
- package/skills/roger-roger/scripts/protocol.mjs +71 -0
- package/skills/roger-roger/scripts/roger-roger.mjs +536 -0
- package/skills/roger-roger/scripts/router.mjs +86 -0
- package/skills/roger-roger/scripts/sessions.mjs +218 -0
- package/skills/roger-roger/scripts/slack.mjs +240 -0
- package/skills/roger-roger/scripts/slackapp.mjs +205 -0
- package/skills/roger-roger/scripts/slackcli.mjs +144 -0
- package/skills/roger-roger/scripts/speaker.mjs +224 -0
- package/skills/roger-roger/scripts/speechkey.mjs +106 -0
- package/skills/roger-roger/scripts/tray.mjs +128 -0
- package/skills/roger-roger/scripts/tts.mjs +275 -0
- package/skills/roger-roger/scripts/tui.mjs +465 -0
- package/skills/roger-roger/slack/manifest.json +34 -0
- package/skills/roger-roger/sounds/alert.wav +0 -0
- package/skills/roger-roger/sounds/bubble.wav +0 -0
- package/skills/roger-roger/sounds/chime.wav +0 -0
- package/skills/roger-roger/sounds/ding.wav +0 -0
- package/skills/roger-roger/sounds/marimba.wav +0 -0
- package/skills/roger-roger/tray/main.mjs +749 -0
- package/skills/roger-roger/tray/panel.html +501 -0
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
// What every command actually does. The daemon serves these; the CLI calls them directly when no
|
|
2
|
+
// daemon can be reached, so there is one implementation either way.
|
|
3
|
+
//
|
|
4
|
+
// A handler takes (ctx, args) and returns { data, code }. `ctx` carries the loaded config, the agent
|
|
5
|
+
// session that is calling, a log, and a bus that wakes `wait` the moment something changes.
|
|
6
|
+
// Anything that needs the caller's working directory — attachments, the project name — is resolved
|
|
7
|
+
// by the CLI before it gets here, because the daemon runs somewhere else entirely.
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { execFile, spawn } from "node:child_process";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_DRAFT_CHOICE, DEFAULTS, KINDS, METHODS, MAX_DRAFT, ON_EXPIRE,
|
|
16
|
+
announceAs, applySetup, applyQuietHours, rogerRogerHome, machineEnv, pidAlive, decisionBlocks, formatElapsed, listArg, listSounds, saveConfig,
|
|
17
|
+
messageBlocks, parseChoices, parseDuration, parseFields, parseRecommend, parseRecommendMany,
|
|
18
|
+
parseRemind, previewList, progressBlocks, sessionLabel, slackAppToken, slackText, slackToken,
|
|
19
|
+
soundAnnouncement, speechText, splitList, stripTags, summaryBlocks, voiceSample,
|
|
20
|
+
} from "./lib.mjs";
|
|
21
|
+
import {
|
|
22
|
+
deliverSpeech, mapPool, playAlert, playWav, playWavBuffer, prepareSpeech, soundFile,
|
|
23
|
+
speakLocally, speechAudio, warmUpAudio, withOthersQuieter,
|
|
24
|
+
} from "./audio.mjs";
|
|
25
|
+
import { withSpeaker } from "./speaker.mjs";
|
|
26
|
+
import { catalog, findVoice, keyFor, missingKey, providerOf, refreshCatalog, testSample } from "./tts.mjs";
|
|
27
|
+
import { addReaction, postMessage, removeReaction, updateMessage, uploadFiles } from "./slack.mjs";
|
|
28
|
+
import * as progressStore from "./progress.mjs";
|
|
29
|
+
import * as sessions from "./sessions.mjs";
|
|
30
|
+
import * as inbox from "./inbox.mjs";
|
|
31
|
+
import * as decisions from "./decisions.mjs";
|
|
32
|
+
import { labelPane } from "./herdr.mjs";
|
|
33
|
+
import { answeredNotice, isQuestion, matchRequest, permissionNotice, rememberRequest } from "./hooks.mjs";
|
|
34
|
+
|
|
35
|
+
export const EXIT_NOT_CONFIGURED = 3;
|
|
36
|
+
export const EXIT_STILL_PENDING = 4;
|
|
37
|
+
export const EXIT_CLOSED = 5;
|
|
38
|
+
export const EXIT_MESSAGE = 6;
|
|
39
|
+
// A question an agent is waiting on settles itself in well under an hour: the recommended option is
|
|
40
|
+
// applied at 45 minutes, with a nudge at 15. Follow-up buttons on a summary card block nobody, so
|
|
41
|
+
// they stay open far longer.
|
|
42
|
+
const DEFAULT_ASK_EXPIRY = 45 * 60;
|
|
43
|
+
// Short on purpose: waking inside the agent's prompt cache costs about a tenth of waking outside it.
|
|
44
|
+
const DEFAULT_LISTEN = 25 * 60;
|
|
45
|
+
const DEFAULT_ACTIONS_EXPIRY = 4 * 3600;
|
|
46
|
+
|
|
47
|
+
const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
|
|
49
|
+
const ok = (data, code = 0) => ({ data, code });
|
|
50
|
+
|
|
51
|
+
function fail(message, code = 1) {
|
|
52
|
+
throw Object.assign(new Error(message), { code });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function requireConfig(ctx) {
|
|
56
|
+
if (!ctx.config) fail("not configured", EXIT_NOT_CONFIGURED);
|
|
57
|
+
return ctx.config;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sayArg = (args) => (typeof args.say === "string" ? args.say : undefined);
|
|
61
|
+
|
|
62
|
+
/** What is actually said out loud, with this agent's name on the front unless that is turned off. */
|
|
63
|
+
function spoken(ctx, config, args, message) {
|
|
64
|
+
const text = speechText({ mode: config.speech, message, say: sayArg(args) });
|
|
65
|
+
return announceAs(text, config.sayWho === false ? null : ctx.session?.nickname);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The user's message has now been answered, which is what the tick means. */
|
|
69
|
+
async function markAnswered(channel, ts) {
|
|
70
|
+
if (!ts) return;
|
|
71
|
+
await addReaction(channel, ts, "white_check_mark").catch(() => {});
|
|
72
|
+
await removeReaction(channel, ts, "eyes").catch(() => {});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** "🌿 sage · machine · agent · model · session" under Slack messages. */
|
|
76
|
+
function labelFor(ctx, args, config) {
|
|
77
|
+
if (config.sessionLabel === false) return "";
|
|
78
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
79
|
+
return sessionLabel({
|
|
80
|
+
hostname: ctx.session?.host || os.hostname(),
|
|
81
|
+
agent: str(args.agent) || ctx.session?.agent,
|
|
82
|
+
model: str(args["agent-model"]),
|
|
83
|
+
session: str(args.session),
|
|
84
|
+
nickname: ctx.session?.nickname,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The session's colour, which Slack draws as a bar down the left of its messages. */
|
|
89
|
+
const colourOf = (ctx) => ctx.session?.colour;
|
|
90
|
+
|
|
91
|
+
/** Methods for this call: `--only` or the configured ones, minus whatever quiet hours mute. */
|
|
92
|
+
function methodsFor(args, config) {
|
|
93
|
+
const only = splitList(args.only);
|
|
94
|
+
const wanted = only.length ? only.filter((m) => METHODS.includes(m)) : config.methods;
|
|
95
|
+
return applyQuietHours(wanted, config);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function attachments(args) {
|
|
99
|
+
const files = listArg(args.attach);
|
|
100
|
+
for (const f of files) {
|
|
101
|
+
if (!fs.existsSync(f) || !fs.statSync(f).isFile()) fail(`--attach: no such file: ${f}`);
|
|
102
|
+
}
|
|
103
|
+
return files;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function draftOf(args) {
|
|
107
|
+
const draft = typeof args.draft === "string" ? args.draft.replace(/\r\n/g, "\n") : "";
|
|
108
|
+
if (draft.length > MAX_DRAFT) {
|
|
109
|
+
fail(`the draft is ${draft.length} characters; Slack can only edit ${MAX_DRAFT}. Shorten it, or --attach the full text and ask about it instead`);
|
|
110
|
+
}
|
|
111
|
+
return draft.trim() ? draft : "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Note what the agent is doing, so the picker can say "waiting on you" next to its name. */
|
|
115
|
+
function setState(ctx, state) {
|
|
116
|
+
if (ctx.session) sessions.touch(ctx.session.id, { state });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------- notify
|
|
120
|
+
|
|
121
|
+
export async function notify(ctx, args) {
|
|
122
|
+
const config = requireConfig(ctx);
|
|
123
|
+
const kind = args.kind ?? "info";
|
|
124
|
+
if (!KINDS.includes(kind)) fail(`--kind must be one of: ${KINDS.join(", ")}`);
|
|
125
|
+
const message = typeof args.message === "string" ? args.message : (args._ ?? []).join(" ");
|
|
126
|
+
if (!message) fail("--message is required");
|
|
127
|
+
const fields = parseFields(listArg(args.field));
|
|
128
|
+
const replyTo = typeof args["reply-to"] === "string" ? args["reply-to"].trim() : "";
|
|
129
|
+
const files = attachments(args);
|
|
130
|
+
const { methods, muted } = methodsFor(args, config);
|
|
131
|
+
const followUp = followUpActions(args, config, methods);
|
|
132
|
+
const text = spoken(ctx, config, args, message);
|
|
133
|
+
const results = {};
|
|
134
|
+
setState(ctx, kind === "done" ? "done" : "working");
|
|
135
|
+
|
|
136
|
+
const headline = slackText({ kind, message, project: args.project, mention: config.slack.mention });
|
|
137
|
+
const label = labelFor(ctx, args, config);
|
|
138
|
+
const colour = colourOf(ctx);
|
|
139
|
+
let blocks = messageBlocks({ headline, label });
|
|
140
|
+
if (args.summary || fields.length) {
|
|
141
|
+
// The end-of-task card: facts, time taken, and the questions answered in this session.
|
|
142
|
+
const name = typeof args.session === "string" ? args.session.trim().toLowerCase() : "";
|
|
143
|
+
const answered = args.summary && name
|
|
144
|
+
? decisions.list().filter((d) => d.status !== "pending" && d.session?.trim().toLowerCase() === name).reverse()
|
|
145
|
+
: [];
|
|
146
|
+
const elapsed = args.summary && ctx.session ? formatElapsed(Date.now() - Date.parse(ctx.session.firstSeen)) : "";
|
|
147
|
+
blocks = summaryBlocks({ headline, fields, elapsed, decisions: answered, label });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Slack posts while the speech is synthesized; the sound waits for the speech.
|
|
151
|
+
if (methods.includes("sound") || methods.includes("speech")) warmUpAudio();
|
|
152
|
+
const prepared = methods.includes("speech") ? prepareSpeech(text, config) : null;
|
|
153
|
+
let followUpId = null;
|
|
154
|
+
const slackJob = methods.includes("slack")
|
|
155
|
+
? (async () => {
|
|
156
|
+
const id = followUp ? decisions.newId() : null;
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
const expiresAt = followUp ? new Date(now + followUp.expiresIn * 1000).toISOString() : null;
|
|
159
|
+
const payload = followUp
|
|
160
|
+
? { text: stripTags(headline), blocks: decisionBlocks({ id, baseBlocks: blocks, choices: followUp.choices, recommended: followUp.recommended, allowOther: followUp.allowOther, expiresAt, onExpire: followUp.onExpire }), color: colour }
|
|
161
|
+
: { text: stripTags(headline), blocks, color: colour };
|
|
162
|
+
// Answering something the user said in a thread belongs in that thread, not the channel.
|
|
163
|
+
const { channel, ts } = await postMessage(config, { ...payload, ...(replyTo ? { thread_ts: replyTo } : {}) });
|
|
164
|
+
await markAnswered(channel, replyTo);
|
|
165
|
+
sessions.bindThread(ts, ctx.session?.id);
|
|
166
|
+
results.slack = { ok: true, channel, ts };
|
|
167
|
+
if (followUp) {
|
|
168
|
+
decisions.create({
|
|
169
|
+
id, status: "pending", question: stripTags(message), details: "", headline, baseBlocks: blocks,
|
|
170
|
+
choices: followUp.choices, recommended: followUp.recommended, multi: false, label, allowOther: followUp.allowOther,
|
|
171
|
+
onExpire: followUp.onExpire, reminders: [], remindersSent: 0, channel, ts, colour,
|
|
172
|
+
sessionId: ctx.session?.id ?? null,
|
|
173
|
+
...(typeof args.session === "string" ? { session: args.session.trim() } : {}),
|
|
174
|
+
createdAt: new Date(now).toISOString(), expiresAt,
|
|
175
|
+
});
|
|
176
|
+
followUpId = id;
|
|
177
|
+
}
|
|
178
|
+
if (files.length) results.slack.attached = await uploadFiles(channel, files);
|
|
179
|
+
})().catch((e) => (results.slack = { ok: Boolean(results.slack?.ok), error: e.message }))
|
|
180
|
+
: null;
|
|
181
|
+
|
|
182
|
+
Object.assign(results, await playAlert({ methods, config, text, prepared }));
|
|
183
|
+
await slackJob;
|
|
184
|
+
const succeeded = Object.values(results).some((r) => r.ok);
|
|
185
|
+
const body = {
|
|
186
|
+
ok: succeeded,
|
|
187
|
+
kind,
|
|
188
|
+
results,
|
|
189
|
+
...(followUpId ? { id: followUpId, status: "pending", next: `wait ${followUpId}` } : {}),
|
|
190
|
+
...(muted.length ? { mutedByQuietHours: muted } : {}),
|
|
191
|
+
...(files.length && !methods.includes("slack") ? { note: "--attach needs slack" } : {}),
|
|
192
|
+
};
|
|
193
|
+
// Park in the same breath as replying. Doing it as a second command leaves a few seconds where
|
|
194
|
+
// the agent looks idle, and a message sent in that gap goes to the picker instead of here.
|
|
195
|
+
if (succeeded && args["then-listen"] !== undefined) {
|
|
196
|
+
const parked = await listen(ctx, { wait: args["then-listen"] });
|
|
197
|
+
return ok({ ...body, ...parked.data }, parked.code);
|
|
198
|
+
}
|
|
199
|
+
return ok(body, succeeded ? 0 : 1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------- permission prompts
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The agent is waiting on the user in the terminal — a question, a plan, or leave to act — the one
|
|
206
|
+
* moment the model can't tell anyone, because it can't run anything. The agents' hooks call this
|
|
207
|
+
* (see hooks.mjs), in steps: `request` notes what is being asked the moment it is, `prompt` pings
|
|
208
|
+
* once it has gone unanswered with nobody at the keyboard, and `answered` closes the ping once the
|
|
209
|
+
* user has dealt with it in the terminal. Nothing here ever fails loudly — it runs inside a hook,
|
|
210
|
+
* and there is nobody to read an error.
|
|
211
|
+
*/
|
|
212
|
+
export async function permission(ctx, args) {
|
|
213
|
+
const id = ctx.session?.id;
|
|
214
|
+
if (!id) return ok({ ok: false, skipped: "no session" });
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
|
|
217
|
+
if (args.event === "request") {
|
|
218
|
+
const tool = typeof args.tool === "string" ? args.tool : "";
|
|
219
|
+
const input = args.input && typeof args.input === "object" ? args.input : {};
|
|
220
|
+
// An id when the agent gives one (OpenCode, Codex), so later steps can name this exact request.
|
|
221
|
+
const request = { tool, input, at: new Date(now).toISOString(), ...(typeof args.id === "string" && args.id ? { id: args.id } : {}) };
|
|
222
|
+
// The agent's hooks run side by side, so a quick answer (or a tool that failed at once) can
|
|
223
|
+
// arrive before the question it answers. It left a marker; the question is then already closed.
|
|
224
|
+
const early = request.id ? (ctx.session.permissions ?? []).find((r) => r.id === request.id && r.answeredAt) : null;
|
|
225
|
+
if (early) {
|
|
226
|
+
sessions.touch(id, { permissions: (ctx.session.permissions ?? []).map((r) => (r === early ? { ...early, tool, input } : r)) });
|
|
227
|
+
return ok({ ok: false, skipped: "answered before it was noted", about: tool });
|
|
228
|
+
}
|
|
229
|
+
sessions.touch(id, { permissions: rememberRequest(ctx.session.permissions, request, now) });
|
|
230
|
+
return ok({ ok: true, noted: tool });
|
|
231
|
+
}
|
|
232
|
+
if (args.event === "answered") return questionAnswered(ctx, args);
|
|
233
|
+
// The turn was cut short (Codex): whatever it was still asking won't be answered now.
|
|
234
|
+
if (args.event === "interrupted") {
|
|
235
|
+
const open = (ctx.session.permissions ?? []).filter((r) => r.id && !r.answeredAt);
|
|
236
|
+
const closed = [];
|
|
237
|
+
for (const r of open) {
|
|
238
|
+
const fresh = { ...ctx, session: { ...ctx.session, permissions: sessions.get(id)?.permissions ?? [] } };
|
|
239
|
+
closed.push((await questionAnswered(fresh, { id: r.id, tool: r.tool, outcome: "dismissed" })).data);
|
|
240
|
+
}
|
|
241
|
+
return ok({ ok: true, closed: closed.some((c) => c.closed), about: open.length ? `${open.length} open` : "nothing open" });
|
|
242
|
+
}
|
|
243
|
+
if (args.event !== "prompt") fail(`permission: unknown event "${args.event}"`, 2);
|
|
244
|
+
|
|
245
|
+
const config = ctx.config;
|
|
246
|
+
if (!config) return ok({ ok: false, skipped: "not configured" });
|
|
247
|
+
// "Only when I ask for one" covers this too: nobody asked.
|
|
248
|
+
if (config.when === "on-request") return ok({ ok: false, skipped: "when is on-request" });
|
|
249
|
+
|
|
250
|
+
const notice = typeof args.notice === "string" ? args.notice : "";
|
|
251
|
+
const requests = ctx.session.permissions ?? [];
|
|
252
|
+
// A ping for a known request (the agent's own id) is about exactly that one; if it has been dealt
|
|
253
|
+
// with in the meantime, that is the whole point of having waited.
|
|
254
|
+
let request;
|
|
255
|
+
if (typeof args.id === "string" && args.id) {
|
|
256
|
+
request = requests.find((r) => r.id === args.id);
|
|
257
|
+
if (!request) return ok({ ok: false, skipped: "no such request" });
|
|
258
|
+
if (request.answeredAt) return ok({ ok: false, skipped: "already answered", about: request.tool });
|
|
259
|
+
if (request.pingedAt) return ok({ ok: false, skipped: "already pinged", about: request.tool });
|
|
260
|
+
} else {
|
|
261
|
+
request = matchRequest(requests, notice, now);
|
|
262
|
+
}
|
|
263
|
+
// A question always pings. Leave to run something only when the user turned that on: most of
|
|
264
|
+
// those come while they're at the keyboard, approving as the agent goes.
|
|
265
|
+
if (!isQuestion(request?.tool) && config.permissionPings !== true) {
|
|
266
|
+
return ok({ ok: false, skipped: "permission pings are off", about: request?.tool ?? null });
|
|
267
|
+
}
|
|
268
|
+
// With nothing noted to tie it to, a second notice for the same prompt is told apart by time.
|
|
269
|
+
if (!request && ctx.session.permissionPingAt && now - Date.parse(ctx.session.permissionPingAt) < 60_000) {
|
|
270
|
+
return ok({ ok: false, skipped: "already pinged" });
|
|
271
|
+
}
|
|
272
|
+
const { kind, message, say } = permissionNotice({ request, notice, cwd: args.cwd || ctx.session.cwd });
|
|
273
|
+
const sent = await notify(ctx, {
|
|
274
|
+
kind,
|
|
275
|
+
message,
|
|
276
|
+
say,
|
|
277
|
+
project: args.cwd ? path.basename(args.cwd) : ctx.session.project,
|
|
278
|
+
...(ctx.session.name ? { session: ctx.session.name } : {}),
|
|
279
|
+
...(ctx.session.model ? { "agent-model": ctx.session.model } : {}),
|
|
280
|
+
});
|
|
281
|
+
const pingedAt = new Date().toISOString();
|
|
282
|
+
const slack = sent.data.results?.slack;
|
|
283
|
+
// Where the message went, so answering in the terminal can close it.
|
|
284
|
+
const posted = slack?.ts ? { channel: slack.channel, ts: slack.ts, project: args.cwd ? path.basename(args.cwd) : ctx.session.project } : {};
|
|
285
|
+
sessions.touch(id, {
|
|
286
|
+
state: "waiting",
|
|
287
|
+
permissionPingAt: pingedAt,
|
|
288
|
+
permissions: requests.map((r) => (r === request ? { ...r, pingedAt, ...posted } : r)),
|
|
289
|
+
});
|
|
290
|
+
return ok({ ...sent.data, about: request?.tool ?? null }, 0);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The user dealt with it in the terminal. The Slack message about it would otherwise sit there
|
|
295
|
+
* asking, so it is rewritten to say what they chose — and one answered before any ping went out is
|
|
296
|
+
* marked, so a late notice for it doesn't ping after all.
|
|
297
|
+
*/
|
|
298
|
+
async function questionAnswered(ctx, args) {
|
|
299
|
+
const id = ctx.session.id;
|
|
300
|
+
const requests = ctx.session.permissions ?? [];
|
|
301
|
+
// By id when the agent gives one; otherwise the newest open request for this tool, since
|
|
302
|
+
// questions are answered one at a time, in order.
|
|
303
|
+
const request = typeof args.id === "string" && args.id
|
|
304
|
+
? requests.find((r) => r.id === args.id && !r.answeredAt)
|
|
305
|
+
: [...requests].reverse().find((r) => r.tool === args.tool && !r.answeredAt);
|
|
306
|
+
if (!request) {
|
|
307
|
+
// Arrived ahead of the question it answers: leave a marker for it (see `request` above).
|
|
308
|
+
if (typeof args.id === "string" && args.id && !requests.some((r) => r.id === args.id)) {
|
|
309
|
+
const at = new Date().toISOString();
|
|
310
|
+
sessions.touch(id, { permissions: rememberRequest(requests, { id: args.id, tool: args.tool ?? "", input: {}, at, answeredAt: at, pingedAt: at }) });
|
|
311
|
+
return ok({ ok: true, closed: false, about: args.tool ?? null, early: true });
|
|
312
|
+
}
|
|
313
|
+
return ok({ ok: false, skipped: "no question on record" });
|
|
314
|
+
}
|
|
315
|
+
const answeredAt = new Date().toISOString();
|
|
316
|
+
sessions.touch(id, {
|
|
317
|
+
state: "working",
|
|
318
|
+
permissions: requests.map((r) => (r === request ? { ...r, answeredAt, pingedAt: r.pingedAt ?? answeredAt } : r)),
|
|
319
|
+
});
|
|
320
|
+
if (!request.ts) return ok({ ok: true, closed: false, about: request.tool });
|
|
321
|
+
const config = ctx.config;
|
|
322
|
+
const outcome = ["answered", "dismissed", "allowed", "denied"].includes(args.outcome) ? args.outcome : "answered";
|
|
323
|
+
const { message } = answeredNotice(request, args.response, outcome);
|
|
324
|
+
const headline = slackText({ kind: outcome === "dismissed" || outcome === "denied" ? "info" : "done", message, project: request.project });
|
|
325
|
+
const label = config ? labelFor(ctx, { session: ctx.session.name, "agent-model": ctx.session.model }, config) : "";
|
|
326
|
+
try {
|
|
327
|
+
await updateMessage(request.channel, request.ts, { text: stripTags(headline), blocks: messageBlocks({ headline, label }), color: colourOf(ctx) });
|
|
328
|
+
return ok({ ok: true, closed: true, about: request.tool });
|
|
329
|
+
} catch (e) {
|
|
330
|
+
return ok({ ok: false, closed: false, about: request.tool, error: e.message });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** `--actions "Open a PR|Run the full suite"`: follow-ups offered as buttons on the message. */
|
|
335
|
+
function followUpActions(args, config, methods) {
|
|
336
|
+
if (args.actions === undefined) return null;
|
|
337
|
+
if (!methods.includes("slack")) fail("--actions needs slack");
|
|
338
|
+
if (!slackAppToken()) fail("--actions needs Slack buttons (an app-level token; see slack-setup)");
|
|
339
|
+
const choices = parseChoices(typeof args.actions === "string" ? args.actions : undefined, { min: 1 });
|
|
340
|
+
const onExpire = args["on-expire"] === undefined ? "nothing" : String(args["on-expire"]);
|
|
341
|
+
if (!ON_EXPIRE.includes(onExpire)) fail(`--on-expire must be one of: ${ON_EXPIRE.join(", ")}`);
|
|
342
|
+
return {
|
|
343
|
+
choices,
|
|
344
|
+
recommended: parseRecommend(args.recommend, choices),
|
|
345
|
+
allowOther: args.other === undefined || !["false", "no", "0", "off"].includes(String(args.other).toLowerCase()),
|
|
346
|
+
onExpire,
|
|
347
|
+
expiresIn: parseDuration(args.expires, DEFAULT_ACTIONS_EXPIRY),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------- progress
|
|
352
|
+
|
|
353
|
+
export async function progress(ctx, args) {
|
|
354
|
+
const config = requireConfig(ctx);
|
|
355
|
+
const message = typeof args.message === "string" ? args.message : (args._ ?? []).join(" ");
|
|
356
|
+
if (!message) fail("--message is required");
|
|
357
|
+
if (!config.methods.includes("slack") || !config.slack.target) {
|
|
358
|
+
return ok({ ok: false, skipped: true, reason: "progress messages need slack in the configured methods" });
|
|
359
|
+
}
|
|
360
|
+
const key = progressStore.progressKey(typeof args.key === "string" ? args.key : args.project);
|
|
361
|
+
const done = Boolean(args.done);
|
|
362
|
+
// Buttons pressed and messages sent since the last update: this call counts as the agent's check.
|
|
363
|
+
await ctx.catchUp();
|
|
364
|
+
const updatedAt = new Date().toISOString();
|
|
365
|
+
const headline = slackText({ kind: done ? "done" : "progress", message, project: args.project });
|
|
366
|
+
const label = labelFor(ctx, args, config);
|
|
367
|
+
const colour = colourOf(ctx);
|
|
368
|
+
setState(ctx, done ? "done" : "working");
|
|
369
|
+
|
|
370
|
+
let state = progressStore.current(key);
|
|
371
|
+
const seen = progressStore.acknowledge(state);
|
|
372
|
+
const control = seen.control;
|
|
373
|
+
const payload = { text: stripTags(headline), blocks: progressBlocks({ headline, updatedAt, label, key, control, notes: seen.notes, done }), color: colour };
|
|
374
|
+
|
|
375
|
+
let action = "updated";
|
|
376
|
+
if (state) {
|
|
377
|
+
try {
|
|
378
|
+
await updateMessage(state.channel, state.ts, payload);
|
|
379
|
+
} catch (e) {
|
|
380
|
+
// The message was deleted or can't be edited any more: start a new one.
|
|
381
|
+
if (!["message_not_found", "cant_update_message", "channel_not_found"].includes(e.code)) throw e;
|
|
382
|
+
state = null;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!state) {
|
|
386
|
+
state = await postMessage(config, payload);
|
|
387
|
+
action = "posted";
|
|
388
|
+
}
|
|
389
|
+
sessions.bindThread(state.ts, ctx.session?.id);
|
|
390
|
+
if (done) {
|
|
391
|
+
progressStore.finish(key);
|
|
392
|
+
} else {
|
|
393
|
+
// Merge with a fresh read, so a click or note that landed during the update isn't lost.
|
|
394
|
+
const merged = progressStore.mergeAcknowledged(progressStore.current(key), seen);
|
|
395
|
+
progressStore.save(key, {
|
|
396
|
+
channel: state.channel, ts: state.ts, updatedAt, headline, label, colour,
|
|
397
|
+
session: ctx.session?.id ?? null, nickname: ctx.session?.nickname ?? "", ...merged,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
await inbox.markNotesRead(seen.unread);
|
|
401
|
+
return ok({ ok: true, key, action, done, ...(await report(ctx, control, seen.unread)) });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The part of `progress` / `control` output the agent acts on. */
|
|
405
|
+
async function report(ctx, control, unread) {
|
|
406
|
+
const messages = ctx.session ? await inbox.takeForSession(ctx.session.id) : [];
|
|
407
|
+
const waiting = await inbox.takeInbox();
|
|
408
|
+
return {
|
|
409
|
+
control: control.state,
|
|
410
|
+
...(control.by ? { controlBy: control.by } : {}),
|
|
411
|
+
...(unread.length ? { notes: unread.map((n) => ({ text: n.text, by: n.by, at: n.at })) } : {}),
|
|
412
|
+
...(messages.length ? { messages } : {}),
|
|
413
|
+
...(waiting.length ? { inbox: waiting } : {}),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Record that the agent has seen the latest Pause / Stop and notes, and show that in the message. */
|
|
418
|
+
async function acknowledgeProgress(key) {
|
|
419
|
+
const state = progressStore.current(key);
|
|
420
|
+
const seen = progressStore.acknowledge(state);
|
|
421
|
+
if (state && seen.changed) {
|
|
422
|
+
const merged = progressStore.mergeAcknowledged(progressStore.current(key), seen);
|
|
423
|
+
const next = { ...state, ...merged };
|
|
424
|
+
progressStore.save(key, next);
|
|
425
|
+
try {
|
|
426
|
+
await updateMessage(state.channel, state.ts, { text: stripTags(state.headline), blocks: progressBlocks({ ...next, key }), color: state.colour });
|
|
427
|
+
} catch {
|
|
428
|
+
// The agent still gets its answer; the message just keeps its previous wording.
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return { state, control: seen.control, unread: seen.unread };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function control(ctx, args) {
|
|
435
|
+
const key = progressStore.progressKey(typeof args.key === "string" ? args.key : args.project);
|
|
436
|
+
const timeout = args.wait === undefined ? 0 : parseDuration(args.wait, Infinity);
|
|
437
|
+
const deadline = Date.now() + timeout * 1000;
|
|
438
|
+
const unread = [];
|
|
439
|
+
while (true) {
|
|
440
|
+
await ctx.catchUp();
|
|
441
|
+
const { state, control: c, unread: fresh } = await acknowledgeProgress(key);
|
|
442
|
+
await inbox.markNotesRead(fresh);
|
|
443
|
+
unread.push(...fresh);
|
|
444
|
+
if (c.state === "pause") setState(ctx, "paused");
|
|
445
|
+
if (c.state !== "pause" || Date.now() >= deadline) {
|
|
446
|
+
return ok(
|
|
447
|
+
{ key, active: Boolean(state), ...(await report(ctx, c, unread)) },
|
|
448
|
+
c.state === "pause" && timeout > 0 ? EXIT_STILL_PENDING : 0,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
await ctx.idle(Math.min(1000, Math.max(0, deadline - Date.now())));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------- messages
|
|
456
|
+
|
|
457
|
+
/** Messages for this session, plus anything in the shared inbox nobody claimed. */
|
|
458
|
+
export async function messages(ctx) {
|
|
459
|
+
requireConfig(ctx);
|
|
460
|
+
if (!slackToken()) fail("Slack isn't set up (see slack-setup)");
|
|
461
|
+
await ctx.catchUp();
|
|
462
|
+
const mine = ctx.session ? await inbox.takeForSession(ctx.session.id) : [];
|
|
463
|
+
const waiting = await inbox.takeInbox();
|
|
464
|
+
return ok({ ok: true, messages: mine, ...(waiting.length ? { inbox: waiting } : {}) });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Park until the user says something. This is what keeps an agent reachable once its work is done:
|
|
469
|
+
* run it in the background as the last thing in a turn, and when it returns the agent's own harness
|
|
470
|
+
* starts a new turn with whatever arrived. Nothing is polled — the daemon wakes it the moment
|
|
471
|
+
* anything lands — and nothing is spent while it waits, so the only cost is the turn it wakes into.
|
|
472
|
+
*
|
|
473
|
+
* It does not park itself again when the time runs out. Re-parking forever is the one version of
|
|
474
|
+
* this that costs real money, so it is the caller's decision, not a default.
|
|
475
|
+
*/
|
|
476
|
+
/** Progress messages of this session with a button press or a note the agent hasn't dealt with. */
|
|
477
|
+
function steering(sessionId) {
|
|
478
|
+
if (!sessionId) return [];
|
|
479
|
+
return progressStore.active()
|
|
480
|
+
.filter((p) => p.session === sessionId)
|
|
481
|
+
.filter((p) => (p.control?.state && p.control.state !== "running" && !p.control.ack) || (p.notes ?? []).some((n) => !n.readAt))
|
|
482
|
+
.map((p) => p.key)
|
|
483
|
+
.filter(Boolean);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export async function listen(ctx, args) {
|
|
487
|
+
const seconds = parseDuration(args.wait ?? args.timeout, DEFAULT_LISTEN);
|
|
488
|
+
const startedAt = Date.now();
|
|
489
|
+
const deadline = startedAt + seconds * 1000;
|
|
490
|
+
setState(ctx, "listening");
|
|
491
|
+
// Whatever this command had to do is done; from here it is only waiting. Saying so lets the
|
|
492
|
+
// caller pick the waiting back up if the daemon hands over mid-park, without repeating the rest
|
|
493
|
+
// of the command — which matters for `notify --then-listen`, where the rest was a notification.
|
|
494
|
+
ctx.emit?.("parked", { until: new Date(deadline).toISOString() });
|
|
495
|
+
try {
|
|
496
|
+
while (true) {
|
|
497
|
+
await ctx.catchUp();
|
|
498
|
+
const woke = await owedTo(ctx, {});
|
|
499
|
+
if (woke.messages?.length || woke.answers?.length) {
|
|
500
|
+
return ok({ ok: true, woke: woke.messages?.length ? "message" : "answer", ...woke });
|
|
501
|
+
}
|
|
502
|
+
// Pause, Stop and notes should reach a parked agent too: being reachable and being steerable
|
|
503
|
+
// are the same thing. `control` does the acknowledging, so this only points at it.
|
|
504
|
+
const keys = steering(ctx.session?.id);
|
|
505
|
+
if (keys.length) return ok({ ok: true, woke: "control", keys, next: `control --key ${keys[0]}` });
|
|
506
|
+
// The agent is doing something again — the user must have reached it another way. Stand down
|
|
507
|
+
// rather than interrupting it half an hour from now for nothing.
|
|
508
|
+
if ((ctx.lastActivity?.() ?? 0) > startedAt) {
|
|
509
|
+
return ok({ ok: false, woke: "resumed" }, EXIT_STILL_PENDING);
|
|
510
|
+
}
|
|
511
|
+
if (Date.now() >= deadline) {
|
|
512
|
+
return ok({ ok: false, woke: "timeout", listenedFor: formatElapsed(seconds * 1000) }, EXIT_STILL_PENDING);
|
|
513
|
+
}
|
|
514
|
+
await ctx.idle(Math.min(1000, Math.max(0, deadline - Date.now())));
|
|
515
|
+
}
|
|
516
|
+
} finally {
|
|
517
|
+
setState(ctx, "working");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// The native window is installed on request rather than vendored here.
|
|
522
|
+
const TRAY_PACKAGES = ["@webviewjs/webview"];
|
|
523
|
+
const TRAY_PACKAGE = TRAY_PACKAGES[0];
|
|
524
|
+
const TRAY_ENTRY = path.resolve(SCRIPTS_DIR, "..", "tray", "main.mjs");
|
|
525
|
+
const TRAY_INSTALL_TIMEOUT_MS = 5 * 60_000;
|
|
526
|
+
|
|
527
|
+
/** Run a program and hand back its output, without holding up the daemon while it runs. */
|
|
528
|
+
function capture(file, argv, timeout = 15_000) {
|
|
529
|
+
return new Promise((resolve, reject) => {
|
|
530
|
+
execFile(file, argv, { encoding: "utf8", windowsHide: true, timeout, maxBuffer: 8 * 1024 * 1024 }, (e, stdout) => (e ? reject(e) : resolve(stdout)));
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Every tray process on this machine, found by looking rather than by trusting a file. A pid file
|
|
536
|
+
* goes stale the moment something is killed rather than asked to stop, and two trays is worse than
|
|
537
|
+
* none: each draws its own icon and neither knows about the other.
|
|
538
|
+
*
|
|
539
|
+
* Matched on the exact path of this skill's tray script, not on anything that merely mentions a
|
|
540
|
+
* tray — an agent whose message says "fixed tray/main.mjs" is not a tray, and must not be stopped.
|
|
541
|
+
* Asynchronous because listing processes on Windows takes seconds, and the daemon serving this
|
|
542
|
+
* cannot stop answering Slack for that long.
|
|
543
|
+
*/
|
|
544
|
+
export async function trayProcesses() {
|
|
545
|
+
const mine = [];
|
|
546
|
+
const target = TRAY_ENTRY.toLowerCase();
|
|
547
|
+
try {
|
|
548
|
+
if (process.platform === "win32") {
|
|
549
|
+
const out = await capture("powershell.exe", [
|
|
550
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
551
|
+
"Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }",
|
|
552
|
+
]);
|
|
553
|
+
for (const line of out.split(/\r?\n/)) {
|
|
554
|
+
const tab = line.indexOf("\t");
|
|
555
|
+
if (tab === -1) continue;
|
|
556
|
+
const pid = Number(line.slice(0, tab).trim());
|
|
557
|
+
const command = line.slice(tab + 1).replace(/"/g, "").replace(/\//g, "\\").toLowerCase();
|
|
558
|
+
if (pid && pid !== process.pid && command.includes(target)) mine.push(pid);
|
|
559
|
+
}
|
|
560
|
+
} else {
|
|
561
|
+
const out = await capture("ps", ["-eo", "pid=,args="]);
|
|
562
|
+
for (const line of out.split("\n")) {
|
|
563
|
+
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
564
|
+
if (!m) continue;
|
|
565
|
+
const pid = Number(m[1]);
|
|
566
|
+
// The script is node's first argument; a path that merely appears later is someone's text.
|
|
567
|
+
const argv = m[2].split(/\s+/);
|
|
568
|
+
if (pid !== process.pid && argv[1]?.toLowerCase() === target) mine.push(pid);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
} catch {
|
|
572
|
+
// Without a process list the pid file is all there is; better than refusing to work.
|
|
573
|
+
try {
|
|
574
|
+
const raw = fs.readFileSync(path.join(rogerRogerHome(), "tray.pid"), "utf8").trim();
|
|
575
|
+
const pid = Number(raw.startsWith("{") ? JSON.parse(raw).pid : raw);
|
|
576
|
+
if (pidAlive(pid)) mine.push(pid);
|
|
577
|
+
} catch {
|
|
578
|
+
// Nothing running, or nothing we can see.
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return [...new Set(mine)];
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export const trayInstalled = () => fs.existsSync(path.join(rogerRogerHome(), "node_modules", "@webviewjs", "webview", "package.json"));
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Start the tray if it isn't running. Started from the user's home directory rather than wherever
|
|
588
|
+
* the caller happened to be, so no project folder is held open for as long as the tray lives, and
|
|
589
|
+
* with the machine's environment rather than an agent's, so what it does isn't credited to that agent.
|
|
590
|
+
*/
|
|
591
|
+
export async function startTray() {
|
|
592
|
+
if (!trayInstalled()) return { running: false, installed: false };
|
|
593
|
+
const already = await trayProcesses();
|
|
594
|
+
if (already.length) return { running: true, pids: already, note: "already running" };
|
|
595
|
+
const child = spawn(process.execPath, [TRAY_ENTRY], {
|
|
596
|
+
cwd: os.homedir(),
|
|
597
|
+
detached: true,
|
|
598
|
+
stdio: "ignore",
|
|
599
|
+
windowsHide: true,
|
|
600
|
+
env: { ...machineEnv(), ROGER_ROGER_CALLER: "tray" },
|
|
601
|
+
});
|
|
602
|
+
child.unref();
|
|
603
|
+
return { running: true, starting: true, pid: child.pid };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* The tray icon: start it, stop it, or say what it is doing. The native part is not vendored here —
|
|
608
|
+
* it is installed into ~/.roger-roger on request, so the skill itself stays a dependency-free Node
|
|
609
|
+
* script and everything works with no tray at all. Once installed, the daemon starts it by itself
|
|
610
|
+
* (`setup --tray off` stops that).
|
|
611
|
+
*/
|
|
612
|
+
export async function tray(ctx, args) {
|
|
613
|
+
const what = (args._ ?? [])[0] ?? "status";
|
|
614
|
+
const home = rogerRogerHome();
|
|
615
|
+
|
|
616
|
+
if (what === "status") {
|
|
617
|
+
const all = await trayProcesses();
|
|
618
|
+
return ok({
|
|
619
|
+
ok: true,
|
|
620
|
+
installed: trayInstalled(),
|
|
621
|
+
running: all.length > 0,
|
|
622
|
+
autostart: ctx.config?.tray !== "off",
|
|
623
|
+
...(all.length ? { pids: all } : {}),
|
|
624
|
+
...(all.length > 1 ? { warning: `${all.length} trays are running; "tray stop" ends all of them` } : {}),
|
|
625
|
+
package: TRAY_PACKAGE,
|
|
626
|
+
home,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (what === "install") {
|
|
631
|
+
fs.mkdirSync(home, { recursive: true });
|
|
632
|
+
const manifest = path.join(home, "package.json");
|
|
633
|
+
if (!fs.existsSync(manifest)) fs.writeFileSync(manifest, JSON.stringify({ name: "roger-roger-local", private: true }, null, 2) + "\n", "utf8");
|
|
634
|
+
const done = await new Promise((resolve) => {
|
|
635
|
+
const npm = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["install", "--no-audit", "--no-fund", ...TRAY_PACKAGES], {
|
|
636
|
+
cwd: home,
|
|
637
|
+
stdio: "ignore",
|
|
638
|
+
windowsHide: true,
|
|
639
|
+
shell: process.platform === "win32",
|
|
640
|
+
});
|
|
641
|
+
// A registry that never answers would otherwise leave this waiting for ever.
|
|
642
|
+
const timer = setTimeout(() => {
|
|
643
|
+
npm.kill();
|
|
644
|
+
resolve(false);
|
|
645
|
+
}, TRAY_INSTALL_TIMEOUT_MS);
|
|
646
|
+
npm.on("error", () => (clearTimeout(timer), resolve(false)));
|
|
647
|
+
npm.on("close", (code) => (clearTimeout(timer), resolve(code === 0)));
|
|
648
|
+
});
|
|
649
|
+
if (!done) fail(`could not install the tray packages; try it by hand: npm install --prefix "${home}" ${TRAY_PACKAGES.join(" ")}`);
|
|
650
|
+
return ok({ ok: true, installed: true, packages: TRAY_PACKAGES, home });
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (what === "stop") {
|
|
654
|
+
const all = await trayProcesses();
|
|
655
|
+
if (!all.length) return ok({ ok: true, running: false, note: "it wasn't running" });
|
|
656
|
+
const stopped = [];
|
|
657
|
+
const stubborn = [];
|
|
658
|
+
for (const pid of all) {
|
|
659
|
+
try {
|
|
660
|
+
process.kill(pid);
|
|
661
|
+
stopped.push(pid);
|
|
662
|
+
} catch (e) {
|
|
663
|
+
stubborn.push({ pid, error: e.message });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
fs.rmSync(path.join(home, "tray.pid"), { force: true });
|
|
667
|
+
return ok({ ok: stubborn.length === 0, stopped, ...(stubborn.length ? { stubborn } : {}) });
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (what !== "start") fail("tray takes status, start, stop or install");
|
|
671
|
+
const started = await startTray();
|
|
672
|
+
if (started.installed === false) {
|
|
673
|
+
fail(`the tray needs ${TRAY_PACKAGE}, which isn't installed yet. Run \`tray install\` (about 4 MB, no compiler) and then \`tray start\`.`);
|
|
674
|
+
}
|
|
675
|
+
return ok({ ok: true, ...started });
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Quiet for a while, on purpose. Quiet hours are a schedule and a meeting is not one, so this is
|
|
680
|
+
* the ad-hoc version: sound and speech off until it runs out, Slack untouched.
|
|
681
|
+
*/
|
|
682
|
+
export function snooze(ctx, args) {
|
|
683
|
+
const value = (args._ ?? [])[0] ?? args.for ?? "1h";
|
|
684
|
+
// Snoozing is not setting up: saving here would write the defaults and call that the user's choice.
|
|
685
|
+
if (!ctx.config) fail("not configured yet, so there is nothing to snooze: run setup first", EXIT_NOT_CONFIGURED);
|
|
686
|
+
const config = applySetup(ctx.config, { snooze: value }, listSounds());
|
|
687
|
+
saveConfig(config);
|
|
688
|
+
ctx.config = config;
|
|
689
|
+
return ok({ ok: true, snoozeUntil: config.snoozeUntil, ...(config.snoozeUntil ? {} : { note: "not snoozed" }) });
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---------------------------------------------------------------- sessions
|
|
693
|
+
|
|
694
|
+
export function listSessions(ctx, args) {
|
|
695
|
+
const all = args.all ? sessions.all() : sessions.live();
|
|
696
|
+
return ok({
|
|
697
|
+
ok: true,
|
|
698
|
+
me: ctx.session?.id ?? null,
|
|
699
|
+
sessions: all.map((s) => ({
|
|
700
|
+
owes: decisions.undeliveredFor(s.id).map((d) => d.id),
|
|
701
|
+
nickname: s.nickname,
|
|
702
|
+
swatch: s.swatch,
|
|
703
|
+
colour: s.colour,
|
|
704
|
+
project: s.project,
|
|
705
|
+
name: s.name,
|
|
706
|
+
model: s.model,
|
|
707
|
+
state: s.state,
|
|
708
|
+
live: s.live,
|
|
709
|
+
queued: s.queue?.length ?? 0,
|
|
710
|
+
lastSeen: s.lastSeen,
|
|
711
|
+
id: s.id,
|
|
712
|
+
...(s.id === ctx.session?.id ? { me: true } : {}),
|
|
713
|
+
})),
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function renameSession(ctx, args) {
|
|
718
|
+
const [from, to] = args._ ?? [];
|
|
719
|
+
const target = from ? sessions.byNickname(from) ?? sessions.get(from) : ctx.session;
|
|
720
|
+
if (!target) fail(`no session called "${from}"`);
|
|
721
|
+
const nickname = String(to ?? "").toLowerCase();
|
|
722
|
+
if (!nickname) fail("give the new name, e.g. `sessions rename sage plum`");
|
|
723
|
+
const renamed = sessions.rename(target.id, nickname);
|
|
724
|
+
// A new name goes on the Herdr tab too, if the session sits in one.
|
|
725
|
+
labelPane(sessions.decorate(renamed), { log: ctx.log, touch: sessions.touch }).catch(() => {});
|
|
726
|
+
return ok({ ok: true, session: sessions.decorate(renamed) });
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export function endSession(ctx, args) {
|
|
730
|
+
const who = (args._ ?? [])[0];
|
|
731
|
+
const target = who ? sessions.byNickname(who) ?? sessions.get(who) : ctx.session;
|
|
732
|
+
if (!target) fail("no such session");
|
|
733
|
+
sessions.end(target.id);
|
|
734
|
+
return ok({ ok: true, ended: target.nickname });
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ---------------------------------------------------------------- questions
|
|
738
|
+
|
|
739
|
+
function decisionReport(d) {
|
|
740
|
+
return {
|
|
741
|
+
id: d.id,
|
|
742
|
+
status: d.status,
|
|
743
|
+
question: d.question,
|
|
744
|
+
choices: d.choices,
|
|
745
|
+
multi: Boolean(d.multi),
|
|
746
|
+
recommended: d.recommended ?? null,
|
|
747
|
+
onExpire: d.onExpire ?? "nothing",
|
|
748
|
+
...(d.answer ? { answer: d.answer } : {}),
|
|
749
|
+
...(d.reason ? { reason: d.reason } : {}),
|
|
750
|
+
createdAt: d.createdAt,
|
|
751
|
+
expiresAt: d.expiresAt,
|
|
752
|
+
...(d.resolvedAt ? { resolvedAt: d.resolvedAt } : {}),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function exitFor(d) {
|
|
757
|
+
if (d.status === "answered") return 0;
|
|
758
|
+
if (d.status === "pending") return EXIT_STILL_PENDING;
|
|
759
|
+
return EXIT_CLOSED;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export async function ask(ctx, args) {
|
|
763
|
+
const config = requireConfig(ctx);
|
|
764
|
+
if (!slackToken()) fail("asking needs Slack: SLACK_API_BOT_TOKEN is not set");
|
|
765
|
+
if (!config.slack.target) fail("asking needs Slack: run setup with --slack-target");
|
|
766
|
+
if (!slackAppToken()) {
|
|
767
|
+
fail("asking needs Slack buttons: set ROGER_ROGER_SLACK_APP_TOKEN (or SLACK_APP_TOKEN) to an app-level xapp- token with Socket Mode and Interactivity on (see SKILL.md, Requirements)");
|
|
768
|
+
}
|
|
769
|
+
if (typeof WebSocket !== "function") fail("asking needs Node 22 or newer (built-in WebSocket)");
|
|
770
|
+
|
|
771
|
+
const question = typeof args.question === "string" ? args.question : (args._ ?? []).join(" ");
|
|
772
|
+
if (!question) fail("--question is required");
|
|
773
|
+
const draft = draftOf(args);
|
|
774
|
+
const choices = draft && args.choices === undefined ? [DEFAULT_DRAFT_CHOICE] : parseChoices(args.choices, { min: draft ? 1 : 2 });
|
|
775
|
+
const multi = Boolean(args.multi) && !["false", "no", "0", "off"].includes(String(args.multi).toLowerCase());
|
|
776
|
+
if (draft && multi) fail("--draft can't be combined with --multi");
|
|
777
|
+
const recommended = multi ? parseRecommendMany(args.recommend, choices) : parseRecommend(args.recommend, choices);
|
|
778
|
+
const label = labelFor(ctx, args, config);
|
|
779
|
+
const colour = colourOf(ctx);
|
|
780
|
+
const kind = args.kind ?? "input";
|
|
781
|
+
if (!KINDS.includes(kind)) fail(`--kind must be one of: ${KINDS.join(", ")}`);
|
|
782
|
+
const expiresIn = parseDuration(args.expires, DEFAULT_ASK_EXPIRY);
|
|
783
|
+
const details = typeof args.details === "string" ? args.details : "";
|
|
784
|
+
const allowOther = args.other === undefined || !["false", "no", "0", "off"].includes(String(args.other).toLowerCase());
|
|
785
|
+
const onExpire = args["on-expire"] === undefined ? config.onExpire : String(args["on-expire"]);
|
|
786
|
+
if (!ON_EXPIRE.includes(onExpire)) fail(`--on-expire must be one of: ${ON_EXPIRE.join(", ")}`);
|
|
787
|
+
const remind = args.remind === undefined ? (config.remind ?? []).map((d) => parseDuration(d)) : parseRemind(args.remind);
|
|
788
|
+
const files = attachments(args);
|
|
789
|
+
|
|
790
|
+
const { methods, muted } = methodsFor(args, config);
|
|
791
|
+
const audible = methods.filter((m) => m === "sound" || m === "speech");
|
|
792
|
+
const replyTo = typeof args["reply-to"] === "string" ? args["reply-to"].trim() : "";
|
|
793
|
+
const text = spoken(ctx, config, args, question);
|
|
794
|
+
if (audible.length) warmUpAudio();
|
|
795
|
+
const prepared = audible.includes("speech") ? prepareSpeech(text, config) : null;
|
|
796
|
+
|
|
797
|
+
const id = decisions.newId();
|
|
798
|
+
const headline = slackText({ kind, message: question, project: args.project, mention: config.slack.mention });
|
|
799
|
+
const fallback = `${stripTags(headline)} (${choices.join(" / ")})`;
|
|
800
|
+
const now = Date.now();
|
|
801
|
+
const expiresAt = new Date(now + expiresIn * 1000).toISOString();
|
|
802
|
+
const { channel, ts } = await postMessage(config, {
|
|
803
|
+
text: fallback,
|
|
804
|
+
blocks: decisionBlocks({ id, headline, details, choices, recommended, multi, allowOther, expiresAt, onExpire, label, draft }),
|
|
805
|
+
color: colour,
|
|
806
|
+
...(replyTo ? { thread_ts: replyTo } : {}),
|
|
807
|
+
});
|
|
808
|
+
await markAnswered(channel, replyTo);
|
|
809
|
+
const attached = files.length ? await uploadFiles(channel, files).catch((e) => ({ error: e.message })) : null;
|
|
810
|
+
|
|
811
|
+
decisions.create({
|
|
812
|
+
id,
|
|
813
|
+
status: "pending",
|
|
814
|
+
question: stripTags(question),
|
|
815
|
+
details,
|
|
816
|
+
...(draft ? { draft } : {}),
|
|
817
|
+
...(typeof args.session === "string" ? { session: args.session.trim() } : {}),
|
|
818
|
+
sessionId: ctx.session?.id ?? null,
|
|
819
|
+
nickname: ctx.session?.nickname ?? null,
|
|
820
|
+
swatch: ctx.session?.swatch ?? null,
|
|
821
|
+
headline,
|
|
822
|
+
choices,
|
|
823
|
+
recommended,
|
|
824
|
+
multi,
|
|
825
|
+
label,
|
|
826
|
+
colour,
|
|
827
|
+
allowOther,
|
|
828
|
+
onExpire,
|
|
829
|
+
reminders: remind.filter((sec) => sec < expiresIn).map((sec) => new Date(now + sec * 1000).toISOString()),
|
|
830
|
+
remindersSent: 0,
|
|
831
|
+
channel,
|
|
832
|
+
ts,
|
|
833
|
+
createdAt: new Date(now).toISOString(),
|
|
834
|
+
expiresAt,
|
|
835
|
+
});
|
|
836
|
+
sessions.bindThread(ts, ctx.session?.id);
|
|
837
|
+
setState(ctx, "waiting");
|
|
838
|
+
|
|
839
|
+
const audio = await playAlert({ methods: audible, config, text, prepared });
|
|
840
|
+
const extra = { audio, ...(attached ? { attached } : {}), ...(muted.length ? { mutedByQuietHours: muted } : {}) };
|
|
841
|
+
if (args.wait !== undefined) return waitFor(ctx, id, parseDuration(args.wait, Infinity), extra);
|
|
842
|
+
// Waiting costs the agent nothing: run it in the background and carry on. It always comes back,
|
|
843
|
+
// because the question settles itself at `settlesAt` whatever the user does.
|
|
844
|
+
return ok({
|
|
845
|
+
ok: true,
|
|
846
|
+
id,
|
|
847
|
+
status: "pending",
|
|
848
|
+
...extra,
|
|
849
|
+
next: `wait ${id}`,
|
|
850
|
+
settlesAt: expiresAt,
|
|
851
|
+
hint: onExpire === "recommended" && recommended !== null
|
|
852
|
+
? `run \`wait ${id}\` in the background and keep working; it returns when the user answers, or by ${expiresAt} with the recommended option applied`
|
|
853
|
+
: `run \`wait ${id}\` in the background and keep working; it returns when the user answers, or by ${expiresAt} unanswered`,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function waitFor(ctx, id, timeoutSeconds, extra = {}) {
|
|
858
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
859
|
+
// Say that someone is here. Settling a question nobody waits on marks the answer undelivered, so
|
|
860
|
+
// the Slack message can be honest about it.
|
|
861
|
+
const release = ctx.waiting?.(id) ?? (() => {});
|
|
862
|
+
try {
|
|
863
|
+
while (true) {
|
|
864
|
+
let d = decisions.read(id);
|
|
865
|
+
if (!d) fail(`no decision with id "${id}"`);
|
|
866
|
+
if (decisions.isExpired(d)) d = (await decisions.expire(d, ctx.log, { waiting: true })) ?? decisions.read(id);
|
|
867
|
+
if (d.status !== "pending" || Date.now() >= deadline) {
|
|
868
|
+
setState(ctx, d.status === "pending" ? "waiting" : "working");
|
|
869
|
+
// An answer settled before this wait started is still owed to us; take it now.
|
|
870
|
+
if (d.status === "answered" && d.deliveredAt === null) d = (await decisions.deliver(d.id, ctx.log)) ?? d;
|
|
871
|
+
return ok({ ok: d.status === "answered", ...decisionReport(d), ...extra }, exitFor(d));
|
|
872
|
+
}
|
|
873
|
+
// A message sent to this agent while it waits: hand it over now, and leave the question open.
|
|
874
|
+
await ctx.catchUp();
|
|
875
|
+
const mine = ctx.session ? await inbox.takeForSession(ctx.session.id) : [];
|
|
876
|
+
if (mine.length) {
|
|
877
|
+
return ok({ ok: false, ...decisionReport(d), messages: mine, ...extra }, EXIT_MESSAGE);
|
|
878
|
+
}
|
|
879
|
+
await ctx.idle(Math.min(1000, Math.max(0, deadline - Date.now())));
|
|
880
|
+
}
|
|
881
|
+
} finally {
|
|
882
|
+
release();
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Everything this agent is owed: answers it stopped waiting for, and messages the user sent it.
|
|
888
|
+
* Attached to every command's reply, so the user is never left wondering whether it arrived — the
|
|
889
|
+
* agent does not have to happen to run the one command that looks.
|
|
890
|
+
*/
|
|
891
|
+
export async function owedTo(ctx, data) {
|
|
892
|
+
if (!ctx?.session || !data || typeof data !== "object") return data;
|
|
893
|
+
const answers = await pendingAnswers(ctx);
|
|
894
|
+
// A handler that already went looking (wait, inbox, progress, control) keeps what it took.
|
|
895
|
+
const messages = "messages" in data ? [] : await inbox.takeForSession(ctx.session.id);
|
|
896
|
+
if (!answers.length && !messages.length) return data;
|
|
897
|
+
return { ...data, ...(answers.length ? { answers } : {}), ...(messages.length ? { messages } : {}) };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Answers the user gave while this agent was not waiting, handed over by whatever it runs next.
|
|
902
|
+
* Every command carries them, so an agent that stopped waiting — for any reason, on any agent
|
|
903
|
+
* product — still finds out what the user said. Delivering one also updates its Slack message.
|
|
904
|
+
*/
|
|
905
|
+
export async function pendingAnswers(ctx) {
|
|
906
|
+
if (!ctx?.session) return [];
|
|
907
|
+
const answers = [];
|
|
908
|
+
for (const owed of decisions.undeliveredFor(ctx.session.id)) {
|
|
909
|
+
const d = await decisions.deliver(owed.id, ctx.log);
|
|
910
|
+
if (!d) continue;
|
|
911
|
+
answers.push({
|
|
912
|
+
...decisionReport(d),
|
|
913
|
+
waited: formatElapsed(Date.parse(d.resolvedAt) - Date.parse(d.createdAt)),
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
return answers;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const idArg = (args) => {
|
|
920
|
+
const id = (args._ ?? [])[0];
|
|
921
|
+
if (!id) fail("give the decision id");
|
|
922
|
+
return id;
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
export function wait(ctx, args) {
|
|
926
|
+
return waitFor(ctx, idArg(args), parseDuration(args.timeout, Infinity));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
export function check(ctx, args) {
|
|
930
|
+
return waitFor(ctx, idArg(args), 0);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
export async function cancel(ctx, args) {
|
|
934
|
+
const id = idArg(args);
|
|
935
|
+
const reason = typeof args.reason === "string" ? args.reason : "no longer needed";
|
|
936
|
+
const settled = await decisions.settle(id, { status: "cancelled", reason });
|
|
937
|
+
const d = settled ?? decisions.read(id);
|
|
938
|
+
if (!d) fail(`no decision with id "${id}"`);
|
|
939
|
+
return ok({
|
|
940
|
+
ok: Boolean(settled),
|
|
941
|
+
...decisionReport(d),
|
|
942
|
+
...(settled ? {} : { note: "already closed" }),
|
|
943
|
+
...(settled?.slackUpdateError ? { slackUpdateError: settled.slackUpdateError } : {}),
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
export function listDecisions(ctx, args) {
|
|
948
|
+
const all = decisions.list();
|
|
949
|
+
const shown = args.all ? all : all.filter((d) => d.status === "pending");
|
|
950
|
+
return ok({ decisions: shown.map(decisionReport) });
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ---------------------------------------------------------------- previews
|
|
954
|
+
|
|
955
|
+
export async function play(ctx, args) {
|
|
956
|
+
const name = (args._ ?? [])[0] ?? ctx.config?.sound ?? DEFAULTS.sound;
|
|
957
|
+
const sounds = listSounds();
|
|
958
|
+
if (!sounds.includes(name)) fail(`unknown sound "${name}"; available: ${sounds.join(", ")}`);
|
|
959
|
+
await withSpeaker(() => playWav(soundFile(name)));
|
|
960
|
+
return ok({ ok: true, played: name });
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** A voice for this call: `--voice` if given (by id or name), else the configured one. */
|
|
964
|
+
function voiceFor(config, wanted) {
|
|
965
|
+
if (!wanted) return config.voice;
|
|
966
|
+
const provider = providerOf(config);
|
|
967
|
+
const found = findVoice(provider.id, wanted);
|
|
968
|
+
if (!found && catalog(provider.id).voices.length) fail(`--voice: "${wanted}" is not a ${provider.label} voice (see \`voices\`)`);
|
|
969
|
+
return found ? found.id : String(wanted);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
export async function say(ctx, args) {
|
|
973
|
+
// `say --test "hello"` reads as --test=hello to the parser; the words are the words either way.
|
|
974
|
+
// (`--gemini` is the older name for `--test`, from when there was only one provider.)
|
|
975
|
+
const flagText = [args.test, args.gemini].find((v) => typeof v === "string") ?? "";
|
|
976
|
+
const text = typeof args.text === "string" ? args.text : (args._ ?? []).join(" ") || flagText;
|
|
977
|
+
if (!text) fail("give the text to say");
|
|
978
|
+
const config = { ...DEFAULTS, ...(ctx.config ?? {}) };
|
|
979
|
+
config.voice = voiceFor(config, args.voice);
|
|
980
|
+
// `--test` checks the provider and its key, so it is never covered for: falling back to the
|
|
981
|
+
// computer's own voice would sound like success while the thing being tested had failed.
|
|
982
|
+
if (args.test || args.gemini) {
|
|
983
|
+
const provider = providerOf(config);
|
|
984
|
+
const key = keyFor(config);
|
|
985
|
+
if (!key.value) fail(missingKey(config));
|
|
986
|
+
let sample;
|
|
987
|
+
try {
|
|
988
|
+
sample = await testSample(text, config);
|
|
989
|
+
} catch (e) {
|
|
990
|
+
fail(`${provider.label} failed with the key from ${key.name}: ${e.message}`);
|
|
991
|
+
}
|
|
992
|
+
await withSpeaker(() => withOthersQuieter(config, () => playWavBuffer(sample.wav)));
|
|
993
|
+
return ok({ ok: true, engine: provider.id, voice: config.voice, model: config.model, keyVar: key.name, cached: sample.cached });
|
|
994
|
+
}
|
|
995
|
+
const prepared = await prepareSpeech(text, config, { local: Boolean(args.local) });
|
|
996
|
+
const result = await withSpeaker(() => deliverSpeech(prepared, text, config));
|
|
997
|
+
return ok(result, result.ok ? 0 : 1);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Fetch the audio up front; on failure the entry carries the error instead. */
|
|
1001
|
+
function prefetch(texts, config) {
|
|
1002
|
+
if (!keyFor(config).value) return texts.map(() => ({ error: missingKey(config) }));
|
|
1003
|
+
return mapPool(texts, 4, (text) => speechAudio(text, config).then((wav) => ({ wav }), (e) => ({ error: e.message })));
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* The provider's voices and models. `--refresh` asks the provider (with the configured key) and
|
|
1008
|
+
* remembers the answer; without it, the last answer, or what is known without asking. A refresh
|
|
1009
|
+
* that finds the configured voice or model gone moves to one that exists, so speech keeps working
|
|
1010
|
+
* after switching provider or account.
|
|
1011
|
+
*/
|
|
1012
|
+
export async function voices(ctx, args) {
|
|
1013
|
+
const config = { ...DEFAULTS, ...(ctx.config ?? {}) };
|
|
1014
|
+
const provider = providerOf(config);
|
|
1015
|
+
let error = null;
|
|
1016
|
+
let refreshed = false;
|
|
1017
|
+
if (args.refresh) {
|
|
1018
|
+
try {
|
|
1019
|
+
await refreshCatalog(config);
|
|
1020
|
+
refreshed = true;
|
|
1021
|
+
} catch (e) {
|
|
1022
|
+
error = e.message;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const found = catalog(provider.id);
|
|
1026
|
+
const changed = {};
|
|
1027
|
+
if (refreshed && ctx.config) {
|
|
1028
|
+
const next = { ...ctx.config };
|
|
1029
|
+
const pick = (list, preferred) => (list.find((x) => x.id === preferred) ?? list[0])?.id;
|
|
1030
|
+
if (found.voices.length && !found.voices.some((v) => v.id === next.voice)) next.voice = pick(found.voices, provider.defaultVoice);
|
|
1031
|
+
if (found.models.length && !found.models.some((m) => m.id === next.model)) next.model = pick(found.models, provider.defaultModel);
|
|
1032
|
+
for (const k of ["voice", "model"]) if (next[k] !== ctx.config[k]) changed[k] = next[k];
|
|
1033
|
+
if (Object.keys(changed).length) {
|
|
1034
|
+
saveConfig(next);
|
|
1035
|
+
ctx.config = next;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
const key = keyFor(config);
|
|
1039
|
+
return ok({
|
|
1040
|
+
ok: !error,
|
|
1041
|
+
provider: provider.id,
|
|
1042
|
+
keyVar: key.name,
|
|
1043
|
+
keyFound: Boolean(key.value),
|
|
1044
|
+
voice: ctx.config?.voice ?? config.voice,
|
|
1045
|
+
model: ctx.config?.model ?? config.model,
|
|
1046
|
+
voices: found.voices,
|
|
1047
|
+
models: found.models,
|
|
1048
|
+
fetchedAt: found.fetchedAt,
|
|
1049
|
+
...(Object.keys(changed).length ? { changed } : {}),
|
|
1050
|
+
...(error ? { error } : {}),
|
|
1051
|
+
}, error ? 1 : 0);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export async function previewSounds(ctx, args) {
|
|
1055
|
+
const config = { ...DEFAULTS, ...(ctx.config ?? {}) };
|
|
1056
|
+
config.voice = voiceFor(config, args.voice);
|
|
1057
|
+
const sounds = previewList(args._ ?? [], listSounds(), listSounds());
|
|
1058
|
+
// Synthesize every announcement first so playback runs back to back.
|
|
1059
|
+
const audio = args.local ? sounds.map(() => ({ error: "--local" })) : await prefetch(sounds.map(soundAnnouncement), config);
|
|
1060
|
+
|
|
1061
|
+
// The whole run-through is one turn at the speakers, so another agent can't cut in mid-list.
|
|
1062
|
+
const played = await withSpeaker(async () => {
|
|
1063
|
+
const done = [];
|
|
1064
|
+
for (const [i, name] of sounds.entries()) {
|
|
1065
|
+
const entry = { sound: name };
|
|
1066
|
+
try {
|
|
1067
|
+
if (audio[i].wav) await playWavBuffer(audio[i].wav);
|
|
1068
|
+
else await speakLocally(soundAnnouncement(name));
|
|
1069
|
+
} catch (e) {
|
|
1070
|
+
entry.announceError = e.message;
|
|
1071
|
+
}
|
|
1072
|
+
try {
|
|
1073
|
+
await playWav(soundFile(name));
|
|
1074
|
+
entry.ok = true;
|
|
1075
|
+
} catch (e) {
|
|
1076
|
+
Object.assign(entry, { ok: false, error: e.message });
|
|
1077
|
+
}
|
|
1078
|
+
done.push(entry);
|
|
1079
|
+
}
|
|
1080
|
+
return done;
|
|
1081
|
+
});
|
|
1082
|
+
const succeeded = played.some((p) => p.ok);
|
|
1083
|
+
return ok({ ok: succeeded, order: sounds, played }, succeeded ? 0 : 1);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
export async function previewVoices(ctx, args) {
|
|
1087
|
+
const config = { ...DEFAULTS, ...(ctx.config ?? {}) };
|
|
1088
|
+
const provider = providerOf(config);
|
|
1089
|
+
const offered = catalog(provider.id).voices;
|
|
1090
|
+
if (!offered.length) fail(`no ${provider.label} voices known yet: run \`voices --refresh\` first`);
|
|
1091
|
+
const all = offered.map((v) => v.id);
|
|
1092
|
+
const byId = Object.fromEntries(offered.map((v) => [v.id, v]));
|
|
1093
|
+
const shortlist = provider.previewVoices.filter((v) => byId[v]);
|
|
1094
|
+
const voices = args.all ? all : previewList(args._ ?? [], all, shortlist.length ? shortlist : all.slice(0, 6), (n) => findVoice(provider.id, n)?.id ?? n);
|
|
1095
|
+
if (!keyFor(config).value) fail(`${missingKey(config)}, so ${provider.label} voices can't be previewed`);
|
|
1096
|
+
const text = (v) => (typeof args.text === "string" ? args.text : voiceSample(byId[v]?.name ?? v));
|
|
1097
|
+
const audio = await mapPool(voices, 4, (voice) =>
|
|
1098
|
+
speechAudio(text(voice), { ...config, voice }).then((wav) => ({ wav }), (e) => ({ error: e.message })));
|
|
1099
|
+
|
|
1100
|
+
const played = await withSpeaker(async () => {
|
|
1101
|
+
const done = [];
|
|
1102
|
+
for (const [i, voice] of voices.entries()) {
|
|
1103
|
+
const entry = { voice, name: byId[voice]?.name ?? voice, ...(byId[voice]?.description ? { character: byId[voice].description } : {}) };
|
|
1104
|
+
if (audio[i].error) {
|
|
1105
|
+
Object.assign(entry, { ok: false, error: audio[i].error });
|
|
1106
|
+
} else {
|
|
1107
|
+
try {
|
|
1108
|
+
await playWavBuffer(audio[i].wav);
|
|
1109
|
+
entry.ok = true;
|
|
1110
|
+
} catch (e) {
|
|
1111
|
+
Object.assign(entry, { ok: false, error: e.message });
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
done.push(entry);
|
|
1115
|
+
}
|
|
1116
|
+
return done;
|
|
1117
|
+
});
|
|
1118
|
+
const succeeded = played.some((p) => p.ok);
|
|
1119
|
+
return ok({ ok: succeeded, order: voices, played }, succeeded ? 0 : 1);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Commands run by the agent's program (its hooks), not by the model. They must not hand over what
|
|
1124
|
+
* the agent is owed — nobody would read it, and the agent would never get it — and they don't mean
|
|
1125
|
+
* the agent is back, so they leave a parked `listen` alone.
|
|
1126
|
+
*/
|
|
1127
|
+
export const FROM_HOOKS = new Set(["permission"]);
|
|
1128
|
+
|
|
1129
|
+
export const HANDLERS = {
|
|
1130
|
+
voices,
|
|
1131
|
+
notify,
|
|
1132
|
+
progress,
|
|
1133
|
+
control,
|
|
1134
|
+
messages,
|
|
1135
|
+
listen,
|
|
1136
|
+
snooze,
|
|
1137
|
+
tray,
|
|
1138
|
+
ask,
|
|
1139
|
+
wait,
|
|
1140
|
+
check,
|
|
1141
|
+
cancel,
|
|
1142
|
+
decisions: listDecisions,
|
|
1143
|
+
sessions: listSessions,
|
|
1144
|
+
rename: renameSession,
|
|
1145
|
+
end: endSession,
|
|
1146
|
+
play,
|
|
1147
|
+
say,
|
|
1148
|
+
"preview-sounds": previewSounds,
|
|
1149
|
+
"preview-voices": previewVoices,
|
|
1150
|
+
permission,
|
|
1151
|
+
};
|