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,84 @@
1
+ // Session nicknames. Every agent session is given a short colour name — rose, jade, gold — so the
2
+ // user can tell three agents apart at a glance and address one of them in a sentence. The colour is
3
+ // not just a word: it becomes the swatch in front of the session's Slack messages and the colour of
4
+ // the bar down their left edge, so a glance at the DM is enough to see who is talking.
5
+ //
6
+ // The pool lives in the config (`setup --names`), so the user can replace it with anything they
7
+ // like; a name outside the table still gets a stable colour, derived from the name itself.
8
+
9
+ /**
10
+ * name → [hex, emoji]. Short colour words that read like names, each with a token that gives it a
11
+ * face. The emoji is what the user sees in front of the session's messages; the hex is the bar Slack
12
+ * draws down their left edge.
13
+ */
14
+ const COLOURS = {
15
+ rose: ["#D95F7C", "🌹"],
16
+ ruby: ["#9B111E", "💎"],
17
+ jade: ["#00A86B", "🐉"],
18
+ sage: ["#8A9A5B", "đŸŒŋ"],
19
+ iris: ["#5A4FCF", "đŸĒģ"],
20
+ cyan: ["#00B7C2", "đŸŗ"],
21
+ plum: ["#8E4585", "🍇"],
22
+ gold: ["#D4AF37", "🏆"],
23
+ amber: ["#FFBF00", "đŸ¯"],
24
+ ash: ["#B2BEB5", "đŸĒ¨"],
25
+ flint: ["#6F6F6F", "⚡"],
26
+ mint: ["#7FD4A0", "đŸŦ"],
27
+ olive: ["#808000", "đŸĢ’"],
28
+ };
29
+
30
+ export const NAMES = Object.keys(COLOURS);
31
+
32
+ const SWATCHES = ["🔴", "🟠", "🟡", "đŸŸĸ", "đŸ”ĩ", "đŸŸŖ", "🟤", "âšĢ", "âšĒ"];
33
+
34
+ /** The hex and swatch for a nickname. Names the user invented get a stable colour of their own. */
35
+ export function colourOf(name) {
36
+ const key = String(name ?? "").toLowerCase();
37
+ // `jade2` is still a jade: a numbered name keeps the colour and face of the one it came from.
38
+ const base = key.replace(/\d+$/, "");
39
+ if (COLOURS[base]) return { name: key, hex: COLOURS[base][0], swatch: COLOURS[base][1] };
40
+ let hash = 0;
41
+ for (const ch of key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
42
+ const hue = hash % 360;
43
+ return { name: key, hex: hslHex(hue, 55, 55), swatch: SWATCHES[hash % SWATCHES.length] };
44
+ }
45
+
46
+ /** `đŸŒŋ sage` — how a session is shown wherever there is room for one short token. */
47
+ export function badge(name) {
48
+ const { swatch } = colourOf(name);
49
+ return `${swatch} ${name}`;
50
+ }
51
+
52
+ function hslHex(h, s, l) {
53
+ const a = (s / 100) * Math.min(l / 100, 1 - l / 100);
54
+ const f = (n) => {
55
+ const k = (n + h / 30) % 12;
56
+ const v = l / 100 - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
57
+ return Math.round(255 * v).toString(16).padStart(2, "0");
58
+ };
59
+ return `#${f(0)}${f(8)}${f(4)}`.toUpperCase();
60
+ }
61
+
62
+ /** Valid nickname: one short word, so it can be typed in front of a sentence. */
63
+ export function isNickname(value) {
64
+ return typeof value === "string" && /^[a-z][a-z0-9-]{1,15}$/.test(value.toLowerCase());
65
+ }
66
+
67
+ /**
68
+ * A free nickname from the pool, chosen at random so consecutive sessions don't look alike. Falls
69
+ * back to numbering (`sage2`) only once every name in the pool is in use.
70
+ */
71
+ export function pickName(taken = [], pool = NAMES, random = Math.random) {
72
+ const used = new Set(taken.map((n) => String(n).toLowerCase()));
73
+ const names = (pool.length ? pool : NAMES).map((n) => String(n).toLowerCase()).filter(isNickname);
74
+ const free = names.filter((n) => !used.has(n));
75
+ // Two of a kind side by side defeat the point, so prefer an emoji nobody is wearing.
76
+ const swatches = new Set([...used].map((n) => colourOf(n).swatch));
77
+ const distinct = free.filter((n) => !swatches.has(colourOf(n).swatch));
78
+ const from = distinct.length ? distinct : free;
79
+ if (from.length) return from[Math.floor(random() * from.length) % from.length];
80
+ for (let suffix = 2; ; suffix++) {
81
+ const candidates = names.map((n) => `${n}${suffix}`).filter((n) => !used.has(n));
82
+ if (candidates.length) return candidates[Math.floor(random() * candidates.length) % candidates.length];
83
+ }
84
+ }
@@ -0,0 +1,91 @@
1
+ // Progress messages: one Slack message per key, edited in place as work moves on, with Pause /
2
+ // Resume / Stop buttons. State (~/.roger-roger/progress/<key>.json) remembers which message to edit,
3
+ // what it last said, and the latest button the user pressed.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { rogerRogerHome } from "./lib.mjs";
8
+
9
+ // After this long a key starts a fresh message rather than editing one buried far up the DM.
10
+ const STALE_MS = 12 * 3600 * 1000;
11
+ // How long after its last update a progress message keeps the watcher (and so its buttons) alive.
12
+ const ACTIVE_MS = 2 * 3600 * 1000;
13
+
14
+ export const CONTROLS = ["running", "pause", "stop"];
15
+
16
+ function dir() {
17
+ return path.join(rogerRogerHome(), "progress");
18
+ }
19
+
20
+ function fileFor(key) {
21
+ return path.join(dir(), `${key}.json`);
22
+ }
23
+
24
+ /** Keys become file names, so keep them to a safe, readable alphabet. */
25
+ export function progressKey(value) {
26
+ const key = String(value).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
27
+ if (!key) throw new Error("--key must contain letters or digits");
28
+ return key;
29
+ }
30
+
31
+ /** The message a key is editing, or null if there is none (or it has gone stale). */
32
+ export function current(key, now = Date.now()) {
33
+ try {
34
+ const state = JSON.parse(fs.readFileSync(fileFor(key), "utf8"));
35
+ return now - Date.parse(state.updatedAt) < STALE_MS ? state : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ export function save(key, state) {
42
+ fs.mkdirSync(dir(), { recursive: true });
43
+ const tmp = `${fileFor(key)}.${process.pid}.tmp`;
44
+ fs.writeFileSync(tmp, JSON.stringify({ ...state, key }, null, 2) + "\n", "utf8");
45
+ fs.renameSync(tmp, fileFor(key));
46
+ }
47
+
48
+ /**
49
+ * What an agent's check sees: the user's Pause / Stop (now acknowledged, so the message can say the
50
+ * agent is really holding or stopping) and notes it hasn't read yet (now marked read).
51
+ */
52
+ export function acknowledge(state, now = new Date().toISOString()) {
53
+ const control = state?.control ?? { state: "running" };
54
+ const acked = control.state !== "running" && !control.ack ? { ...control, ack: now } : control;
55
+ const notes = state?.notes ?? [];
56
+ const unread = notes.filter((n) => !n.readAt);
57
+ return {
58
+ control: acked,
59
+ notes: notes.map((n) => (n.readAt ? n : { ...n, readAt: now })),
60
+ unread,
61
+ changed: acked !== control || unread.length > 0,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Save after a check without losing anything the user did meanwhile: a newer button press wins over
67
+ * the acknowledged one, and notes that arrived during the check stay unread.
68
+ */
69
+ export function mergeAcknowledged(latest, seen) {
70
+ const control = latest?.control && latest.control.at !== seen.control.at ? latest.control : seen.control;
71
+ const readAt = new Map(seen.notes.filter((n) => n.readAt).map((n) => [n.at, n.readAt]));
72
+ const notes = (latest?.notes ?? seen.notes).map((n) => (readAt.has(n.at) && !n.readAt ? { ...n, readAt: readAt.get(n.at) } : n));
73
+ return { control, notes };
74
+ }
75
+
76
+ export function finish(key) {
77
+ fs.rmSync(fileFor(key), { force: true });
78
+ }
79
+
80
+ /** Progress messages updated recently enough that their buttons should keep working. */
81
+ export function active(now = Date.now()) {
82
+ let files;
83
+ try {
84
+ files = fs.readdirSync(dir()).filter((f) => f.endsWith(".json"));
85
+ } catch {
86
+ return [];
87
+ }
88
+ return files
89
+ .map((f) => current(f.slice(0, -5), now))
90
+ .filter((s) => s && s.headline && now - Date.parse(s.updatedAt) < ACTIVE_MS);
91
+ }
@@ -0,0 +1,71 @@
1
+ // How the CLI talks to the daemon: newline-delimited JSON over a local socket. A named pipe on
2
+ // Windows, a unix socket elsewhere. No port is opened, so nothing on the network can reach it, and
3
+ // the socket lives or dies with the process that bound it.
4
+ //
5
+ // A request is { n, cmd, args, session }. The daemon answers with { n, ok, data, code } and may send
6
+ // { n, event, data } lines first, so a client blocked in `wait` hears about an answer the moment it
7
+ // lands instead of polling for it.
8
+
9
+ import crypto from "node:crypto";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { rogerRogerHome } from "./lib.mjs";
13
+
14
+ // macOS and Linux cap a unix socket path at ~104 bytes, and quietly fail to bind past it. A home
15
+ // under a temp directory — a test, or a machine that puts homes somewhere deep — can reach that
16
+ // without anyone noticing, so a long one gets a short name derived from it instead. Two homes still
17
+ // get two daemons either way.
18
+ const MAX_SOCKET_PATH = 100;
19
+
20
+ export function unixSocketPath(home, tmpDir = os.tmpdir()) {
21
+ const preferred = path.join(home, "daemon.sock");
22
+ if (Buffer.byteLength(preferred) <= MAX_SOCKET_PATH) return preferred;
23
+ const tag = crypto.createHash("sha1").update(home).digest("hex").slice(0, 12);
24
+ return path.join(tmpDir, `roger-roger-${tag}.sock`);
25
+ }
26
+
27
+ export const PROTOCOL_VERSION = 1;
28
+
29
+ /**
30
+ * The socket to talk on. It is derived from ROGER_ROGER_HOME, so a test (or a second account) gets its
31
+ * own daemon rather than joining the real one.
32
+ */
33
+ export function socketPath(env = process.env) {
34
+ const home = rogerRogerHome(env);
35
+ if (process.platform !== "win32") return unixSocketPath(home);
36
+ const tag = crypto.createHash("sha1").update(home.toLowerCase()).digest("hex").slice(0, 12);
37
+ return `\\\\.\\pipe\\roger-roger-${tag}`;
38
+ }
39
+
40
+ /** Reads a socket's NDJSON and calls `onMessage` for each complete line. */
41
+ export function lineReader(onMessage, onBad = () => {}) {
42
+ let buffer = "";
43
+ return (chunk) => {
44
+ buffer += chunk;
45
+ let nl;
46
+ while ((nl = buffer.indexOf("\n")) !== -1) {
47
+ const line = buffer.slice(0, nl).trim();
48
+ buffer = buffer.slice(nl + 1);
49
+ if (!line) continue;
50
+ let message;
51
+ try {
52
+ message = JSON.parse(line);
53
+ } catch (e) {
54
+ onBad(e, line);
55
+ continue;
56
+ }
57
+ onMessage(message);
58
+ }
59
+ };
60
+ }
61
+
62
+ export function send(socket, message) {
63
+ try {
64
+ socket.write(JSON.stringify(message) + "\n");
65
+ return true;
66
+ } catch {
67
+ return false; // The other end went away; callers treat that as a closed connection.
68
+ }
69
+ }
70
+
71
+ export { identity } from "./agent.mjs";