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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/package.json +45 -0
  4. package/skills/roger-roger/SKILL.md +289 -0
  5. package/skills/roger-roger/herdr-plugin.toml +38 -0
  6. package/skills/roger-roger/scripts/agent.mjs +132 -0
  7. package/skills/roger-roger/scripts/audio.mjs +392 -0
  8. package/skills/roger-roger/scripts/client.mjs +121 -0
  9. package/skills/roger-roger/scripts/daemon.mjs +604 -0
  10. package/skills/roger-roger/scripts/decisions.mjs +158 -0
  11. package/skills/roger-roger/scripts/handlers.mjs +1151 -0
  12. package/skills/roger-roger/scripts/herdr.mjs +140 -0
  13. package/skills/roger-roger/scripts/hooks-codex.mjs +154 -0
  14. package/skills/roger-roger/scripts/hooks-opencode.mjs +167 -0
  15. package/skills/roger-roger/scripts/hooks.mjs +420 -0
  16. package/skills/roger-roger/scripts/inbox.mjs +381 -0
  17. package/skills/roger-roger/scripts/install.mjs +560 -0
  18. package/skills/roger-roger/scripts/lib.mjs +1133 -0
  19. package/skills/roger-roger/scripts/names.mjs +84 -0
  20. package/skills/roger-roger/scripts/progress.mjs +91 -0
  21. package/skills/roger-roger/scripts/protocol.mjs +71 -0
  22. package/skills/roger-roger/scripts/roger-roger.mjs +536 -0
  23. package/skills/roger-roger/scripts/router.mjs +86 -0
  24. package/skills/roger-roger/scripts/sessions.mjs +218 -0
  25. package/skills/roger-roger/scripts/slack.mjs +240 -0
  26. package/skills/roger-roger/scripts/slackapp.mjs +205 -0
  27. package/skills/roger-roger/scripts/slackcli.mjs +144 -0
  28. package/skills/roger-roger/scripts/speaker.mjs +224 -0
  29. package/skills/roger-roger/scripts/speechkey.mjs +106 -0
  30. package/skills/roger-roger/scripts/tray.mjs +128 -0
  31. package/skills/roger-roger/scripts/tts.mjs +275 -0
  32. package/skills/roger-roger/scripts/tui.mjs +465 -0
  33. package/skills/roger-roger/slack/manifest.json +34 -0
  34. package/skills/roger-roger/sounds/alert.wav +0 -0
  35. package/skills/roger-roger/sounds/bubble.wav +0 -0
  36. package/skills/roger-roger/sounds/chime.wav +0 -0
  37. package/skills/roger-roger/sounds/ding.wav +0 -0
  38. package/skills/roger-roger/sounds/marimba.wav +0 -0
  39. package/skills/roger-roger/tray/main.mjs +749 -0
  40. package/skills/roger-roger/tray/panel.html +501 -0
@@ -0,0 +1,144 @@
1
+ // Finding and installing the Slack CLI. Installation mirrors Slack's official install scripts
2
+ // (same download URLs and locations) but runs from Node, so it needs no PowerShell 7 on Windows.
3
+
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { spawn } from "node:child_process";
8
+
9
+ const METADATA_URL = "https://docs.slack.dev/tools/metadata.json";
10
+ const DOWNLOADS = "https://downloads.slack-edge.com/slack-cli";
11
+
12
+ /** Run a command and collect its output. Never rejects; `code` is null if it couldn't start. */
13
+ export function capture(cmd, args, { input, env, cwd, timeout = 120_000 } = {}) {
14
+ return new Promise((resolve) => {
15
+ let child;
16
+ try {
17
+ // Detached: on Windows the Slack CLI can hang indefinitely when it shares the caller's console
18
+ // (seen from agent shells); without a console it runs normally. Output is still piped back.
19
+ child = spawn(cmd, args, {
20
+ env: env ?? process.env,
21
+ cwd,
22
+ timeout,
23
+ detached: process.platform === "win32",
24
+ windowsHide: true,
25
+ stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
26
+ });
27
+ } catch (e) {
28
+ return resolve({ code: null, stdout: "", stderr: e.message });
29
+ }
30
+ let stdout = "";
31
+ let stderr = "";
32
+ child.stdout.on("data", (d) => (stdout += d));
33
+ child.stderr.on("data", (d) => (stderr += d));
34
+ child.on("error", (e) => resolve({ code: null, stdout, stderr: stderr || e.message }));
35
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
36
+ if (input !== undefined) child.stdin.end(input);
37
+ });
38
+ }
39
+
40
+ /** Where the official installers put the binary, per platform. */
41
+ function installDir() {
42
+ if (process.platform === "win32") return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "slack-cli");
43
+ return path.join(os.homedir(), ".slack");
44
+ }
45
+
46
+ function candidates() {
47
+ const bin = path.join(installDir(), "bin");
48
+ const exe = process.platform === "win32" ? ".exe" : "";
49
+ const known = [path.join(bin, `slackcli${exe}`), path.join(bin, `slack${exe}`)];
50
+ if (process.platform === "win32") known.push(path.join(os.homedir(), ".slack-cli", "bin", "slackcli.exe"), path.join(os.homedir(), ".slack-cli", "bin", "slack.exe"));
51
+ return [...known.filter((p) => fs.existsSync(p)), ...pathLookups()];
52
+ }
53
+
54
+ /** `slackcli` / `slack` on PATH, skipping the Windows Store alias of the Slack *desktop* app. */
55
+ function pathLookups() {
56
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ""] : [""];
57
+ const found = [];
58
+ for (const dir of (process.env.PATH || "").split(path.delimiter)) {
59
+ if (!dir || /WindowsApps/i.test(dir)) continue;
60
+ for (const name of ["slackcli", "slack"]) {
61
+ for (const ext of exts) {
62
+ const p = path.join(dir, name + ext);
63
+ if (fs.existsSync(p) && fs.statSync(p).isFile()) found.push(p);
64
+ }
65
+ }
66
+ }
67
+ return found;
68
+ }
69
+
70
+ /** The first working Slack CLI: `{ command, version }`, or null. */
71
+ export async function findCli() {
72
+ for (const command of [...new Set(candidates())]) {
73
+ const r = await capture(command, ["--version"], { timeout: 20_000 });
74
+ const m = /Using \S+ v(\d+\.\d+\.\d+)/.exec(r.stdout + r.stderr);
75
+ if (r.code === 0 && m) return { command, version: m[1] };
76
+ }
77
+ return null;
78
+ }
79
+
80
+ async function download(url, file) {
81
+ const res = await fetch(url, { signal: AbortSignal.timeout(300_000) });
82
+ if (!res.ok) throw new Error(`download failed (${res.status}): ${url}`);
83
+ fs.writeFileSync(file, Buffer.from(await res.arrayBuffer()));
84
+ }
85
+
86
+ function archiveUrl(version) {
87
+ const arch = process.arch === "arm64" ? "arm64" : "amd64";
88
+ if (process.platform === "win32") return `${DOWNLOADS}/slack_cli_${version}_windows_64-bit.zip`;
89
+ if (process.platform === "darwin") return `${DOWNLOADS}/slack_cli_${version}_macOS_${arch}.tar.gz`;
90
+ return `${DOWNLOADS}/slack_cli_${version}_linux_${arch}.tar.gz`;
91
+ }
92
+
93
+ /**
94
+ * Install the latest Slack CLI for this user. On Windows the binary is named `slackcli.exe`
95
+ * so it never collides with the Slack desktop app's `slack` alias. Returns `{ command, version }`.
96
+ */
97
+ export async function installCli() {
98
+ const meta = await (await fetch(METADATA_URL, { signal: AbortSignal.timeout(30_000) })).json();
99
+ const version = meta?.["slack-cli"]?.releases?.[0]?.version;
100
+ if (!version) throw new Error("could not find the latest Slack CLI version");
101
+
102
+ const dir = installDir();
103
+ const bin = path.join(dir, "bin");
104
+ fs.mkdirSync(dir, { recursive: true });
105
+
106
+ if (process.platform === "win32") {
107
+ const zip = path.join(dir, "slack_cli.zip");
108
+ await download(archiveUrl(version), zip);
109
+ const unpack = await capture("powershell.exe", [
110
+ "-NoProfile", "-NonInteractive", "-Command",
111
+ "Expand-Archive -LiteralPath $env:AG_ZIP -DestinationPath $env:AG_DIR -Force; " +
112
+ "Move-Item -LiteralPath (Join-Path $env:AG_DIR 'bin\\slack.exe') -Destination (Join-Path $env:AG_DIR 'bin\\slackcli.exe') -Force; " +
113
+ "$p = [Environment]::GetEnvironmentVariable('Path', 'User'); $b = Join-Path $env:AG_DIR 'bin'; " +
114
+ "if (-not ((';' + $p + ';').ToLower().Contains(';' + $b.ToLower() + ';'))) { [Environment]::SetEnvironmentVariable('Path', $p.TrimEnd(';') + ';' + $b, 'User') }",
115
+ ], { env: { ...process.env, AG_ZIP: zip, AG_DIR: dir } });
116
+ fs.rmSync(zip, { force: true });
117
+ if (unpack.code !== 0) throw new Error(`could not unpack the Slack CLI: ${unpack.stderr.trim()}`);
118
+ } else {
119
+ const tgz = path.join(dir, "slack-cli.tar.gz");
120
+ await download(archiveUrl(version), tgz);
121
+ const unpack = await capture("tar", ["-xf", tgz, "-C", dir]);
122
+ fs.rmSync(tgz, { force: true });
123
+ if (unpack.code !== 0) throw new Error(`could not unpack the Slack CLI: ${unpack.stderr.trim()}`);
124
+ fs.chmodSync(path.join(bin, "slack"), 0o755);
125
+ if (process.platform === "darwin") {
126
+ // Something fetched over the network can carry a quarantine flag, and macOS then refuses to
127
+ // run it with a dialog nobody sees from here. Clearing it is best-effort.
128
+ await capture("xattr", ["-d", "com.apple.quarantine", path.join(bin, "slack")]).catch(() => {});
129
+ }
130
+ // Like the official script: link into ~/.local/bin, unless some other `slack` is already there.
131
+ const localBin = path.join(os.homedir(), ".local", "bin");
132
+ const link = path.join(localBin, "slack");
133
+ try {
134
+ fs.mkdirSync(localBin, { recursive: true });
135
+ if (!fs.existsSync(link)) fs.symlinkSync(path.join(bin, "slack"), link);
136
+ } catch {
137
+ // The absolute path still works for this skill; PATH is a convenience for the user.
138
+ }
139
+ }
140
+
141
+ const cli = await findCli();
142
+ if (!cli) throw new Error("the Slack CLI was installed but does not run");
143
+ return cli;
144
+ }
@@ -0,0 +1,224 @@
1
+ // One voice at a time. Every agent runs its own `roger-roger` process, so nothing inside a
2
+ // single process can stop two announcements overlapping — the speakers are shared state
3
+ // between processes. A lock file under ROGER_ROGER_HOME serialises playback: a process waits
4
+ // for its turn, and a holder that died or wedged is stepped over rather than waited on
5
+ // forever. Failing to lock never silences an announcement; it only risks an overlap.
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { rogerRogerHome, pidAlive } from "./lib.mjs";
10
+
11
+ export const STALE_MS = 15_000; // a holder that stops refreshing has gone
12
+ export const HEARTBEAT_MS = 5_000;
13
+ export const POLL_MS = 120;
14
+ export const MAX_WAIT_MS = 180_000; // past this the queue is broken rather than left silent
15
+ export const GAP_MS = 200; // breathing room so two announcements don't run together
16
+
17
+ export { pidAlive };
18
+
19
+ export function speakerLockPath(env = process.env) {
20
+ return path.join(rogerRogerHome(env), "speaker.lock");
21
+ }
22
+
23
+ /** Is this lock still somebody's? Pure, so the rules can be tested without processes. */
24
+ export function lockActive(lock, { now = Date.now(), alive = pidAlive } = {}) {
25
+ if (!lock || typeof lock.pid !== "number") return false;
26
+ if (!alive(lock.pid)) return false;
27
+ return now - (Number(lock.heartbeat) || 0) < STALE_MS;
28
+ }
29
+
30
+ function readLock(file) {
31
+ try {
32
+ return JSON.parse(fs.readFileSync(file, "utf8"));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function readRaw(file) {
39
+ try {
40
+ return fs.readFileSync(file, "utf8");
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * True while another process holds the speaker, judged from the lock's text as read (`raw`), so
48
+ * the caller knows exactly which claim it judged. An unreadable lock is judged by its mtime: a
49
+ * lock is created empty and written a moment later, and that moment is not an abandoned lock.
50
+ */
51
+ function heldByOther(file, raw) {
52
+ if (raw === null) return false; // gone: free
53
+ let lock = null;
54
+ try {
55
+ lock = JSON.parse(raw);
56
+ } catch {}
57
+ if (lock) return lock.pid !== process.pid && lockActive(lock);
58
+ try {
59
+ return Date.now() - fs.statSync(file).mtimeMs < STALE_MS;
60
+ } catch {
61
+ return false; // gone between the read and the stat: free
62
+ }
63
+ }
64
+
65
+ /** Who holds the speaker right now, or null. For `status` and for tests. */
66
+ export function currentSpeaker(file = speakerLockPath()) {
67
+ const lock = readLock(file);
68
+ return lock && lockActive(lock) ? lock : null;
69
+ }
70
+
71
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
72
+
73
+ let holding = false;
74
+ let timer = null;
75
+ let cleanupFile = null;
76
+
77
+ function write(file) {
78
+ fs.writeFileSync(file, JSON.stringify({ pid: process.pid, heartbeat: Date.now() }));
79
+ }
80
+
81
+ function startHeartbeat(file) {
82
+ timer = setInterval(() => {
83
+ // If another process took the lock, stop refreshing rather than clobbering its claim.
84
+ const lock = readLock(file);
85
+ if (lock && lock.pid !== process.pid) return stopHeartbeat();
86
+ try {
87
+ write(file);
88
+ } catch {
89
+ // A lock we can no longer refresh just goes stale; playback carries on.
90
+ }
91
+ }, HEARTBEAT_MS);
92
+ timer.unref?.();
93
+ }
94
+
95
+ function stopHeartbeat() {
96
+ clearInterval(timer);
97
+ timer = null;
98
+ }
99
+
100
+ function drop(file) {
101
+ if (readLock(file)?.pid === process.pid) fs.rmSync(file, { force: true });
102
+ }
103
+
104
+ function registerCleanup(file) {
105
+ if (cleanupFile) return;
106
+ cleanupFile = file;
107
+ process.on("exit", () => {
108
+ if (holding) drop(cleanupFile);
109
+ });
110
+ }
111
+
112
+ const disabled = (env = process.env) => env.ROGER_ROGER_NO_SPEAKER_LOCK === "1";
113
+
114
+ let asideCounter = 0;
115
+
116
+ /**
117
+ * Clear the stale claim we judged (`seen`), and only that one. Two waiters can both judge the same
118
+ * lock stale; if both simply deleted it, the slower one would delete the lock the faster one had
119
+ * just created, and both would speak at once. A rename is atomic, so the lock is moved aside first
120
+ * and only then checked: if what was moved is not what we judged — a newer claim, or a heartbeat
121
+ * that proved the holder alive — it is put back with 'wx', which never overwrites a lock someone
122
+ * created in between. True when the way is clear to try creating the lock again.
123
+ */
124
+ function takeOver(file, seen) {
125
+ if (seen === null) return true; // nothing there to clear
126
+ const aside = `${file}.${process.pid}.${Date.now()}.${asideCounter++}.stale`;
127
+ try {
128
+ fs.renameSync(file, aside);
129
+ } catch (e) {
130
+ if (e.code === "ENOENT") return true; // somebody else cleared it; race them for the new one
131
+ if (["EPERM", "EACCES", "EBUSY"].includes(e.code)) return false; // Windows: open elsewhere, try next poll
132
+ throw e;
133
+ }
134
+ const moved = readRaw(aside);
135
+ if (moved !== seen) {
136
+ try {
137
+ fs.writeFileSync(file, moved ?? "", { flag: "wx" });
138
+ } catch {
139
+ // A newer lock is already there; the claim we moved has lost the race regardless.
140
+ }
141
+ }
142
+ fs.rmSync(aside, { force: true });
143
+ return moved === seen;
144
+ }
145
+
146
+ /**
147
+ * Take the speaker, waiting for whoever has it. Returns a handle for `releaseSpeaker`:
148
+ * `held` says whether we actually got the lock, `waitedMs` how long the queue took, and
149
+ * `tookOver` that a stale holder was stepped over.
150
+ */
151
+ export async function acquireSpeaker({ file = speakerLockPath(), wait = MAX_WAIT_MS, env = process.env } = {}) {
152
+ if (disabled(env)) return { held: false, waitedMs: 0, reason: "disabled" };
153
+ const start = Date.now();
154
+ let tookOver = false;
155
+ let queued = false; // a lock taken on the first try was never a wait
156
+ try {
157
+ fs.mkdirSync(path.dirname(file), { recursive: true });
158
+ for (;;) {
159
+ try {
160
+ fs.writeFileSync(file, JSON.stringify({ pid: process.pid, heartbeat: Date.now() }), { flag: "wx" });
161
+ holding = true;
162
+ startHeartbeat(file);
163
+ registerCleanup(file);
164
+ return { held: true, waitedMs: queued ? Date.now() - start : 0, ...(tookOver ? { tookOver } : {}) };
165
+ } catch (e) {
166
+ if (e.code !== "EEXIST") throw e;
167
+ queued = true;
168
+ const raw = readRaw(file);
169
+ if (!heldByOther(file, raw) || Date.now() - start >= wait) {
170
+ // Free, abandoned, or we have queued long enough to stop believing the holder.
171
+ if (takeOver(file, raw)) {
172
+ tookOver = true;
173
+ continue;
174
+ }
175
+ }
176
+ await sleep(POLL_MS);
177
+ }
178
+ }
179
+ } catch (e) {
180
+ // No usable lock (read-only home, odd filesystem). Better a possible overlap than silence.
181
+ return { held: false, waitedMs: Date.now() - start, reason: e.message };
182
+ }
183
+ }
184
+
185
+ /** Give the speaker back, after a short gap so the next announcement is distinct from ours. */
186
+ export async function releaseSpeaker(handle, { file = speakerLockPath(), gap = GAP_MS } = {}) {
187
+ if (!handle?.held) return;
188
+ if (gap) await sleep(gap);
189
+ holding = false;
190
+ stopHeartbeat();
191
+ try {
192
+ drop(file);
193
+ } catch {
194
+ // The next process steps over a lock nobody refreshes.
195
+ }
196
+ }
197
+
198
+ // Inside one process the lock file is not enough: the daemon serves several agents at once, and a
199
+ // second request would find the lock already ours. Callers queue here first, then for the machine.
200
+ let chain = Promise.resolve();
201
+
202
+ /**
203
+ * Run `fn` with the speaker to ourselves; the handle tells it how long the queue took. Calls are
204
+ * served in the order they arrive. Don't nest one inside another: the inner call would wait for the
205
+ * outer one to finish.
206
+ */
207
+ export function withSpeaker(fn, opts = {}) {
208
+ const queuedAt = Date.now();
209
+ const run = chain.then(() => exclusive(fn, opts, queuedAt), () => exclusive(fn, opts, queuedAt));
210
+ chain = run.then(() => {}, () => {});
211
+ return run;
212
+ }
213
+
214
+ async function exclusive(fn, opts, queuedAt) {
215
+ const handle = await acquireSpeaker(opts);
216
+ // Waiting behind another announcement in this process counts as queueing just as much as waiting
217
+ // for another machine-wide holder, so the caller hears about either.
218
+ const waited = Math.max(handle.waitedMs ?? 0, Date.now() - queuedAt);
219
+ try {
220
+ return await fn({ ...handle, waitedMs: waited });
221
+ } finally {
222
+ await releaseSpeaker(handle, opts);
223
+ }
224
+ }
@@ -0,0 +1,106 @@
1
+ // Where the speech API key comes from: an environment variable, and the user gets to say which.
2
+ //
3
+ // The daemon is long-lived and inherited its environment from whichever agent happened to start it,
4
+ // so a variable the user set afterwards — in Windows' own settings, the usual way — is invisible to
5
+ // it. On Windows the user's and the machine's saved variables are read directly as well, so setting
6
+ // a key there works without restarting anything. Only names ever leave this file, never values.
7
+
8
+ import { execFileSync } from "node:child_process";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+
13
+ /** Tried in this order when nothing else says which (the provider normally does). */
14
+ export const DEFAULT_KEY_VARS = ["GEMINI_API_KEY", "GOOGLE_API_KEY"];
15
+
16
+ // Keys pasted into `install` are kept here, one JSON object of NAME → value, readable by the user
17
+ // only. It is the third place a key is looked for, after this process's environment and (on
18
+ // Windows) the saved variables, so a key set the ordinary way always wins over one pasted earlier.
19
+ // The home is worked out here rather than imported: lib.mjs imports this file's dependants.
20
+ const keyFilePath = (env = process.env) => path.join(env.ROGER_ROGER_HOME || path.join(os.homedir(), ".roger-roger"), "keys.json");
21
+
22
+ function savedKeys(env = process.env) {
23
+ try {
24
+ const parsed = JSON.parse(fs.readFileSync(keyFilePath(env), "utf8"));
25
+ return parsed && typeof parsed === "object" ? parsed : {};
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ /** Keep a key for `name`, or forget it with an empty value. Returns where it was written. */
32
+ export function saveKey(name, value, env = process.env) {
33
+ const file = keyFilePath(env);
34
+ const keys = savedKeys(env);
35
+ if (value && String(value).trim()) keys[name.toUpperCase()] = String(value).trim();
36
+ else delete keys[name.toUpperCase()];
37
+ fs.mkdirSync(path.dirname(file), { recursive: true });
38
+ fs.writeFileSync(file, JSON.stringify(keys, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
39
+ try {
40
+ fs.chmodSync(file, 0o600); // A no-op on Windows, where the user profile is already private.
41
+ } catch {}
42
+ return file;
43
+ }
44
+
45
+ /** Whether a pasted key is on file for `name` (never the key itself). */
46
+ export const hasSavedKey = (name, env = process.env) => Boolean(savedKeys(env)[String(name).toUpperCase()]);
47
+
48
+ /** `reg query` output → { NAME: value }. Values that point at other variables are expanded by hand. */
49
+ function registryVars(key) {
50
+ if (process.platform !== "win32") return {};
51
+ try {
52
+ const out = execFileSync("reg.exe", ["query", key], { encoding: "utf8", windowsHide: true, timeout: 5_000 });
53
+ const vars = {};
54
+ for (const line of out.split(/\r?\n/)) {
55
+ const m = /^\s{4}(\S+)\s+REG_(?:EXPAND_)?SZ\s+(.*)$/.exec(line);
56
+ if (m) vars[m[1].toUpperCase()] = m[2].replace(/%([^%]+)%/g, (all, name) => process.env[name] ?? all);
57
+ }
58
+ return vars;
59
+ } catch {
60
+ return {};
61
+ }
62
+ }
63
+
64
+ let saved = null;
65
+ let savedAt = 0;
66
+ /**
67
+ * The variables Windows keeps for new processes. Read at most every half minute: it spawns `reg`,
68
+ * and the tray's snapshot asks on every write.
69
+ */
70
+ function savedVars() {
71
+ if (!saved || Date.now() - savedAt > 30_000) {
72
+ saved = {
73
+ ...registryVars("HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment"),
74
+ ...registryVars("HKCU\\Environment"),
75
+ };
76
+ savedAt = Date.now();
77
+ }
78
+ return saved;
79
+ }
80
+
81
+ /** This process's variable first, then Windows' saved one, then a key pasted into `install`. Empty when none has it. */
82
+ export function envValue(name, env = process.env) {
83
+ if (!name) return "";
84
+ const live = env[name] ?? env[name.toUpperCase()];
85
+ if (live && live.trim()) return live.trim();
86
+ if (env !== process.env) return ""; // a caller with its own env (a test) means exactly that env
87
+ return (savedVars()[name.toUpperCase()] ?? savedKeys(env)[name.toUpperCase()] ?? "").trim();
88
+ }
89
+
90
+ /**
91
+ * The key to use, and which variable it came from. `name` is the user's choice; empty means the
92
+ * provider's usual names in turn. A chosen variable that is empty is an answer, not a reason to look
93
+ * elsewhere: the user said where the key is.
94
+ */
95
+ export function speechKey(name = "", defaults = DEFAULT_KEY_VARS, env = process.env) {
96
+ const names = name ? [name] : defaults;
97
+ for (const n of names) {
98
+ const value = envValue(n, env);
99
+ if (value) return { name: n, value };
100
+ }
101
+ return { name: name || defaults[0], value: "" };
102
+ }
103
+
104
+ /** What the missing key is called, for an error message that says what to set. */
105
+ export const missingKeyMessage = (name = "", defaults = DEFAULT_KEY_VARS) =>
106
+ name ? `the environment variable ${name} is empty or not set` : `no API key: set ${defaults.join(" or ")}, or name another variable in setup`;
@@ -0,0 +1,128 @@
1
+ // What a tray icon shows, as a plain file the daemon keeps up to date.
2
+ //
3
+ // The tray itself is native on each platform and has no business speaking the daemon's protocol, so
4
+ // it doesn't: it watches one small file and draws whatever is in it. Anything it lets you press is
5
+ // an ordinary `roger-roger` command, the same one you could have typed.
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import {
10
+ DUCK_MODES, METHODS, ON_EXPIRE, SPEECH_MODES, WHEN_MODES, duckFactor,
11
+ rogerRogerHome, inQuietHours, listSounds, snoozeUntil,
12
+ } from "./lib.mjs";
13
+ import * as decisions from "./decisions.mjs";
14
+ import * as progressStore from "./progress.mjs";
15
+ import * as sessions from "./sessions.mjs";
16
+ import { PROVIDERS, catalog, keyFor, providerOf } from "./tts.mjs";
17
+
18
+ export const snapshotPath = (env = process.env) => path.join(rogerRogerHome(env), "tray.json");
19
+
20
+ /** One line per agent, short enough to read at a glance and nothing more. */
21
+ function agentLine(session, pending, now) {
22
+ const waiting = pending.find((d) => d.sessionId === session.id);
23
+ return {
24
+ id: session.id,
25
+ nickname: session.nickname,
26
+ swatch: session.swatch,
27
+ colour: session.colour,
28
+ project: session.project,
29
+ // The same line Slack puts under a message, so the panel and the DM agree on who this is.
30
+ agent: session.agent ?? null,
31
+ model: session.model ?? null,
32
+ name: session.name ?? null,
33
+ host: session.host ?? null,
34
+ state: session.state,
35
+ quietForMs: Math.max(0, now - Date.parse(session.lastSeen ?? 0)),
36
+ queued: session.queue?.length ?? 0,
37
+ owes: decisions.undeliveredFor(session.id).length,
38
+ ...(waiting ? { waitingOn: { id: waiting.id, question: waiting.question, expiresAt: waiting.expiresAt } } : {}),
39
+ };
40
+ }
41
+
42
+ /** Everything a panel needs, and nothing it doesn't. */
43
+ export function snapshot({ config = null, daemon = null, now = Date.now() } = {}) {
44
+ const pending = decisions.list().filter((d) => d.status === "pending");
45
+ const live = sessions.live(now);
46
+ return {
47
+ updatedAt: new Date(now).toISOString(),
48
+ daemon: daemon ? { pid: daemon.pid, slackConnected: Boolean(daemon.slackConnected) } : null,
49
+ muted: {
50
+ snoozeUntil: snoozeUntil(config, new Date(now)),
51
+ quietHours: config?.quietHours ?? null,
52
+ quietNow: Boolean(config?.quietHours && inQuietHours(config.quietHours, new Date(now))),
53
+ },
54
+ agents: live.map((s) => agentLine(s, pending, now)),
55
+ questions: pending.map((d) => ({
56
+ id: d.id,
57
+ question: d.question,
58
+ nickname: d.nickname ?? null,
59
+ swatch: d.swatch ?? null,
60
+ choices: d.choices ?? [],
61
+ expiresAt: d.expiresAt,
62
+ })),
63
+ working: progressStore.active(now).map((p) => ({ key: p.key, headline: p.headline, nickname: p.nickname ?? null })),
64
+ // Everything `setup` can change, so the panel can offer it without asking anyone. Tokens live
65
+ // in slack.json and the environment and are deliberately not here.
66
+ settings: config
67
+ ? {
68
+ methods: config.methods ?? [],
69
+ sound: config.sound ?? "",
70
+ voice: config.voice ?? "",
71
+ speech: config.speech ?? "auto",
72
+ when: config.when ?? "auto",
73
+ remind: (config.remind ?? []).join(","),
74
+ onExpire: config.onExpire ?? "recommended",
75
+ quietHours: config.quietHours ? { from: config.quietHours.from, to: config.quietHours.to } : null,
76
+ quietMute: config.quietHours?.mute ?? ["speech", "sound"],
77
+ sessionLabel: config.sessionLabel !== false,
78
+ sayWho: config.sayWho !== false,
79
+ localFallback: config.localFallback !== false,
80
+ tray: config.tray ?? "auto",
81
+ model: config.model ?? "",
82
+ duck: duckFactor(config) < 1 || config.duck === "on",
83
+ duckLevel: Math.round(duckFactor({ ...config, duck: "on" }) * 100),
84
+ speechVolume: Number.isFinite(config.speechVolume) ? config.speechVolume : 100,
85
+ speechProvider: providerOf(config).id,
86
+ // The variable's name and whether it holds anything. The key itself never leaves the daemon.
87
+ speechKeyVar: config.speechKeyVar ?? "",
88
+ speechKeyFound: Boolean(keyFor(config).value),
89
+ speechKeyUsed: keyFor(config).name,
90
+ notes: config.notes ?? "",
91
+ slackTarget: config.slack?.target ?? "",
92
+ slackMention: config.slack?.mention ?? "",
93
+ }
94
+ : null,
95
+ options: {
96
+ methods: METHODS,
97
+ sounds: listSounds(),
98
+ // The chosen provider's voices and models, as last fetched; `fetchedAt` is null until they have been.
99
+ speechProviders: Object.values(PROVIDERS).map((p) => ({ id: p.id, label: p.label, keyVars: p.keyVars })),
100
+ speechCatalog: catalog(providerOf(config).id),
101
+ speech: SPEECH_MODES,
102
+ duck: DUCK_MODES,
103
+ when: WHEN_MODES,
104
+ onExpire: ON_EXPIRE,
105
+ },
106
+ };
107
+ }
108
+
109
+ /** Write via a temp file and rename, so a tray watching it never reads half a snapshot. */
110
+ export function write(data, env = process.env) {
111
+ const file = snapshotPath(env);
112
+ try {
113
+ fs.mkdirSync(path.dirname(file), { recursive: true });
114
+ const tmp = `${file}.${process.pid}.tmp`;
115
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8");
116
+ fs.renameSync(tmp, file);
117
+ } catch {
118
+ // A tray that can't be updated is not a reason to take the daemon down.
119
+ }
120
+ }
121
+
122
+ export function read(env = process.env) {
123
+ try {
124
+ return JSON.parse(fs.readFileSync(snapshotPath(env), "utf8"));
125
+ } catch {
126
+ return null;
127
+ }
128
+ }