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,275 @@
|
|
|
1
|
+
// Speech from whichever provider the user picked. Each one knows its own voices and models, where
|
|
2
|
+
// its key usually lives, and how to turn a line of text into a WAV — so everything after synthesis
|
|
3
|
+
// (queueing, the ding, playback) stays the same whoever made the audio.
|
|
4
|
+
//
|
|
5
|
+
// Voices and models are fetched from the provider once a key is there and kept in a small cache,
|
|
6
|
+
// so the panel and `status` can offer real choices without going to the network every time.
|
|
7
|
+
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { VOICES, parsePcmMime, rogerRogerHome, scaleWav, speechGain, stripTags, wavFromPcm } from "./lib.mjs";
|
|
12
|
+
import { missingKeyMessage, speechKey } from "./speechkey.mjs";
|
|
13
|
+
|
|
14
|
+
const TIMEOUT_MS = 45_000;
|
|
15
|
+
|
|
16
|
+
/** The audio tags in a line (`[positive]`, `[short pause]`), for providers that take them as a style note. */
|
|
17
|
+
const tagsIn = (text) => [...String(text).matchAll(/(?:^|\s)\[([a-z][a-z' -]{0,38})\](?=$|\s|[,.!?;:])/g)].map((m) => m[1]);
|
|
18
|
+
|
|
19
|
+
async function json(res, who) {
|
|
20
|
+
const body = await res.json().catch(() => ({}));
|
|
21
|
+
if (!res.ok) throw new Error(`${who} ${res.status}: ${body?.error?.message ?? body?.detail?.message ?? body?.detail ?? "request failed"}`);
|
|
22
|
+
return body;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const PROVIDERS = {
|
|
26
|
+
gemini: {
|
|
27
|
+
id: "gemini",
|
|
28
|
+
label: "Google Gemini",
|
|
29
|
+
keyVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
|
30
|
+
defaultModel: "gemini-3.1-flash-tts-preview",
|
|
31
|
+
defaultVoice: "Kore",
|
|
32
|
+
// Gemini has no voice list endpoint; this is the published set.
|
|
33
|
+
staticVoices: Object.entries(VOICES).map(([id, description]) => ({ id, name: id, description })),
|
|
34
|
+
previewVoices: ["Kore", "Puck", "Charon", "Aoede", "Achird", "Sulafat"],
|
|
35
|
+
async models(key) {
|
|
36
|
+
const res = await fetch("https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000", {
|
|
37
|
+
headers: { "x-goog-api-key": key },
|
|
38
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
39
|
+
});
|
|
40
|
+
const body = await json(res, "Gemini");
|
|
41
|
+
return (body.models ?? [])
|
|
42
|
+
.map((m) => String(m.name ?? "").replace(/^models\//, ""))
|
|
43
|
+
.filter((id) => /tts/i.test(id))
|
|
44
|
+
.map((id) => ({ id, name: id }));
|
|
45
|
+
},
|
|
46
|
+
async voices() {
|
|
47
|
+
return this.staticVoices;
|
|
48
|
+
},
|
|
49
|
+
// Gemini reads the audio tags itself, so the text goes as written.
|
|
50
|
+
async synthesize(text, { key, voice, model }) {
|
|
51
|
+
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "Content-Type": "application/json", "x-goog-api-key": key },
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
contents: [{ parts: [{ text }] }],
|
|
56
|
+
generationConfig: {
|
|
57
|
+
responseModalities: ["AUDIO"],
|
|
58
|
+
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voice } } },
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
62
|
+
});
|
|
63
|
+
const body = await json(res, "Gemini");
|
|
64
|
+
const blocked = body?.promptFeedback?.blockReason;
|
|
65
|
+
if (blocked) throw new Error(`Gemini refused the text (${blocked})`);
|
|
66
|
+
const data = body?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)?.inlineData;
|
|
67
|
+
if (!data?.data) throw new Error("Gemini returned no audio");
|
|
68
|
+
const { rate, channels } = parsePcmMime(data.mimeType);
|
|
69
|
+
return wavFromPcm(Buffer.from(data.data, "base64"), rate, channels);
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
openai: {
|
|
74
|
+
id: "openai",
|
|
75
|
+
label: "OpenAI",
|
|
76
|
+
keyVars: ["OPENAI_API_KEY"],
|
|
77
|
+
defaultModel: "gpt-4o-mini-tts",
|
|
78
|
+
defaultVoice: "coral",
|
|
79
|
+
// No voice list endpoint here either; these are the documented ones.
|
|
80
|
+
staticVoices: ["alloy", "ash", "ballad", "cedar", "coral", "echo", "fable", "marin", "nova", "onyx", "sage", "shimmer", "verse"]
|
|
81
|
+
.map((id) => ({ id, name: id })),
|
|
82
|
+
previewVoices: ["coral", "marin", "cedar", "nova", "ash", "sage"],
|
|
83
|
+
async models(key) {
|
|
84
|
+
const res = await fetch("https://api.openai.com/v1/models", {
|
|
85
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
86
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
87
|
+
});
|
|
88
|
+
const body = await json(res, "OpenAI");
|
|
89
|
+
return (body.data ?? []).map((m) => m.id).filter((id) => /tts/i.test(id)).sort().map((id) => ({ id, name: id }));
|
|
90
|
+
},
|
|
91
|
+
async voices() {
|
|
92
|
+
return this.staticVoices;
|
|
93
|
+
},
|
|
94
|
+
// The tags are not read aloud here; the newer models take them as a note on how to say it.
|
|
95
|
+
async synthesize(text, { key, voice, model }) {
|
|
96
|
+
const tags = tagsIn(text);
|
|
97
|
+
const res = await fetch("https://api.openai.com/v1/audio/speech", {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
model,
|
|
102
|
+
voice,
|
|
103
|
+
input: stripTags(text),
|
|
104
|
+
response_format: "wav",
|
|
105
|
+
...(tags.length && !/^tts-1/.test(model) ? { instructions: `Delivery: ${tags.join(", ")}.` } : {}),
|
|
106
|
+
}),
|
|
107
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
108
|
+
});
|
|
109
|
+
if (!res.ok) await json(res, "OpenAI");
|
|
110
|
+
return Buffer.from(await res.arrayBuffer());
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
elevenlabs: {
|
|
115
|
+
id: "elevenlabs",
|
|
116
|
+
label: "ElevenLabs",
|
|
117
|
+
keyVars: ["ELEVENLABS_API_KEY", "XI_API_KEY"],
|
|
118
|
+
defaultModel: "eleven_multilingual_v2",
|
|
119
|
+
// Voices are the account's own, so there is no sensible default until they have been fetched.
|
|
120
|
+
defaultVoice: "",
|
|
121
|
+
staticVoices: [],
|
|
122
|
+
previewVoices: [],
|
|
123
|
+
async models(key) {
|
|
124
|
+
const res = await fetch("https://api.elevenlabs.io/v1/models", { headers: { "xi-api-key": key }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
125
|
+
const body = await json(res, "ElevenLabs");
|
|
126
|
+
return (Array.isArray(body) ? body : []).filter((m) => m.can_do_text_to_speech !== false).map((m) => ({ id: m.model_id, name: m.name ?? m.model_id }));
|
|
127
|
+
},
|
|
128
|
+
async voices(key) {
|
|
129
|
+
const res = await fetch("https://api.elevenlabs.io/v1/voices", { headers: { "xi-api-key": key }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
130
|
+
const body = await json(res, "ElevenLabs");
|
|
131
|
+
return (body.voices ?? []).map((v) => ({
|
|
132
|
+
id: v.voice_id,
|
|
133
|
+
name: v.name ?? v.voice_id,
|
|
134
|
+
description: [v.labels?.gender, v.labels?.accent, v.labels?.description].filter(Boolean).join(", "),
|
|
135
|
+
}));
|
|
136
|
+
},
|
|
137
|
+
// Only the v3 models read audio tags; the others would say the brackets out loud.
|
|
138
|
+
async synthesize(text, { key, voice, model }) {
|
|
139
|
+
if (!voice) throw new Error("no ElevenLabs voice picked yet");
|
|
140
|
+
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}?output_format=pcm_24000`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/json", "xi-api-key": key },
|
|
143
|
+
body: JSON.stringify({ text: /v3/.test(model) ? text : stripTags(text), model_id: model }),
|
|
144
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
145
|
+
});
|
|
146
|
+
if (!res.ok) await json(res, "ElevenLabs");
|
|
147
|
+
return wavFromPcm(Buffer.from(await res.arrayBuffer()), 24_000, 1);
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const PROVIDER_IDS = Object.keys(PROVIDERS);
|
|
153
|
+
|
|
154
|
+
/** The provider a config speaks with; Gemini for anything set up before there was a choice. */
|
|
155
|
+
export const providerOf = (config) => PROVIDERS[config?.speechProvider] ?? PROVIDERS.gemini;
|
|
156
|
+
|
|
157
|
+
/** The key for the config's provider, and the variable it came from. */
|
|
158
|
+
export const keyFor = (config) => speechKey(config?.speechKeyVar ?? "", providerOf(config).keyVars);
|
|
159
|
+
|
|
160
|
+
/** Why there is no key, naming the variable to set. */
|
|
161
|
+
export const missingKey = (config) => missingKeyMessage(config?.speechKeyVar ?? "", providerOf(config).keyVars);
|
|
162
|
+
|
|
163
|
+
/** Text → WAV as the provider made it, before the voice volume is applied. */
|
|
164
|
+
async function synthesizeRaw(text, config) {
|
|
165
|
+
const provider = providerOf(config);
|
|
166
|
+
const { value: key } = keyFor(config);
|
|
167
|
+
if (!key) throw new Error(missingKey(config));
|
|
168
|
+
return provider.synthesize(text, { key, voice: config.voice || provider.defaultVoice, model: config.model || provider.defaultModel });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Text → WAV with the configured provider, voice and model, at the configured volume. */
|
|
172
|
+
export async function synthesize(text, config) {
|
|
173
|
+
return scaleWav(await synthesizeRaw(text, config), speechGain(config));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------- the test sample
|
|
177
|
+
|
|
178
|
+
const samplePath = () => path.join(rogerRogerHome(), "test-sample.wav");
|
|
179
|
+
const sampleMetaPath = () => path.join(rogerRogerHome(), "test-sample.json");
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Everything that changes what a test would sound like, except the volume (applied afterwards).
|
|
183
|
+
* The key goes in as a hash, so a rotated key is tested afresh without the key itself being kept.
|
|
184
|
+
*/
|
|
185
|
+
function sampleSignature(text, config) {
|
|
186
|
+
const provider = providerOf(config);
|
|
187
|
+
const key = keyFor(config);
|
|
188
|
+
return crypto.createHash("sha256").update(JSON.stringify([
|
|
189
|
+
provider.id, config.model || provider.defaultModel, config.voice || provider.defaultVoice, key.name,
|
|
190
|
+
crypto.createHash("sha256").update(key.value).digest("hex"), text,
|
|
191
|
+
])).digest("hex");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The test line at the configured volume. Asking the provider again when nothing about it has
|
|
196
|
+
* changed costs a round trip and a charge for the same audio, so the last one is kept and replayed;
|
|
197
|
+
* `cached` says which happened. Anything that would change the sound — provider, model, voice, the
|
|
198
|
+
* key — makes a new one.
|
|
199
|
+
*/
|
|
200
|
+
export async function testSample(text, config) {
|
|
201
|
+
const signature = sampleSignature(text, config);
|
|
202
|
+
try {
|
|
203
|
+
if (JSON.parse(fs.readFileSync(sampleMetaPath(), "utf8")).signature === signature) {
|
|
204
|
+
return { wav: scaleWav(fs.readFileSync(samplePath()), speechGain(config)), cached: true };
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// No sample yet, or an unreadable one: make a new one.
|
|
208
|
+
}
|
|
209
|
+
const raw = await synthesizeRaw(text, config);
|
|
210
|
+
try {
|
|
211
|
+
fs.mkdirSync(rogerRogerHome(), { recursive: true });
|
|
212
|
+
fs.writeFileSync(samplePath(), raw);
|
|
213
|
+
fs.writeFileSync(sampleMetaPath(), JSON.stringify({ signature, at: new Date().toISOString() }) + "\n", "utf8");
|
|
214
|
+
} catch {
|
|
215
|
+
// A sample that can't be kept is only a slower next test.
|
|
216
|
+
}
|
|
217
|
+
return { wav: scaleWav(raw, speechGain(config)), cached: false };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------- the catalog cache
|
|
221
|
+
|
|
222
|
+
const catalogPath = () => path.join(rogerRogerHome(), "speech-catalog.json");
|
|
223
|
+
|
|
224
|
+
function readAll() {
|
|
225
|
+
try {
|
|
226
|
+
return JSON.parse(fs.readFileSync(catalogPath(), "utf8"));
|
|
227
|
+
} catch {
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* What a provider offers: fetched voices and models if we have them, otherwise what is known
|
|
234
|
+
* without asking (Gemini's and OpenAI's voices are fixed lists; their models are not).
|
|
235
|
+
*/
|
|
236
|
+
export function catalog(providerId) {
|
|
237
|
+
const provider = PROVIDERS[providerId] ?? PROVIDERS.gemini;
|
|
238
|
+
const cached = readAll()[provider.id];
|
|
239
|
+
return {
|
|
240
|
+
provider: provider.id,
|
|
241
|
+
voices: cached?.voices?.length ? cached.voices : provider.staticVoices,
|
|
242
|
+
models: cached?.models?.length ? cached.models : [{ id: provider.defaultModel, name: provider.defaultModel }],
|
|
243
|
+
fetchedAt: cached?.fetchedAt ?? null,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Ask the provider what it has, with the configured key, and remember the answer. */
|
|
248
|
+
export async function refreshCatalog(config) {
|
|
249
|
+
const provider = providerOf(config);
|
|
250
|
+
const { value: key } = keyFor(config);
|
|
251
|
+
if (!key) throw new Error(missingKey(config));
|
|
252
|
+
const [voices, models] = await Promise.all([provider.voices(key), provider.models(key)]);
|
|
253
|
+
const entry = { voices, models, fetchedAt: new Date().toISOString() };
|
|
254
|
+
const all = readAll();
|
|
255
|
+
all[provider.id] = entry;
|
|
256
|
+
fs.mkdirSync(path.dirname(catalogPath()), { recursive: true });
|
|
257
|
+
const tmp = `${catalogPath()}.${process.pid}.tmp`;
|
|
258
|
+
fs.writeFileSync(tmp, JSON.stringify(all, null, 2) + "\n", "utf8");
|
|
259
|
+
fs.renameSync(tmp, catalogPath());
|
|
260
|
+
return { provider: provider.id, ...entry };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** What `applySetup` needs to know about providers, without any of it touching the network. */
|
|
264
|
+
export const SPEECH_SETUP = {
|
|
265
|
+
ids: PROVIDER_IDS,
|
|
266
|
+
defaults: Object.fromEntries(Object.values(PROVIDERS).map((p) => [p.id, { voice: p.defaultVoice, model: p.defaultModel }])),
|
|
267
|
+
findVoice: (providerId, name) => findVoice(providerId, name),
|
|
268
|
+
knowsVoices: (providerId) => catalog(providerId).voices.length > 0,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** A voice by id or (case-insensitively) by name, from what the provider is known to offer. */
|
|
272
|
+
export function findVoice(providerId, wanted) {
|
|
273
|
+
const w = String(wanted ?? "").toLowerCase();
|
|
274
|
+
return catalog(providerId).voices.find((v) => v.id.toLowerCase() === w || v.name.toLowerCase() === w) ?? null;
|
|
275
|
+
}
|