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,1133 @@
|
|
|
1
|
+
// Pure helpers for the roger-roger skill: config, argument parsing, text shaping, WAV,
|
|
2
|
+
// and the Slack message shapes for decisions. Nothing in here touches the network,
|
|
3
|
+
// spawns processes, or plays audio.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
import { badge, colourOf } from "./names.mjs";
|
|
11
|
+
import { detectAgent } from "./agent.mjs";
|
|
12
|
+
|
|
13
|
+
export const SKILL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
export const SOUNDS_DIR = path.join(SKILL_DIR, "sounds");
|
|
15
|
+
|
|
16
|
+
export const METHODS = ["slack", "sound", "speech"];
|
|
17
|
+
export const KINDS = ["done", "blocked", "input", "milestone", "error", "info"];
|
|
18
|
+
export const SPEECH_MODES = ["auto", "same", "brief"];
|
|
19
|
+
export const WHEN_MODES = ["auto", "done-and-blocked", "blocked-only", "on-request"];
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_MODEL = "gemini-3.1-flash-tts-preview";
|
|
22
|
+
|
|
23
|
+
/** How far other sounds go down while the voice speaks, as a share of their own volume. */
|
|
24
|
+
export const DUCK_LEVELS = { off: 1, quieter: 0.4, "much-quieter": 0.15, silent: 0 };
|
|
25
|
+
export const DUCK_MODES = ["on", "off"];
|
|
26
|
+
|
|
27
|
+
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The share of their own volume other apps keep while the voice speaks: 1 when ducking is off.
|
|
31
|
+
* The level is `duckLevel` (0–100); a config from before there was a slider says it by name.
|
|
32
|
+
*/
|
|
33
|
+
export function duckFactor(config) {
|
|
34
|
+
const duck = config?.duck ?? "on";
|
|
35
|
+
if (duck === "off" || duck === false) return 1;
|
|
36
|
+
if (Number.isFinite(config?.duckLevel)) return clamp(config.duckLevel, 0, 100) / 100;
|
|
37
|
+
return DUCK_LEVELS[duck] ?? DUCK_LEVELS.quieter;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The voice's volume as a factor: 1 is as the provider made it. */
|
|
41
|
+
export const speechGain = (config) => clamp(Number.isFinite(config?.speechVolume) ? config.speechVolume : 100, 0, 150) / 100;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A 16-bit PCM WAV with every sample scaled by `gain`, clipped rather than wrapped. Anything that
|
|
45
|
+
* isn't 16-bit PCM is returned as it came: better at the wrong volume than not at all. The data
|
|
46
|
+
* chunk runs to the end of the buffer, whatever its header says — a streamed WAV says nonsense.
|
|
47
|
+
*/
|
|
48
|
+
export function scaleWav(wav, gain) {
|
|
49
|
+
if (gain === 1 || !Buffer.isBuffer(wav) || wav.length < 44 || wav.toString("ascii", 0, 4) !== "RIFF") return wav;
|
|
50
|
+
let offset = 12;
|
|
51
|
+
let format = null;
|
|
52
|
+
let bits = null;
|
|
53
|
+
while (offset + 8 <= wav.length) {
|
|
54
|
+
const id = wav.toString("ascii", offset, offset + 4);
|
|
55
|
+
const size = wav.readUInt32LE(offset + 4);
|
|
56
|
+
if (id === "fmt ") {
|
|
57
|
+
format = wav.readUInt16LE(offset + 8);
|
|
58
|
+
bits = wav.readUInt16LE(offset + 22);
|
|
59
|
+
} else if (id === "data") {
|
|
60
|
+
if (format !== 1 || bits !== 16) return wav;
|
|
61
|
+
const out = Buffer.from(wav);
|
|
62
|
+
for (let i = offset + 8; i + 1 < out.length; i += 2) {
|
|
63
|
+
out.writeInt16LE(clamp(Math.round(out.readInt16LE(i) * gain), -32768, 32767), i);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
offset += 8 + size + (size % 2);
|
|
68
|
+
}
|
|
69
|
+
return wav;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Gemini TTS prebuilt voices. The API has no list endpoint, so this is the published set.
|
|
73
|
+
export const VOICES = {
|
|
74
|
+
Zephyr: "Bright", Puck: "Upbeat", Charon: "Informative", Kore: "Firm",
|
|
75
|
+
Fenrir: "Excitable", Leda: "Youthful", Orus: "Firm", Aoede: "Breezy",
|
|
76
|
+
Callirrhoe: "Easy-going", Autonoe: "Bright", Enceladus: "Breathy", Iapetus: "Clear",
|
|
77
|
+
Umbriel: "Easy-going", Algieba: "Smooth", Despina: "Smooth", Erinome: "Clear",
|
|
78
|
+
Algenib: "Gravelly", Rasalgethi: "Informative", Laomedeia: "Upbeat", Achernar: "Soft",
|
|
79
|
+
Alnilam: "Firm", Schedar: "Even", Gacrux: "Mature", Pulcherrima: "Forward",
|
|
80
|
+
Achird: "Friendly", Zubenelgenubi: "Casual", Vindemiatrix: "Gentle", Sadachbia: "Lively",
|
|
81
|
+
Sadaltager: "Knowledgeable", Sulafat: "Warm",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Case-insensitive voice lookup returning the canonical name; throws on an unknown voice. */
|
|
85
|
+
export function matchVoice(name) {
|
|
86
|
+
const match = Object.keys(VOICES).find((v) => v.toLowerCase() === String(name).toLowerCase());
|
|
87
|
+
if (!match) throw new Error(`--voice must be one of: ${Object.keys(VOICES).join(", ")}`);
|
|
88
|
+
return match;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A varied shortlist for `preview-voices` when the user doesn't name voices; `--all` plays every one. */
|
|
92
|
+
export const PREVIEW_VOICES = ["Kore", "Puck", "Charon", "Aoede", "Achird", "Sulafat"];
|
|
93
|
+
|
|
94
|
+
/** What each voice says when previewed. */
|
|
95
|
+
export function voiceSample(name) {
|
|
96
|
+
return `Hi, I'm ${name}. [short pause] I'll let you know when your work is done.`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Spoken before each sound in `preview-sounds`, so the user can tell them apart. */
|
|
100
|
+
export function soundAnnouncement(name) {
|
|
101
|
+
return `This is ${name}.`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Which items a preview covers: the positionals if given (validated against `available`),
|
|
106
|
+
* otherwise `fallback`. `match` canonicalises a name or throws.
|
|
107
|
+
*/
|
|
108
|
+
export function previewList(requested, available, fallback, match = (n) => n) {
|
|
109
|
+
if (!requested.length) return fallback;
|
|
110
|
+
return [...new Set(requested.map((n) => {
|
|
111
|
+
const canonical = match(n);
|
|
112
|
+
if (!available.includes(canonical)) throw new Error(`unknown "${n}"; available: ${available.join(", ")}`);
|
|
113
|
+
return canonical;
|
|
114
|
+
}))];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const DEFAULTS = {
|
|
118
|
+
version: 1,
|
|
119
|
+
methods: ["sound", "speech"],
|
|
120
|
+
sound: "chime",
|
|
121
|
+
voice: "Kore",
|
|
122
|
+
model: DEFAULT_MODEL,
|
|
123
|
+
localFallback: true,
|
|
124
|
+
speech: "auto",
|
|
125
|
+
when: "auto",
|
|
126
|
+
slack: { target: "", mention: "" },
|
|
127
|
+
// Reminders while a question is unanswered, measured from when it was asked.
|
|
128
|
+
remind: ["15m"],
|
|
129
|
+
// What an unanswered question settles as: its recommended option, or nothing (expired).
|
|
130
|
+
onExpire: "recommended",
|
|
131
|
+
// e.g. { from: "22:00", to: "08:00", mute: ["speech", "sound"] }; null means never quiet.
|
|
132
|
+
quietHours: null,
|
|
133
|
+
// Quiet until this moment, set by hand when something comes up. Null means not snoozed.
|
|
134
|
+
snoozeUntil: null,
|
|
135
|
+
// Show "machine · agent · session" under Slack messages, to tell several agents apart.
|
|
136
|
+
sessionLabel: true,
|
|
137
|
+
// Start a spoken line with the agent's name, so a voice from the next room is identifiable.
|
|
138
|
+
sayWho: true,
|
|
139
|
+
// Ping when an agent stops at an ordinary permission prompt (Claude Code hooks). Questions and plans
|
|
140
|
+
// to approve always ping; a prompt to run a command mostly means someone is at the keyboard anyway.
|
|
141
|
+
permissionPings: false,
|
|
142
|
+
// Seconds a question waits in the terminal before the ping goes out, for agents that can't tell
|
|
143
|
+
// whether anyone is typing (OpenCode, Codex). Claude Code waits until nobody has typed for a bit.
|
|
144
|
+
terminalWait: 20,
|
|
145
|
+
// Who makes the voice, and which environment variable holds that provider's key. An empty variable
|
|
146
|
+
// means the provider's usual one (GEMINI_API_KEY for Gemini, OPENAI_API_KEY for OpenAI, …).
|
|
147
|
+
speechProvider: "gemini",
|
|
148
|
+
speechKeyVar: "",
|
|
149
|
+
// Everything else playing is turned down while the voice speaks: "on" or "off", and to what share
|
|
150
|
+
// of its own volume (0–100). On by default: a notification you can't hear over a video is missed.
|
|
151
|
+
duck: "on",
|
|
152
|
+
duckLevel: 40,
|
|
153
|
+
// The voice's own volume, 0–150: 100 is as the provider made it.
|
|
154
|
+
speechVolume: 100,
|
|
155
|
+
// The tray icon: "auto" starts it with the daemon whenever it is installed, "off" leaves it to `tray start`.
|
|
156
|
+
tray: "auto",
|
|
157
|
+
notes: "",
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const ON_EXPIRE = ["recommended", "nothing"];
|
|
161
|
+
|
|
162
|
+
const KIND_EMOJI = {
|
|
163
|
+
done: ":white_check_mark:",
|
|
164
|
+
blocked: ":octagonal_sign:",
|
|
165
|
+
input: ":raising_hand:",
|
|
166
|
+
milestone: ":triangular_flag_on_post:",
|
|
167
|
+
error: ":x:",
|
|
168
|
+
info: ":speech_balloon:",
|
|
169
|
+
progress: ":hourglass_flowing_sand:",
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** Is a process still running? EPERM means it exists but belongs to someone else. */
|
|
173
|
+
export function pidAlive(pid) {
|
|
174
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
175
|
+
try {
|
|
176
|
+
process.kill(pid, 0);
|
|
177
|
+
return true;
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return e.code === "EPERM";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Everything the skill stores lives under one dot-directory: config, decisions, daemon lock. */
|
|
184
|
+
export function rogerRogerHome(env = process.env) {
|
|
185
|
+
return env.ROGER_ROGER_HOME || path.join(os.homedir(), ".roger-roger");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Where this skill kept its things when it was called "attention". Only the default location is
|
|
190
|
+
* looked at: someone who pointed ATTENTION_HOME elsewhere knows where their files are.
|
|
191
|
+
*/
|
|
192
|
+
export const legacyHome = () => path.join(os.homedir(), ".attention");
|
|
193
|
+
|
|
194
|
+
// What belonged to the old daemon's process rather than to the user: locks, sockets, pid files and
|
|
195
|
+
// the tray's drawing of a moment that has passed. Copying them would only confuse the new daemon.
|
|
196
|
+
const LEFT_BEHIND = new Set(["daemon.json", "daemon.sock", "speaker.lock", "tray.pid", "tray.json", "webview"]);
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The first run after the rename: copy ~/.attention to ~/.roger-roger, so the config, the Slack
|
|
200
|
+
* tokens, open questions and the tray's packages come along instead of everything quietly looking
|
|
201
|
+
* unconfigured. Copied, not moved — the old folder stays as it was, in case anyone goes back.
|
|
202
|
+
* Returns what happened, or null when there was nothing to do.
|
|
203
|
+
*/
|
|
204
|
+
export function migrateLegacyHome({ env = process.env, from = legacyHome(), hooks = null } = {}) {
|
|
205
|
+
if (env.ROGER_ROGER_HOME) return null;
|
|
206
|
+
const home = rogerRogerHome(env);
|
|
207
|
+
if (fs.existsSync(home) || !fs.existsSync(from)) return null;
|
|
208
|
+
// Built beside the real one and renamed into place, so a second command starting at the same
|
|
209
|
+
// moment either sees nothing yet or sees all of it.
|
|
210
|
+
const staging = `${home}.migrating-${process.pid}`;
|
|
211
|
+
try {
|
|
212
|
+
fs.cpSync(from, staging, {
|
|
213
|
+
recursive: true,
|
|
214
|
+
filter: (src) => {
|
|
215
|
+
const rel = path.relative(from, src);
|
|
216
|
+
if (!rel) return true;
|
|
217
|
+
const top = rel.split(path.sep)[0];
|
|
218
|
+
return !LEFT_BEHIND.has(top) && !(rel === top && /\.(log|tmp)$/.test(top));
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
// The Slack CLI runs the app's hooks by command line, and those still name the old script.
|
|
222
|
+
const hooksFile = path.join(staging, "slack-app", ".slack", "hooks.json");
|
|
223
|
+
if (hooks && fs.existsSync(hooksFile)) fs.writeFileSync(hooksFile, JSON.stringify(hooks, null, 2) + "\n");
|
|
224
|
+
fs.renameSync(staging, home);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
227
|
+
if (fs.existsSync(home)) return null; // someone else finished first
|
|
228
|
+
throw e;
|
|
229
|
+
}
|
|
230
|
+
return { migrated: true, from, to: home };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function configPath(env = process.env) {
|
|
234
|
+
return path.join(rogerRogerHome(env), "config.json");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function listSounds(dir = SOUNDS_DIR) {
|
|
238
|
+
try {
|
|
239
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".wav")).map((f) => f.slice(0, -4)).sort();
|
|
240
|
+
} catch {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function loadConfig(file = configPath()) {
|
|
246
|
+
if (!fs.existsSync(file)) return null;
|
|
247
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8").replace(/^/, ""));
|
|
248
|
+
return { ...DEFAULTS, ...raw, slack: { ...DEFAULTS.slack, ...(raw.slack ?? {}) } };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Written beside the real file and renamed over it. The daemon reads the config every few seconds,
|
|
253
|
+
* and a read that landed in the middle of a plain rewrite saw half a file and took the daemon down.
|
|
254
|
+
*/
|
|
255
|
+
export function saveConfig(config, file = configPath()) {
|
|
256
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
257
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
258
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
259
|
+
fs.renameSync(tmp, file);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The config, or null when it is missing or unreadable. For the places that must keep going — the
|
|
264
|
+
* daemon's loop above all — where a typo in a hand-edited file should cost a log line, not the process.
|
|
265
|
+
*/
|
|
266
|
+
export function loadConfigSafely(file = configPath(), log = () => {}) {
|
|
267
|
+
try {
|
|
268
|
+
return loadConfig(file);
|
|
269
|
+
} catch (e) {
|
|
270
|
+
log(`config is unreadable, carrying on without it: ${e.message}`);
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// What an agent puts in the environment to say which session this is. A process started on behalf
|
|
276
|
+
// of the machine — the daemon, the tray — must not carry them, or everything it does is credited to
|
|
277
|
+
// whichever agent happened to start it.
|
|
278
|
+
const AGENT_ENV = /^(?:CLAUDECODE|CLAUDE_CODE_.*|CLAUDE_PID|CODEX_.*|OPENCODE_.*|AI_AGENT|ROGER_ROGER_SESSION_ID|ROGER_ROGER_AGENT_PID|HERDR_.*|[A-Z][A-Z0-9_]*_(?:SESSION|THREAD|CONVERSATION)_ID|[A-Z][A-Z0-9_]*_SESSION_ATTENDED)$/;
|
|
279
|
+
|
|
280
|
+
/** The environment for a long-lived process that belongs to nobody in particular. */
|
|
281
|
+
export function machineEnv(env = process.env) {
|
|
282
|
+
return Object.fromEntries(Object.entries(env).filter(([k]) => !AGENT_ENV.test(k)));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Where `slack-setup` keeps the tokens the Slack CLI created for the skill's own app. */
|
|
286
|
+
export function slackCredentialsPath(env = process.env) {
|
|
287
|
+
return path.join(rogerRogerHome(env), "slack.json");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Tokens saved by `slack-setup`, or null. */
|
|
291
|
+
export function readSlackCredentials(file = slackCredentialsPath()) {
|
|
292
|
+
try {
|
|
293
|
+
const c = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
294
|
+
return c && typeof c.botToken === "string" ? c : null;
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The bot token: the one `slack-setup` saved, else the manual environment variables (the documented
|
|
302
|
+
* name first, then the common alternative). Saved credentials are only consulted for the real
|
|
303
|
+
* process environment, so a caller passing its own `env` sees exactly that.
|
|
304
|
+
*/
|
|
305
|
+
export function slackToken(env = process.env, stored = env === process.env ? readSlackCredentials() : null) {
|
|
306
|
+
return stored?.botToken || (env.SLACK_API_BOT_TOKEN || env.SLACK_BOT_TOKEN || "").trim();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Button clicks arrive over Socket Mode, which needs an app-level `xapp-` token: the saved one,
|
|
311
|
+
* else `ROGER_ROGER_SLACK_APP_TOKEN`, else `SLACK_APP_TOKEN` (for machines where that already holds
|
|
312
|
+
* something else, the first wins). A value that isn't an `xapp-` token is ignored.
|
|
313
|
+
*/
|
|
314
|
+
export function slackAppToken(env = process.env, stored = env === process.env ? readSlackCredentials() : null) {
|
|
315
|
+
if (stored?.appToken?.startsWith("xapp-")) return stored.appToken;
|
|
316
|
+
const t = (env.ROGER_ROGER_SLACK_APP_TOKEN || env.SLACK_APP_TOKEN || "").trim();
|
|
317
|
+
return t.startsWith("xapp-") ? t : "";
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
/** `--key value`, `--key=value` and bare `--flag` (true). Positionals collect in `_`. */
|
|
322
|
+
export function parseArgs(argv) {
|
|
323
|
+
const out = { _: [] };
|
|
324
|
+
for (let i = 0; i < argv.length; i++) {
|
|
325
|
+
const a = argv[i];
|
|
326
|
+
if (!a.startsWith("--")) {
|
|
327
|
+
out._.push(a);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const eq = a.indexOf("=");
|
|
331
|
+
let key;
|
|
332
|
+
let value;
|
|
333
|
+
if (eq !== -1) {
|
|
334
|
+
[key, value] = [a.slice(2, eq), a.slice(eq + 1)];
|
|
335
|
+
} else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
|
|
336
|
+
[key, value] = [a.slice(2), argv[++i]];
|
|
337
|
+
} else {
|
|
338
|
+
[key, value] = [a.slice(2), true];
|
|
339
|
+
}
|
|
340
|
+
// A repeated flag (e.g. several --attach) collects into an array.
|
|
341
|
+
out[key] = key in out ? [].concat(out[key], value) : value;
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** A flag that may be given several times → array of strings. */
|
|
347
|
+
export function listArg(value) {
|
|
348
|
+
if (value === undefined) return [];
|
|
349
|
+
return [].concat(value).filter((v) => typeof v === "string" && v.trim());
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function splitList(value) {
|
|
353
|
+
if (value === undefined || value === true) return [];
|
|
354
|
+
return String(value).split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function bool(value, name) {
|
|
358
|
+
if (value === true || value === "true" || value === "yes" || value === "1") return true;
|
|
359
|
+
if (value === "false" || value === "no" || value === "0") return false;
|
|
360
|
+
throw new Error(`--${name} must be true or false`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** A whole percentage from `0` to `max`, with or without the % sign. */
|
|
364
|
+
function percent(value, name, max) {
|
|
365
|
+
const n = Number(String(value).trim().replace(/%$/, ""));
|
|
366
|
+
if (!Number.isFinite(n) || n < 0 || n > max) throw new Error(`--${name} must be a number from 0 to ${max}`);
|
|
367
|
+
return Math.round(n);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function oneOf(value, allowed, name) {
|
|
371
|
+
if (!allowed.includes(value)) throw new Error(`--${name} must be one of: ${allowed.join(", ")}`);
|
|
372
|
+
return value;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* What setup needs to know about speech providers. The real table lives in tts.mjs, which talks to
|
|
377
|
+
* the network; this is the part of it that works offline, and Gemini alone when nothing better is
|
|
378
|
+
* passed — which is how everything before there was a choice behaved.
|
|
379
|
+
*/
|
|
380
|
+
const GEMINI_ONLY = {
|
|
381
|
+
ids: ["gemini"],
|
|
382
|
+
defaults: { gemini: { voice: "Kore", model: DEFAULT_MODEL } },
|
|
383
|
+
findVoice: (provider, name) => {
|
|
384
|
+
const match = Object.keys(VOICES).find((v) => v.toLowerCase() === String(name).toLowerCase());
|
|
385
|
+
return match ? { id: match } : null;
|
|
386
|
+
},
|
|
387
|
+
knowsVoices: () => true,
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Merge setup flags into an existing (or default) config, validating each one.
|
|
392
|
+
* Throws with a message naming the offending flag.
|
|
393
|
+
*/
|
|
394
|
+
export function applySetup(base, args, sounds = listSounds(), speech = GEMINI_ONLY) {
|
|
395
|
+
const c = { ...DEFAULTS, ...(base ?? {}), slack: { ...DEFAULTS.slack, ...(base?.slack ?? {}) } };
|
|
396
|
+
delete c.names; // once a setting; nicknames are the built-in colours now, so nobody has to choose
|
|
397
|
+
|
|
398
|
+
if (args.methods !== undefined) {
|
|
399
|
+
const methods = splitList(args.methods);
|
|
400
|
+
if (methods.length === 0) throw new Error("--methods needs at least one of: " + METHODS.join(", "));
|
|
401
|
+
for (const m of methods) oneOf(m, METHODS, "methods");
|
|
402
|
+
c.methods = [...new Set(methods)];
|
|
403
|
+
}
|
|
404
|
+
if (args.sound !== undefined) {
|
|
405
|
+
if (sounds.length && !sounds.includes(args.sound)) {
|
|
406
|
+
throw new Error(`--sound must be one of: ${sounds.join(", ")}`);
|
|
407
|
+
}
|
|
408
|
+
c.sound = args.sound;
|
|
409
|
+
}
|
|
410
|
+
// A different provider has different voices, models and a different key: start it from its own
|
|
411
|
+
// defaults, and let anything given in the same breath override them.
|
|
412
|
+
if (args["speech-provider"] !== undefined) {
|
|
413
|
+
const provider = oneOf(String(args["speech-provider"]).toLowerCase(), speech.ids, "speech-provider");
|
|
414
|
+
if (provider !== (c.speechProvider || "gemini")) {
|
|
415
|
+
c.speechProvider = provider;
|
|
416
|
+
c.voice = speech.defaults[provider].voice;
|
|
417
|
+
c.model = speech.defaults[provider].model;
|
|
418
|
+
c.speechKeyVar = "";
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (args.voice !== undefined) {
|
|
422
|
+
const provider = c.speechProvider || "gemini";
|
|
423
|
+
const found = speech.findVoice(provider, args.voice);
|
|
424
|
+
// A provider whose voices we haven't fetched yet can't be checked; the name is taken on trust.
|
|
425
|
+
if (!found && speech.knowsVoices(provider)) throw new Error(`--voice: "${args.voice}" is not a ${provider} voice (see \`voices\`)`);
|
|
426
|
+
c.voice = found ? found.id : String(args.voice).trim();
|
|
427
|
+
}
|
|
428
|
+
if (args.model !== undefined) c.model = String(args.model).trim() || speech.defaults[c.speechProvider || "gemini"].model;
|
|
429
|
+
if (args["local-fallback"] !== undefined) c.localFallback = bool(args["local-fallback"], "local-fallback");
|
|
430
|
+
if (args.speech !== undefined) c.speech = oneOf(args.speech, SPEECH_MODES, "speech");
|
|
431
|
+
if (args.when !== undefined) c.when = oneOf(args.when, WHEN_MODES, "when");
|
|
432
|
+
if (args["slack-target"] !== undefined) c.slack.target = String(args["slack-target"]).trim();
|
|
433
|
+
if (args["slack-mention"] !== undefined) c.slack.mention = String(args["slack-mention"]).trim();
|
|
434
|
+
if (args.notes !== undefined) c.notes = String(args.notes);
|
|
435
|
+
if (args.snooze !== undefined) {
|
|
436
|
+
const value = String(args.snooze).trim().toLowerCase();
|
|
437
|
+
c.snoozeUntil = value === "off" || value === "" ? null : new Date(Date.now() + parseDuration(value) * 1000).toISOString();
|
|
438
|
+
}
|
|
439
|
+
if (args["say-who"] !== undefined) {
|
|
440
|
+
const v = String(args["say-who"]).toLowerCase();
|
|
441
|
+
c.sayWho = v === "on" ? true : v === "off" ? false : bool(args["say-who"], "say-who");
|
|
442
|
+
}
|
|
443
|
+
if (args["terminal-wait"] !== undefined) {
|
|
444
|
+
const seconds = parseDuration(String(args["terminal-wait"]));
|
|
445
|
+
if (!Number.isFinite(seconds) || seconds < 0 || seconds > 3600) throw new Error("--terminal-wait must be a duration from 0s to 1h, e.g. 20s or 2m");
|
|
446
|
+
c.terminalWait = seconds;
|
|
447
|
+
}
|
|
448
|
+
if (args["permission-pings"] !== undefined) {
|
|
449
|
+
const v = String(args["permission-pings"]).toLowerCase();
|
|
450
|
+
c.permissionPings = v === "on" ? true : v === "off" ? false : bool(args["permission-pings"], "permission-pings");
|
|
451
|
+
}
|
|
452
|
+
if (args["session-label"] !== undefined) {
|
|
453
|
+
const v = String(args["session-label"]).toLowerCase();
|
|
454
|
+
c.sessionLabel = v === "on" ? true : v === "off" ? false : bool(args["session-label"], "session-label");
|
|
455
|
+
}
|
|
456
|
+
if (args["speech-key-var"] !== undefined) {
|
|
457
|
+
const value = String(args["speech-key-var"]).trim();
|
|
458
|
+
if (value && value.toLowerCase() !== "auto" && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
459
|
+
throw new Error("--speech-key-var must be the name of an environment variable, like GEMINI_API_KEY (or auto)");
|
|
460
|
+
}
|
|
461
|
+
c.speechKeyVar = value.toLowerCase() === "auto" ? "" : value.toUpperCase();
|
|
462
|
+
}
|
|
463
|
+
if (args.duck !== undefined) {
|
|
464
|
+
const v = String(args.duck).toLowerCase();
|
|
465
|
+
// The old names still work, and say how far down as well as that it's on.
|
|
466
|
+
if (v in DUCK_LEVELS && v !== "off") {
|
|
467
|
+
c.duck = "on";
|
|
468
|
+
c.duckLevel = Math.round(DUCK_LEVELS[v] * 100);
|
|
469
|
+
} else {
|
|
470
|
+
c.duck = oneOf(v === "true" ? "on" : v === "false" ? "off" : v, DUCK_MODES, "duck");
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (args["duck-level"] !== undefined) c.duckLevel = percent(args["duck-level"], "duck-level", 100);
|
|
474
|
+
if (args["speech-volume"] !== undefined) c.speechVolume = percent(args["speech-volume"], "speech-volume", 150);
|
|
475
|
+
if (args.tray !== undefined) {
|
|
476
|
+
const v = String(args.tray).toLowerCase();
|
|
477
|
+
c.tray = v === "on" || v === "true" || v === "auto" ? "auto" : v === "off" || v === "false" ? "off" : oneOf(v, ["auto", "off"], "tray");
|
|
478
|
+
}
|
|
479
|
+
if (args.remind !== undefined) c.remind = parseRemind(args.remind).map((s) => formatDuration(s));
|
|
480
|
+
if (args["on-expire"] !== undefined) c.onExpire = oneOf(args["on-expire"], ON_EXPIRE, "on-expire");
|
|
481
|
+
if (args["quiet-hours"] !== undefined) {
|
|
482
|
+
const range = parseQuietHours(args["quiet-hours"]);
|
|
483
|
+
c.quietHours = range && { ...range, mute: c.quietHours?.mute ?? ["speech", "sound"] };
|
|
484
|
+
}
|
|
485
|
+
if (args["quiet-mute"] !== undefined) {
|
|
486
|
+
if (!c.quietHours) throw new Error("--quiet-mute needs quiet hours: pass --quiet-hours 22:00-08:00 too");
|
|
487
|
+
const mute = splitList(args["quiet-mute"]);
|
|
488
|
+
for (const m of mute) oneOf(m, METHODS, "quiet-mute");
|
|
489
|
+
c.quietHours = { ...c.quietHours, mute: [...new Set(mute)] };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (c.methods.includes("slack") && !c.slack.target) {
|
|
493
|
+
throw new Error("slack is enabled but --slack-target is not set (a member ID like U0123ABCD, or a channel)");
|
|
494
|
+
}
|
|
495
|
+
return c;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Audio tags like `[whispers]` steer Gemini; everywhere else they are noise. */
|
|
499
|
+
//
|
|
500
|
+
// Only something shaped like one counts: lowercase words standing on their own. `[JIRA-123]`,
|
|
501
|
+
// `arr[0]` and `pages/[id].tsx` are the user's text and go to Slack untouched.
|
|
502
|
+
export function stripTags(text) {
|
|
503
|
+
return String(text)
|
|
504
|
+
.replace(/(^|\s)\[[a-z][a-z' -]{0,38}\](?=$|\s|[,.!?;:])/g, "$1")
|
|
505
|
+
.replace(/\s+([,.!?;:])/g, "$1")
|
|
506
|
+
.replace(/\s{2,}/g, " ")
|
|
507
|
+
.trim();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Slack refuses a whole message over one section longer than 3000 characters, so cut it first. */
|
|
511
|
+
export function fitSection(text, max = 3000) {
|
|
512
|
+
const t = String(text ?? "");
|
|
513
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function isUserId(target) {
|
|
517
|
+
return /^[UW][A-Z0-9]{6,}$/.test(target);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function slackText({ kind = "info", message, project, mention }) {
|
|
521
|
+
const emoji = KIND_EMOJI[kind] ?? KIND_EMOJI.info;
|
|
522
|
+
const who = mention ? `<@${mention}> ` : "";
|
|
523
|
+
const where = project ? `*${project}* · ` : "";
|
|
524
|
+
return `${emoji} ${who}${where}${stripTags(message)}`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** What gets spoken: the agent's `--say` line, unless the user wants the message read verbatim. */
|
|
528
|
+
export function speechText({ mode = "auto", message, say }) {
|
|
529
|
+
if (mode === "same") return message;
|
|
530
|
+
return say || message;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Several agents share one pair of speakers, and a voice from the next room with no name on it is
|
|
534
|
+
// a guessing game. Varied openers so it stays a greeting rather than a recording.
|
|
535
|
+
const OPENERS = [
|
|
536
|
+
(n) => `${n} here.`,
|
|
537
|
+
(n) => `This is ${n}.`,
|
|
538
|
+
(n) => `${n} speaking.`,
|
|
539
|
+
(n) => `${n} talking.`,
|
|
540
|
+
(n) => `It's ${n}.`,
|
|
541
|
+
(n) => `${n} again.`,
|
|
542
|
+
(n) => `Hello, this is ${n}.`,
|
|
543
|
+
(n) => `${n}, checking in.`,
|
|
544
|
+
];
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Put the speaker's name in front of a spoken line. A leading tone tag stays first, so the tone
|
|
548
|
+
* still covers the whole thing. Unchanged when there is no name to give.
|
|
549
|
+
*/
|
|
550
|
+
export function announceAs(text, who, pick = Math.random) {
|
|
551
|
+
const name = String(who ?? "").trim();
|
|
552
|
+
const line = String(text ?? "");
|
|
553
|
+
if (!name || !line.trim()) return line;
|
|
554
|
+
const spoken = name.charAt(0).toUpperCase() + name.slice(1);
|
|
555
|
+
const opener = OPENERS[Math.min(OPENERS.length - 1, Math.max(0, Math.floor(pick() * OPENERS.length)))](spoken);
|
|
556
|
+
const tag = /^\s*(\[[^\]]+\]\s*)/.exec(line);
|
|
557
|
+
return tag ? `${tag[1]}${opener} ${line.slice(tag[0].length)}` : `${opener} ${line}`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Wrap Gemini's headerless 16-bit mono PCM in a 44-byte RIFF header. */
|
|
561
|
+
export function wavFromPcm(pcm, sampleRate = 24000, channels = 1, bits = 16) {
|
|
562
|
+
const blockAlign = (channels * bits) / 8;
|
|
563
|
+
const header = Buffer.alloc(44);
|
|
564
|
+
header.write("RIFF", 0);
|
|
565
|
+
header.writeUInt32LE(36 + pcm.length, 4);
|
|
566
|
+
header.write("WAVE", 8);
|
|
567
|
+
header.write("fmt ", 12);
|
|
568
|
+
header.writeUInt32LE(16, 16);
|
|
569
|
+
header.writeUInt16LE(1, 20);
|
|
570
|
+
header.writeUInt16LE(channels, 22);
|
|
571
|
+
header.writeUInt32LE(sampleRate, 24);
|
|
572
|
+
header.writeUInt32LE(sampleRate * blockAlign, 28);
|
|
573
|
+
header.writeUInt16LE(blockAlign, 32);
|
|
574
|
+
header.writeUInt16LE(bits, 34);
|
|
575
|
+
header.write("data", 36);
|
|
576
|
+
header.writeUInt32LE(pcm.length, 40);
|
|
577
|
+
return Buffer.concat([header, pcm]);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** `audio/l16; rate=24000; channels=1` → { rate, channels }. */
|
|
581
|
+
export function parsePcmMime(mime = "") {
|
|
582
|
+
const rate = Number(/rate=(\d+)/i.exec(mime)?.[1]) || 24000;
|
|
583
|
+
const channels = Number(/channels=(\d+)/i.exec(mime)?.[1]) || 1;
|
|
584
|
+
return { rate, channels };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------- decisions
|
|
588
|
+
|
|
589
|
+
export const MAX_CHOICES = 10;
|
|
590
|
+
|
|
591
|
+
/** `"Ship it|Wait|Roll back"` → trimmed, de-duplicated labels. */
|
|
592
|
+
export function parseChoices(value, { min = 2 } = {}) {
|
|
593
|
+
if (typeof value !== "string") throw new Error('--choices is required, e.g. --choices "Yes|No"');
|
|
594
|
+
const choices = [...new Set(value.split("|").map((c) => c.trim()).filter(Boolean))];
|
|
595
|
+
if (choices.length < min) throw new Error(`--choices needs at least ${min === 1 ? "one option" : "two options separated by |"}`);
|
|
596
|
+
if (choices.length > MAX_CHOICES) throw new Error(`--choices allows at most ${MAX_CHOICES} options`);
|
|
597
|
+
return choices;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** `--recommend` as a choice label (case-insensitive) or 1-based number → index, or null when absent. */
|
|
601
|
+
export function parseRecommend(value, choices) {
|
|
602
|
+
if (value === undefined) return null;
|
|
603
|
+
const v = String(value).trim();
|
|
604
|
+
let index = choices.findIndex((c) => c.toLowerCase() === v.toLowerCase());
|
|
605
|
+
if (index === -1 && /^\d+$/.test(v) && Number(v) >= 1 && Number(v) <= choices.length) index = Number(v) - 1;
|
|
606
|
+
if (index === -1) throw new Error(`--recommend must be one of the choices (label or number 1-${choices.length})`);
|
|
607
|
+
return index;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** `90`, `90s`, `30m`, `4h`, `2d` → seconds. */
|
|
611
|
+
export function parseDuration(value, fallback) {
|
|
612
|
+
if (value === undefined || value === true) return fallback;
|
|
613
|
+
const m = /^(\d+(?:\.\d+)?)\s*([smhd]?)$/i.exec(String(value).trim());
|
|
614
|
+
if (!m) throw new Error(`invalid duration "${value}" (use e.g. 90s, 30m, 4h)`);
|
|
615
|
+
const unit = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()];
|
|
616
|
+
return Math.round(Number(m[1]) * unit);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function formatDuration(seconds) {
|
|
620
|
+
if (seconds % 3600 === 0) return `${seconds / 3600}h`;
|
|
621
|
+
if (seconds % 60 === 0) return `${seconds / 60}m`;
|
|
622
|
+
return `${seconds}s`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** `"10m,30m"` → [600, 1800] (sorted, de-duplicated); `"off"` → []. */
|
|
626
|
+
export function parseRemind(value) {
|
|
627
|
+
const v = String(value).trim().toLowerCase();
|
|
628
|
+
if (v === "off" || v === "none" || v === "false") return [];
|
|
629
|
+
const seconds = splitList(v).map((d) => parseDuration(d));
|
|
630
|
+
if (seconds.some((s) => s <= 0)) throw new Error("--remind durations must be positive");
|
|
631
|
+
return [...new Set(seconds)].sort((a, b) => a - b);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** `"22:00-08:00"` → { from, to }; `"off"` → null. */
|
|
635
|
+
export function parseQuietHours(value) {
|
|
636
|
+
const v = String(value).trim().toLowerCase();
|
|
637
|
+
if (v === "off" || v === "none" || v === "false") return null;
|
|
638
|
+
const m = /^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/.exec(v);
|
|
639
|
+
if (!m || Number(m[1]) > 23 || Number(m[3]) > 23 || Number(m[2]) > 59 || Number(m[4]) > 59) {
|
|
640
|
+
throw new Error('--quiet-hours must look like "22:00-08:00" (or "off")');
|
|
641
|
+
}
|
|
642
|
+
const pad = (h, mm) => `${h.padStart(2, "0")}:${mm}`;
|
|
643
|
+
return { from: pad(m[1], m[2]), to: pad(m[3], m[4]) };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Whether local time `date` falls inside quiet hours; ranges may wrap past midnight. */
|
|
647
|
+
export function inQuietHours(quiet, date = new Date()) {
|
|
648
|
+
if (!quiet) return false;
|
|
649
|
+
const minutes = (hhmm) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3));
|
|
650
|
+
const now = date.getHours() * 60 + date.getMinutes();
|
|
651
|
+
const [from, to] = [minutes(quiet.from), minutes(quiet.to)];
|
|
652
|
+
if (from === to) return false;
|
|
653
|
+
return from < to ? now >= from && now < to : now >= from || now < to;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Drop the methods quiet hours mute right now. Returns the methods left and what was muted. */
|
|
657
|
+
// A snooze mutes the two methods that make noise. Slack keeps working, so nothing is lost —
|
|
658
|
+
// you just don't hear about it until it's over.
|
|
659
|
+
const SNOOZE_MUTES = ["sound", "speech"];
|
|
660
|
+
|
|
661
|
+
/** When a snooze runs out, or null if there isn't one running. */
|
|
662
|
+
export function snoozeUntil(config, date = new Date()) {
|
|
663
|
+
const until = config?.snoozeUntil ? Date.parse(config.snoozeUntil) : 0;
|
|
664
|
+
return until > date.getTime() ? config.snoozeUntil : null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Methods left after the user's quiet hours and any snooze. `reason` says which of the two did it,
|
|
669
|
+
* because "it's the middle of the night" and "you asked for an hour's peace" are not the same
|
|
670
|
+
* message to read the next morning.
|
|
671
|
+
*/
|
|
672
|
+
export function applyQuietHours(methods, config, date = new Date()) {
|
|
673
|
+
const snoozed = Boolean(snoozeUntil(config, date));
|
|
674
|
+
const quiet = inQuietHours(config.quietHours, date);
|
|
675
|
+
if (!snoozed && !quiet) return { methods, muted: [], reason: null };
|
|
676
|
+
const mute = snoozed ? SNOOZE_MUTES : config.quietHours.mute ?? [];
|
|
677
|
+
return {
|
|
678
|
+
methods: methods.filter((m) => !mute.includes(m)),
|
|
679
|
+
muted: methods.filter((m) => mute.includes(m)),
|
|
680
|
+
reason: snoozed ? "snooze" : "quiet hours",
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export const OTHER = "other";
|
|
685
|
+
export const EDIT = "edit";
|
|
686
|
+
export const EDIT_CALLBACK = "roger-roger_edit";
|
|
687
|
+
// Slack's limit for a text input, and so for a draft the user can edit.
|
|
688
|
+
export const MAX_DRAFT = 3000;
|
|
689
|
+
export const DEFAULT_DRAFT_CHOICE = "Use as is";
|
|
690
|
+
export const CHECKS = "checks";
|
|
691
|
+
export const SUBMIT = "submit";
|
|
692
|
+
export const OTHER_CALLBACK = "roger-roger_other";
|
|
693
|
+
|
|
694
|
+
// ---------------------------------------------------------------- routing a message to an agent
|
|
695
|
+
|
|
696
|
+
export const ROUTE_PREFIX = "roger-roger_route";
|
|
697
|
+
export const NOBODY = "nobody";
|
|
698
|
+
|
|
699
|
+
export function routeActionId(messageTs, sessionId) {
|
|
700
|
+
return `${ROUTE_PREFIX}:${messageTs}:${sessionId}`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export function parseRouteActionId(value) {
|
|
704
|
+
// `attention_route:` is what the buttons said before the rename; messages already in Slack keep it.
|
|
705
|
+
const m = /^(?:roger-roger|attention)_route:([0-9.]+):(.+)$/.exec(String(value));
|
|
706
|
+
return m ? { ts: m[1], sessionId: m[2] } : null;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const quote = (text) => String(text ?? "").split(/\r?\n/).map((line) => `> ${line}`).join("\n");
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* "Which one is this for?" — posted in the thread of a message that arrived while several agents
|
|
713
|
+
* were running. One button per live session, in its own colour, plus a way to say none of them.
|
|
714
|
+
*/
|
|
715
|
+
export function pickerBlocks({ text, sessions, messageTs }) {
|
|
716
|
+
return [
|
|
717
|
+
{ type: "section", text: { type: "mrkdwn", text: `*Which one is this for?*\n${quote(text)}` } },
|
|
718
|
+
{
|
|
719
|
+
type: "actions",
|
|
720
|
+
elements: [
|
|
721
|
+
...sessions.slice(0, 9).map((s) => ({
|
|
722
|
+
type: "button",
|
|
723
|
+
text: { type: "plain_text", text: `${s.swatch} ${s.nickname}${s.project ? ` · ${s.project}` : ""}`.slice(0, 75), emoji: true },
|
|
724
|
+
action_id: routeActionId(messageTs, s.id),
|
|
725
|
+
})),
|
|
726
|
+
{
|
|
727
|
+
type: "button",
|
|
728
|
+
text: { type: "plain_text", text: "📥 Nobody — keep it", emoji: true },
|
|
729
|
+
action_id: routeActionId(messageTs, NOBODY),
|
|
730
|
+
},
|
|
731
|
+
],
|
|
732
|
+
},
|
|
733
|
+
];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Where a message went when one agent was listening and the rest were idle, with the others a tap
|
|
738
|
+
* away. Guessing is only fair if correcting the guess is easy.
|
|
739
|
+
*/
|
|
740
|
+
export function sentToBlocks({ text, session, others = [], messageTs }) {
|
|
741
|
+
const to = `*${badge(session.nickname)}*${session.project ? ` · ${session.project}` : ""}`;
|
|
742
|
+
const blocks = [{
|
|
743
|
+
type: "section",
|
|
744
|
+
text: { type: "mrkdwn", text: `${quote(text)}\n\n:incoming_envelope: Sent to ${to} — the only agent listening.` },
|
|
745
|
+
}];
|
|
746
|
+
if (others.length) {
|
|
747
|
+
blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: "Wrong one? Send it to:" }] });
|
|
748
|
+
blocks.push({
|
|
749
|
+
type: "actions",
|
|
750
|
+
elements: others.slice(0, 9).map((s) => ({
|
|
751
|
+
type: "button",
|
|
752
|
+
text: { type: "plain_text", text: `${s.swatch} ${s.nickname}${s.project ? ` · ${s.project}` : ""}`.slice(0, 75), emoji: true },
|
|
753
|
+
action_id: routeActionId(messageTs, s.id),
|
|
754
|
+
})),
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
return blocks;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** What the picker turns into once it has been answered. */
|
|
761
|
+
export function pickedBlocks({ text, session }) {
|
|
762
|
+
const to = session ? `*${badge(session.nickname)}*${session.project ? ` · ${session.project}` : ""}` : "nobody — it's waiting in the inbox";
|
|
763
|
+
return [{ type: "section", text: { type: "mrkdwn", text: `${quote(text)}\n\n:incoming_envelope: Sent to ${to}` } }];
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export function actionId(id, index) {
|
|
767
|
+
return `roger-roger:${id}:${index}`;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** `roger-roger:<id>:<index|other|edit|checks|submit>` → { id, index }, or null for an element this skill didn't make. */
|
|
771
|
+
export function parseActionId(value) {
|
|
772
|
+
// `attention:` is what the buttons said before the rename; questions already in Slack keep it.
|
|
773
|
+
const m = /^(?:roger-roger|attention):([a-z0-9]+):(\d+|other|edit|checks|submit)$/.exec(String(value));
|
|
774
|
+
if (!m) return null;
|
|
775
|
+
return { id: m[1], index: /^\d+$/.test(m[2]) ? Number(m[2]) : m[2] };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** `--recommend "A|C"` for a multi-select question → sorted indices (labels or 1-based numbers). */
|
|
779
|
+
export function parseRecommendMany(value, choices) {
|
|
780
|
+
if (value === undefined) return null;
|
|
781
|
+
const parts = String(value).split("|").map((p) => p.trim()).filter(Boolean);
|
|
782
|
+
if (!parts.length) throw new Error("--recommend needs at least one choice");
|
|
783
|
+
return [...new Set(parts.map((p) => parseRecommend(p, choices)))].sort((a, b) => a - b);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Ticked checkbox indices from a block_actions payload's `state`, for decision `id`. */
|
|
787
|
+
export function checkedIndices(state, id) {
|
|
788
|
+
// A question posted before the rename has its checkboxes under the old names.
|
|
789
|
+
const selected = state?.values?.[`roger-roger:${id}:pick`]?.[actionId(id, CHECKS)]?.selected_options
|
|
790
|
+
?? state?.values?.[`attention:${id}:pick`]?.[`attention:${id}:${CHECKS}`]?.selected_options
|
|
791
|
+
?? [];
|
|
792
|
+
return [...new Set(selected.map((o) => Number(o.value)).filter(Number.isInteger))].sort((a, b) => a - b);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Friendly names for the agent products that announce themselves in AI_AGENT.
|
|
796
|
+
export { detectAgent } from "./agent.mjs";
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* "machine · agent · model · session" for telling apart messages from several agents.
|
|
800
|
+
* The model and the session name come from the agent itself (it knows both; the environment
|
|
801
|
+
* doesn't), so anything not supplied is simply left out. "" when there is nothing to say.
|
|
802
|
+
*/
|
|
803
|
+
export function sessionLabel({ env = process.env, hostname = "", agent, model, session, nickname } = {}) {
|
|
804
|
+
const clean = (v) => (typeof v === "string" ? v.replace(/\s+/g, " ").trim().slice(0, 60) : "");
|
|
805
|
+
const who = clean(nickname) ? `*${badge(clean(nickname))}*` : "";
|
|
806
|
+
return [who, hostname, clean(agent) || detectAgent(env), clean(model), clean(session)].filter(Boolean).join(" · ");
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export function labelBlock(label) {
|
|
810
|
+
return label ? [{ type: "context", elements: [{ type: "mrkdwn", text: `:computer: ${label}` }] }] : [];
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function questionBlocks(headline, details, label, draft) {
|
|
814
|
+
const blocks = [{ type: "section", text: { type: "mrkdwn", text: fitSection(headline) } }];
|
|
815
|
+
if (details) blocks.push({ type: "section", text: { type: "mrkdwn", text: fitSection(stripTags(details)) } });
|
|
816
|
+
if (draft) blocks.push(draftBlock(draft));
|
|
817
|
+
return [...blocks, ...labelBlock(label)];
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** A draft shown verbatim in a code block, cut to fit Slack's 3000-character section limit. */
|
|
821
|
+
function draftBlock(text) {
|
|
822
|
+
const body = text.length > 2900 ? `${text.slice(0, 2900)}\n…` : text;
|
|
823
|
+
return { type: "section", text: { type: "mrkdwn", text: `\`\`\`${body.replace(/\`\`\`/g, "ʼʼʼ")}\`\`\`` } };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** Labels of the given indices, bolded and comma-separated, for Slack. */
|
|
827
|
+
function boldList(choices, indices) {
|
|
828
|
+
return indices.map((i) => `*${choices[i]}*`).join(", ");
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Slack renders `<!date^…>` in the reader's own timezone. */
|
|
832
|
+
function slackTime(iso) {
|
|
833
|
+
const unix = Math.floor(Date.parse(iso) / 1000);
|
|
834
|
+
return `<!date^${unix}^{time}|${new Date(iso).toISOString().slice(11, 16)} UTC>`;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* The open question. Single choice: one button per choice, the recommended one green.
|
|
839
|
+
* Multi-select: checkboxes (recommended ones pre-ticked) and a Submit button. Either way an
|
|
840
|
+
* optional "Other…" button for a typed answer, and a note on what happens if nobody answers.
|
|
841
|
+
*/
|
|
842
|
+
export function decisionBlocks({ id, headline, details, choices, recommended = null, multi = false, allowOther = false, expiresAt, onExpire, label, draft, baseBlocks }) {
|
|
843
|
+
// With a draft, "Edit" replaces "Other…": the typed answer starts from the agent's text.
|
|
844
|
+
const other = draft
|
|
845
|
+
? { type: "button", action_id: actionId(id, EDIT), value: EDIT, text: { type: "plain_text", text: "✏️ Edit", emoji: true } }
|
|
846
|
+
: { type: "button", action_id: actionId(id, OTHER), value: OTHER, text: { type: "plain_text", text: "Other…", emoji: true } };
|
|
847
|
+
// `baseBlocks`: an already-built message (e.g. a summary card) that the buttons are added to.
|
|
848
|
+
const blocks = baseBlocks ? [...baseBlocks] : questionBlocks(headline, details, null, draft);
|
|
849
|
+
if (baseBlocks) label = "";
|
|
850
|
+
if (draft) allowOther = true;
|
|
851
|
+
|
|
852
|
+
if (multi) {
|
|
853
|
+
const option = (c, i) => ({ text: { type: "plain_text", text: c.slice(0, 75), emoji: true }, value: String(i) });
|
|
854
|
+
const options = choices.map(option);
|
|
855
|
+
const initial = (recommended ?? []).map((i) => options[i]);
|
|
856
|
+
blocks.push({
|
|
857
|
+
type: "actions",
|
|
858
|
+
block_id: `roger-roger:${id}:pick`,
|
|
859
|
+
elements: [{ type: "checkboxes", action_id: actionId(id, CHECKS), options, ...(initial.length ? { initial_options: initial } : {}) }],
|
|
860
|
+
});
|
|
861
|
+
const submit = { type: "button", action_id: actionId(id, SUBMIT), value: SUBMIT, style: "primary", text: { type: "plain_text", text: "Submit", emoji: true } };
|
|
862
|
+
blocks.push({ type: "actions", block_id: `roger-roger:${id}`, elements: allowOther ? [submit, other] : [submit] });
|
|
863
|
+
} else {
|
|
864
|
+
const elements = choices.map((c, i) => ({
|
|
865
|
+
type: "button",
|
|
866
|
+
action_id: actionId(id, i),
|
|
867
|
+
value: String(i),
|
|
868
|
+
text: { type: "plain_text", text: c.slice(0, 75), emoji: true },
|
|
869
|
+
...(i === recommended ? { style: "primary" } : {}),
|
|
870
|
+
}));
|
|
871
|
+
if (allowOther) elements.push(other);
|
|
872
|
+
blocks.push({ type: "actions", block_id: `roger-roger:${id}`, elements });
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (expiresAt) {
|
|
876
|
+
const hasDefault = onExpire === "recommended" && recommended !== null && !(multi && recommended.length === 0);
|
|
877
|
+
const picked = multi ? boldList(choices, recommended ?? []) : `*${choices[recommended]}*`;
|
|
878
|
+
const fallback = hasDefault ? `I'll go with ${picked}` : "this closes unanswered";
|
|
879
|
+
blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: `If there's no answer by ${slackTime(expiresAt)}, ${fallback}.` }] });
|
|
880
|
+
}
|
|
881
|
+
return [...blocks, ...labelBlock(label)];
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** The pop-up behind "Other…": one text box; the decision id rides along in private_metadata. */
|
|
885
|
+
export function otherModal({ id, question }) {
|
|
886
|
+
return {
|
|
887
|
+
type: "modal",
|
|
888
|
+
callback_id: OTHER_CALLBACK,
|
|
889
|
+
private_metadata: id,
|
|
890
|
+
title: { type: "plain_text", text: "Your answer" },
|
|
891
|
+
submit: { type: "plain_text", text: "Send" },
|
|
892
|
+
close: { type: "plain_text", text: "Cancel" },
|
|
893
|
+
blocks: [
|
|
894
|
+
{ type: "section", text: { type: "mrkdwn", text: stripTags(question).slice(0, 2900) } },
|
|
895
|
+
{
|
|
896
|
+
type: "input",
|
|
897
|
+
block_id: "answer",
|
|
898
|
+
label: { type: "plain_text", text: "Answer" },
|
|
899
|
+
element: { type: "plain_text_input", action_id: "text", multiline: true },
|
|
900
|
+
},
|
|
901
|
+
],
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** The pop-up behind "✏️ Edit": the agent's draft, ready to change; the decision id rides along. */
|
|
906
|
+
export function editModal({ id, question, draft }) {
|
|
907
|
+
return {
|
|
908
|
+
type: "modal",
|
|
909
|
+
callback_id: EDIT_CALLBACK,
|
|
910
|
+
private_metadata: id,
|
|
911
|
+
title: { type: "plain_text", text: "Edit and send" },
|
|
912
|
+
submit: { type: "plain_text", text: "Send" },
|
|
913
|
+
close: { type: "plain_text", text: "Cancel" },
|
|
914
|
+
blocks: [
|
|
915
|
+
{ type: "section", text: { type: "mrkdwn", text: stripTags(question).slice(0, 2900) } },
|
|
916
|
+
{
|
|
917
|
+
type: "input",
|
|
918
|
+
block_id: "answer",
|
|
919
|
+
label: { type: "plain_text", text: "Draft" },
|
|
920
|
+
element: { type: "plain_text_input", action_id: "text", multiline: true, max_length: MAX_DRAFT, initial_value: draft.slice(0, MAX_DRAFT) },
|
|
921
|
+
},
|
|
922
|
+
],
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** A reminder about an open question, linking back to it. */
|
|
927
|
+
export function reminderText({ question, permalink, count }) {
|
|
928
|
+
const link = permalink ? `<${permalink}|${stripTags(question)}>` : `*${stripTags(question)}*`;
|
|
929
|
+
return `:alarm_clock: ${count > 1 ? "Still" : "Reminder:"} waiting for your answer: ${link}`;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** A progress message: the latest status plus when it was last updated (and by which session). */
|
|
933
|
+
export function progressBlocks({ headline, updatedAt, label, key, control, notes = [], done = false }) {
|
|
934
|
+
const context = [`Updated ${slackTime(updatedAt)}`, label ? `:computer: ${label}` : ""].filter(Boolean).join(" · ");
|
|
935
|
+
const blocks = [
|
|
936
|
+
{ type: "section", text: { type: "mrkdwn", text: headline } },
|
|
937
|
+
{ type: "context", elements: [{ type: "mrkdwn", text: context }] },
|
|
938
|
+
];
|
|
939
|
+
// The latest few notes, and whether the agent has picked each one up.
|
|
940
|
+
for (const n of notes.slice(-3)) {
|
|
941
|
+
const status = n.readAt ? "_read by the agent_" : "_not read yet_";
|
|
942
|
+
blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: `:memo: <@${n.by}>: “${n.text.slice(0, 200)}” · ${status}` }] });
|
|
943
|
+
}
|
|
944
|
+
if (done || !key) return blocks;
|
|
945
|
+
|
|
946
|
+
const state = control?.state ?? "running";
|
|
947
|
+
const by = control?.by ? ` by <@${control.by}>` : "";
|
|
948
|
+
// "Requested" until the agent's next check sees it; then the message says what the agent is doing.
|
|
949
|
+
if (state === "pause") {
|
|
950
|
+
const text = control.ack ? `:double_vertical_bar: Paused${by}. The agent is holding until you resume.` : `:double_vertical_bar: Pause requested${by}. The agent pauses at its next check.`;
|
|
951
|
+
blocks.push({ type: "context", elements: [{ type: "mrkdwn", text }] });
|
|
952
|
+
} else if (state === "stop") {
|
|
953
|
+
const text = control.ack ? `:black_square_for_stop: Stopped${by}. The agent is wrapping up.` : `:black_square_for_stop: Stop requested${by}. The agent stops at its next check.`;
|
|
954
|
+
blocks.push({ type: "context", elements: [{ type: "mrkdwn", text }] });
|
|
955
|
+
}
|
|
956
|
+
const button = (action, text, style) => ({
|
|
957
|
+
type: "button",
|
|
958
|
+
action_id: progressActionId(key, action),
|
|
959
|
+
value: action,
|
|
960
|
+
text: { type: "plain_text", text, emoji: true },
|
|
961
|
+
...(style ? { style } : {}),
|
|
962
|
+
});
|
|
963
|
+
const note = button("note", "📝 Note");
|
|
964
|
+
const buttons = state === "running"
|
|
965
|
+
? [button("pause", "⏸ Pause"), button("stop", "⏹ Stop", "danger"), note]
|
|
966
|
+
: state === "pause"
|
|
967
|
+
? [button("resume", "▶ Resume", "primary"), button("stop", "⏹ Stop", "danger"), note]
|
|
968
|
+
: [button("resume", "↩ Undo stop"), note];
|
|
969
|
+
blocks.push({ type: "actions", block_id: `roger-roger-progress:${key}`, elements: buttons });
|
|
970
|
+
return blocks;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export const NOTE_CALLBACK = "roger-roger_progress_note";
|
|
974
|
+
|
|
975
|
+
/** The pop-up behind "📝 Note": a message for the running agent; the progress key rides along. */
|
|
976
|
+
export function noteModal({ key, headline }) {
|
|
977
|
+
return {
|
|
978
|
+
type: "modal",
|
|
979
|
+
callback_id: NOTE_CALLBACK,
|
|
980
|
+
private_metadata: key,
|
|
981
|
+
title: { type: "plain_text", text: "Note to the agent" },
|
|
982
|
+
submit: { type: "plain_text", text: "Send" },
|
|
983
|
+
close: { type: "plain_text", text: "Cancel" },
|
|
984
|
+
blocks: [
|
|
985
|
+
{ type: "context", elements: [{ type: "mrkdwn", text: stripTags(headline ?? "").slice(0, 2900) || " " }] },
|
|
986
|
+
{
|
|
987
|
+
type: "input",
|
|
988
|
+
block_id: "note",
|
|
989
|
+
label: { type: "plain_text", text: "It gets this at its next check" },
|
|
990
|
+
element: { type: "plain_text_input", action_id: "text", multiline: true, max_length: 2000 },
|
|
991
|
+
},
|
|
992
|
+
],
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
export function progressActionId(key, action) {
|
|
997
|
+
return `roger-roger-progress:${key}:${action}`;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** `roger-roger-progress:<key>:<pause|resume|stop|note>` → { key, action }, or null. */
|
|
1001
|
+
export function parseProgressActionId(value) {
|
|
1002
|
+
const m = /^(?:roger-roger|attention)-progress:([a-z0-9._-]+):(pause|resume|stop|note)$/.exec(String(value));
|
|
1003
|
+
return m ? { key: m[1], action: m[2] } : null;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** 45s, 12m, 1h 12m, 2d 3h. */
|
|
1007
|
+
export function formatElapsed(ms) {
|
|
1008
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
1009
|
+
if (s < 60) return `${s}s`;
|
|
1010
|
+
const m = Math.floor(s / 60);
|
|
1011
|
+
if (m < 60) return `${m}m`;
|
|
1012
|
+
const h = Math.floor(m / 60);
|
|
1013
|
+
if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
|
|
1014
|
+
const d = Math.floor(h / 24);
|
|
1015
|
+
return h % 24 ? `${d}d ${h % 24}h` : `${d}d`;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** `--field "Tests=31 passed"` (repeatable) → [{ label, value }]. */
|
|
1019
|
+
export function parseFields(values) {
|
|
1020
|
+
return listArg(values).map((f) => {
|
|
1021
|
+
const eq = f.indexOf("=");
|
|
1022
|
+
if (eq <= 0) throw new Error(`--field must look like "Label=value", got "${f}"`);
|
|
1023
|
+
return { label: f.slice(0, eq).trim(), value: f.slice(eq + 1).trim() };
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** One line per settled question: what was asked and what came of it. */
|
|
1028
|
+
export function decisionSummaryLine(d) {
|
|
1029
|
+
const q = stripTags(d.question);
|
|
1030
|
+
const question = q.length > 90 ? `${q.slice(0, 89)}…` : q;
|
|
1031
|
+
const a = d.answer ?? {};
|
|
1032
|
+
let outcome;
|
|
1033
|
+
if (d.status === "answered") {
|
|
1034
|
+
if (Array.isArray(a.indices)) outcome = a.indices.length ? a.choices.map((c) => `*${c}*`).join(", ") : "_none of the options_";
|
|
1035
|
+
else if (a.edited) outcome = "_edited the draft_";
|
|
1036
|
+
else if (a.choice === null) outcome = `“${String(a.text).slice(0, 80)}”`;
|
|
1037
|
+
else outcome = `*${a.choice}*`;
|
|
1038
|
+
if (a.auto) outcome += " _(no answer, went with the recommendation)_";
|
|
1039
|
+
} else {
|
|
1040
|
+
outcome = d.status === "expired" ? "_no answer_" : "_closed_";
|
|
1041
|
+
}
|
|
1042
|
+
return `• ${question} → ${outcome}`;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* The end-of-task card: the message, key facts side by side, how long it took, and the
|
|
1047
|
+
* decisions the user made along the way.
|
|
1048
|
+
*/
|
|
1049
|
+
export function summaryBlocks({ headline, fields = [], elapsed, decisions = [], label }) {
|
|
1050
|
+
const blocks = [{ type: "section", text: { type: "mrkdwn", text: headline } }];
|
|
1051
|
+
// Slack allows 10 fields per section; more go into a second one.
|
|
1052
|
+
for (let i = 0; i < fields.length; i += 10) {
|
|
1053
|
+
blocks.push({
|
|
1054
|
+
type: "section",
|
|
1055
|
+
fields: fields.slice(i, i + 10).map((f) => ({ type: "mrkdwn", text: `*${f.label}*\n${f.value}`.slice(0, 2000) })),
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
if (decisions.length) {
|
|
1059
|
+
const lines = decisions.slice(-10).map(decisionSummaryLine);
|
|
1060
|
+
const more = decisions.length > 10 ? `\n_…and ${decisions.length - 10} earlier_` : "";
|
|
1061
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: `*Your decisions along the way*\n${lines.join("\n")}${more}`.slice(0, 3000) } });
|
|
1062
|
+
}
|
|
1063
|
+
const context = [elapsed ? `:stopwatch: Took ${elapsed}` : "", label ? `:computer: ${label}` : ""].filter(Boolean).join(" · ");
|
|
1064
|
+
if (context) blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: context }] });
|
|
1065
|
+
return blocks;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** A plain notification: the message, plus the session label when there is one. */
|
|
1069
|
+
export function messageBlocks({ headline, label }) {
|
|
1070
|
+
return [{ type: "section", text: { type: "mrkdwn", text: fitSection(headline) } }, ...labelBlock(label)];
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Whether the agent has actually been given the answer. An agent that stopped waiting gets it at its
|
|
1075
|
+
* next check-in, which can be a while, so the message says which of the two is true rather than
|
|
1076
|
+
* leaving the user to assume it landed.
|
|
1077
|
+
*/
|
|
1078
|
+
export function deliveryNote(decision) {
|
|
1079
|
+
if (decision.status !== "answered" || !decision.sessionId) return "";
|
|
1080
|
+
const who = decision.nickname ? `${decision.swatch ? `${decision.swatch} ` : ""}*${decision.nickname}*` : "the agent";
|
|
1081
|
+
if (decision.deliveredAt) return `:inbox_tray: Delivered to ${who}.`;
|
|
1082
|
+
return `:hourglass_flowing_sand: Saved for ${who} — it isn't waiting right now, so it gets this at its next check-in.`;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** The same message once settled: the question stays, the choices give way to the outcome. */
|
|
1086
|
+
export function resolvedBlocks(decision, outcome) {
|
|
1087
|
+
const { headline, details, label, draft, answer, baseBlocks } = decision;
|
|
1088
|
+
const delivery = deliveryNote(decision);
|
|
1089
|
+
const notes = [
|
|
1090
|
+
{ type: "context", elements: [{ type: "mrkdwn", text: outcome }] },
|
|
1091
|
+
...(delivery ? [{ type: "context", elements: [{ type: "mrkdwn", text: delivery }] }] : []),
|
|
1092
|
+
];
|
|
1093
|
+
if (baseBlocks) return [...baseBlocks, ...notes];
|
|
1094
|
+
// A draft stays visible once settled: the user's edited version if they changed it.
|
|
1095
|
+
const finalDraft = draft ? (answer?.edited ? answer.text : draft) : "";
|
|
1096
|
+
return [
|
|
1097
|
+
{ type: "section", text: { type: "mrkdwn", text: fitSection(headline) } },
|
|
1098
|
+
...(details ? [{ type: "section", text: { type: "mrkdwn", text: fitSection(stripTags(details)) } }] : []),
|
|
1099
|
+
...(finalDraft ? [draftBlock(finalDraft)] : []),
|
|
1100
|
+
...notes,
|
|
1101
|
+
...labelBlock(label),
|
|
1102
|
+
];
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
export function outcomeText(decision) {
|
|
1106
|
+
const a = decision.answer ?? {};
|
|
1107
|
+
const by = a.by ? ` by <@${a.by}>` : "";
|
|
1108
|
+
const choices = decision.choices ?? [];
|
|
1109
|
+
switch (decision.status) {
|
|
1110
|
+
case "answered":
|
|
1111
|
+
if (Array.isArray(a.indices)) {
|
|
1112
|
+
const picked = a.indices.length ? boldList(choices, a.indices) : "*none of the options*";
|
|
1113
|
+
return a.auto
|
|
1114
|
+
? `:hourglass: No answer in time, so I went with ${picked} (recommended).`
|
|
1115
|
+
: `:white_check_mark: ${picked} (chosen${by})`;
|
|
1116
|
+
}
|
|
1117
|
+
if (a.auto) return `:hourglass: No answer in time, so I went with *${a.choice}* (recommended).`;
|
|
1118
|
+
if (a.edited) return `:pencil2: Edited${by} and sent (the version above).`;
|
|
1119
|
+
if (a.choice === null) {
|
|
1120
|
+
const also = a.files?.length ? ` (with ${a.files.length === 1 ? "a file" : `${a.files.length} files`})` : "";
|
|
1121
|
+
return a.text
|
|
1122
|
+
? `:speech_balloon: Answered${by}: “${a.text}”${also}`
|
|
1123
|
+
: `:paperclip: Answered${by} with ${a.files?.length === 1 ? "a file" : "files"}.`;
|
|
1124
|
+
}
|
|
1125
|
+
return `:white_check_mark: *${a.choice}* (chosen${by})`;
|
|
1126
|
+
case "expired":
|
|
1127
|
+
return ":hourglass: Expired without an answer.";
|
|
1128
|
+
case "cancelled":
|
|
1129
|
+
return `:no_entry_sign: Closed: ${decision.reason || "no longer needed"}.`;
|
|
1130
|
+
default:
|
|
1131
|
+
return "";
|
|
1132
|
+
}
|
|
1133
|
+
}
|