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,158 @@
|
|
|
1
|
+
// Pending decisions on disk (~/.roger-roger/decisions/<id>.json), plus settling one:
|
|
2
|
+
// recording the outcome and rewriting its Slack message so the buttons go away.
|
|
3
|
+
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { rogerRogerHome, outcomeText, resolvedBlocks, stripTags } from "./lib.mjs";
|
|
8
|
+
import { updateMessage } from "./slack.mjs";
|
|
9
|
+
|
|
10
|
+
const KEEP_RESOLVED_MS = 7 * 24 * 3600 * 1000;
|
|
11
|
+
|
|
12
|
+
export function decisionsDir() {
|
|
13
|
+
return path.join(rogerRogerHome(), "decisions");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fileFor(id) {
|
|
17
|
+
if (!/^[a-z0-9]+$/.test(id)) throw new Error(`invalid decision id "${id}"`);
|
|
18
|
+
return path.join(decisionsDir(), `${id}.json`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function newId() {
|
|
22
|
+
return crypto.randomBytes(4).toString("hex");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Write via a temp file and rename, so a reader never sees half a decision. */
|
|
26
|
+
function write(decision) {
|
|
27
|
+
fs.mkdirSync(decisionsDir(), { recursive: true });
|
|
28
|
+
const file = fileFor(decision.id);
|
|
29
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
30
|
+
fs.writeFileSync(tmp, JSON.stringify(decision, null, 2) + "\n", "utf8");
|
|
31
|
+
fs.renameSync(tmp, file);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function create(decision) {
|
|
35
|
+
write(decision);
|
|
36
|
+
return decision;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function read(id) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(fileFor(id), "utf8"));
|
|
42
|
+
} catch (e) {
|
|
43
|
+
if (e.code === "ENOENT") return null;
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** All decisions, newest first. Resolved ones older than a week are deleted on the way. */
|
|
49
|
+
export function list() {
|
|
50
|
+
let files;
|
|
51
|
+
try {
|
|
52
|
+
files = fs.readdirSync(decisionsDir()).filter((f) => f.endsWith(".json"));
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const all = [];
|
|
58
|
+
for (const f of files) {
|
|
59
|
+
try {
|
|
60
|
+
const d = JSON.parse(fs.readFileSync(path.join(decisionsDir(), f), "utf8"));
|
|
61
|
+
if (d.status !== "pending" && now - Date.parse(d.resolvedAt ?? d.createdAt) > KEEP_RESOLVED_MS) {
|
|
62
|
+
fs.rmSync(path.join(decisionsDir(), f), { force: true });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
all.push(d);
|
|
66
|
+
} catch {
|
|
67
|
+
// A file mid-write or hand-edited into garbage is skipped, not fatal.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return all.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Change a still-pending decision's bookkeeping (e.g. reminders sent). Returns it, or null if settled. */
|
|
74
|
+
export function patchPending(id, patch) {
|
|
75
|
+
const current = read(id);
|
|
76
|
+
if (!current || current.status !== "pending") return null;
|
|
77
|
+
const next = { ...current, ...patch };
|
|
78
|
+
write(next);
|
|
79
|
+
return next;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Settle a decision whose time is up: its recommended option if it has one and asked for that, else expired. */
|
|
83
|
+
export function expire(decision, log, options) {
|
|
84
|
+
const r = decision.recommended;
|
|
85
|
+
if (decision.onExpire === "recommended" && decision.multi && Array.isArray(r) && r.length) {
|
|
86
|
+
const indices = r.filter((i) => decision.choices[i] !== undefined);
|
|
87
|
+
return settle(decision.id, {
|
|
88
|
+
status: "answered",
|
|
89
|
+
answer: { indices, choices: indices.map((i) => decision.choices[i]), by: null, auto: true },
|
|
90
|
+
}, log, options);
|
|
91
|
+
}
|
|
92
|
+
if (decision.onExpire === "recommended" && Number.isInteger(r) && decision.choices[r] !== undefined) {
|
|
93
|
+
return settle(decision.id, { status: "answered", answer: { index: r, choice: decision.choices[r], by: null, auto: true } }, log, options);
|
|
94
|
+
}
|
|
95
|
+
return settle(decision.id, { status: "expired" }, log, options);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isExpired(decision, now = Date.now()) {
|
|
99
|
+
return decision.status === "pending" && Date.parse(decision.expiresAt) <= now;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Move a pending decision to a final state and update its Slack message.
|
|
104
|
+
* Returns the settled decision (with `slackUpdateError` if the message could not be
|
|
105
|
+
* rewritten), or null if it was already settled: the first outcome wins.
|
|
106
|
+
*/
|
|
107
|
+
export async function settle(id, patch, log = () => {}, { waiting = false } = {}) {
|
|
108
|
+
const current = read(id);
|
|
109
|
+
if (!current || current.status !== "pending") return null;
|
|
110
|
+
const now = new Date().toISOString();
|
|
111
|
+
// An answer belongs to the session that asked until it has actually been handed over. While
|
|
112
|
+
// `deliveredAt` is null it waits here, and the next command from that agent — any command — picks
|
|
113
|
+
// it up. Without that, an agent that stopped waiting never learns what the user said.
|
|
114
|
+
const owed = Boolean(current.sessionId) && patch.status === "answered";
|
|
115
|
+
const settled = { ...current, ...patch, resolvedAt: now, ...(owed ? { deliveredAt: waiting ? now : null } : {}) };
|
|
116
|
+
write(settled);
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const outcome = outcomeText(settled);
|
|
120
|
+
await updateMessage(settled.channel, settled.ts, {
|
|
121
|
+
text: `${stripTags(settled.question)} (${outcome})`,
|
|
122
|
+
blocks: resolvedBlocks(settled, outcome),
|
|
123
|
+
});
|
|
124
|
+
return settled;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
log(`could not update Slack message for ${id}: ${e.message}`);
|
|
127
|
+
return { ...settled, slackUpdateError: e.message };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Answers this session has not been handed yet, oldest first. */
|
|
132
|
+
export function undeliveredFor(sessionId) {
|
|
133
|
+
if (!sessionId) return [];
|
|
134
|
+
return list()
|
|
135
|
+
.filter((d) => d.sessionId === sessionId && d.status === "answered" && d.deliveredAt === null)
|
|
136
|
+
.sort((a, b) => Date.parse(a.resolvedAt) - Date.parse(b.resolvedAt));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Hand an answer over, and say so in Slack so the message stops claiming nobody has read it.
|
|
141
|
+
* Returns the delivered decision, or null if there was nothing to hand over.
|
|
142
|
+
*/
|
|
143
|
+
export async function deliver(id, log = () => {}) {
|
|
144
|
+
const current = read(id);
|
|
145
|
+
if (!current || current.status !== "answered" || current.deliveredAt !== null) return null;
|
|
146
|
+
const delivered = { ...current, deliveredAt: new Date().toISOString() };
|
|
147
|
+
write(delivered);
|
|
148
|
+
try {
|
|
149
|
+
const outcome = outcomeText(delivered);
|
|
150
|
+
await updateMessage(delivered.channel, delivered.ts, {
|
|
151
|
+
text: `${stripTags(delivered.question)} (${outcome})`,
|
|
152
|
+
blocks: resolvedBlocks(delivered, outcome),
|
|
153
|
+
});
|
|
154
|
+
} catch (e) {
|
|
155
|
+
log(`could not update Slack message for ${id}: ${e.message}`);
|
|
156
|
+
}
|
|
157
|
+
return delivered;
|
|
158
|
+
}
|