roger-roger 0.1.3 → 0.1.8
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/README.md +2 -1
- package/package.json +1 -1
- package/skills/roger-roger/SKILL.md +7 -4
- package/skills/roger-roger/herdr-plugin.toml +1 -1
- package/skills/roger-roger/scripts/activity.mjs +92 -0
- package/skills/roger-roger/scripts/audio.mjs +123 -19
- package/skills/roger-roger/scripts/daemon.mjs +5 -3
- package/skills/roger-roger/scripts/decisions.mjs +9 -0
- package/skills/roger-roger/scripts/handlers.mjs +29 -10
- package/skills/roger-roger/scripts/help.mjs +3 -2
- package/skills/roger-roger/scripts/herdr.mjs +47 -2
- package/skills/roger-roger/scripts/inbox.mjs +16 -0
- package/skills/roger-roger/scripts/install.mjs +130 -2
- package/skills/roger-roger/scripts/lib.mjs +57 -2
- package/skills/roger-roger/scripts/roger-roger.mjs +1 -0
- package/skills/roger-roger/scripts/tray.mjs +5 -0
- package/skills/roger-roger/tray/main.mjs +5 -1
- package/skills/roger-roger/tray/panel.html +167 -2
|
@@ -110,17 +110,62 @@ function errorText(text) {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* What the pane's agent is working on, from its terminal title: Claude Code, Codex and others set it
|
|
115
|
+
* to the conversation's topic ("Home Assistant MCP setup"). Empty when the title is only the shell
|
|
116
|
+
* or the program's own name, which says nothing about the work.
|
|
117
|
+
*/
|
|
118
|
+
export function workFromTitle(title, agent = "") {
|
|
119
|
+
const t = str(title).replace(/^[^\p{L}\p{N}]+/u, "").trim();
|
|
120
|
+
if (!t) return "";
|
|
121
|
+
if (/[\\/]|\.exe$|^(powershell|pwsh|cmd|bash|zsh|fish|sh|node)$/i.test(t)) return "";
|
|
122
|
+
if (agent && t.toLowerCase() === String(agent).toLowerCase()) return "";
|
|
123
|
+
if (/^(claude( code)?|codex|opencode|gemini|cursor|copilot)$/i.test(t)) return "";
|
|
124
|
+
return t;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Herdr is asked for the title at most this often per session; a label follows the topic, not keystrokes.
|
|
128
|
+
const TITLE_EVERY_MS = 60_000;
|
|
129
|
+
const titleAsked = new Map();
|
|
130
|
+
|
|
131
|
+
/** The work to label with: the `--session` name if the agent gave one, else the pane's title. */
|
|
132
|
+
async function workFor(session, run, now = Date.now()) {
|
|
133
|
+
if (str(session.name)) return str(session.name);
|
|
134
|
+
const last = titleAsked.get(session.id);
|
|
135
|
+
if (last && now - last.at < TITLE_EVERY_MS) return last.work;
|
|
136
|
+
let work = "";
|
|
137
|
+
try {
|
|
138
|
+
const out = await run(["agent", "get", session.herdr.pane], { bin: session.herdr.bin, socket: session.herdr.socket });
|
|
139
|
+
const agent = JSON.parse(out)?.result?.agent ?? {};
|
|
140
|
+
work = workFromTitle(agent.terminal_title_stripped ?? agent.terminal_title, agent.agent ?? session.agent);
|
|
141
|
+
} catch {
|
|
142
|
+
// No title is no label, not an error: the tab still gets the badge.
|
|
143
|
+
}
|
|
144
|
+
titleAsked.set(session.id, { at: now, work });
|
|
145
|
+
return work;
|
|
146
|
+
}
|
|
147
|
+
|
|
113
148
|
const inFlight = new Set();
|
|
114
149
|
|
|
115
150
|
/**
|
|
116
151
|
* Put the session's labels on its Herdr pane, if it has one and they are not there yet. Never
|
|
117
152
|
* throws: a pane that can't be labelled is a line in the log, not a failed command. `touch` records
|
|
118
153
|
* what was put on, so it isn't done again; `run` is the herdr invocation (replaced in tests).
|
|
154
|
+
* Without a `--session` name, the work is taken from the pane's terminal title.
|
|
119
155
|
*/
|
|
120
156
|
export async function labelPane(session, { log = () => {}, touch = () => {}, run = runHerdr } = {}) {
|
|
121
|
-
|
|
122
|
-
if (!commands.length || inFlight.has(session.id)) return { labelled: false };
|
|
157
|
+
if (!session?.herdr?.tab || !session?.herdr?.pane || inFlight.has(session.id)) return { labelled: false };
|
|
123
158
|
inFlight.add(session.id);
|
|
159
|
+
let commands;
|
|
160
|
+
try {
|
|
161
|
+
commands = labelCommands({ ...session, name: await workFor(session, run) });
|
|
162
|
+
} catch {
|
|
163
|
+
commands = [];
|
|
164
|
+
}
|
|
165
|
+
if (!commands.length) {
|
|
166
|
+
inFlight.delete(session.id);
|
|
167
|
+
return { labelled: false };
|
|
168
|
+
}
|
|
124
169
|
const done = { ...(session.herdrLabel ?? {}) };
|
|
125
170
|
try {
|
|
126
171
|
for (const args of commands) {
|
|
@@ -18,6 +18,7 @@ import { routeMessage } from "./router.mjs";
|
|
|
18
18
|
import * as progressStore from "./progress.mjs";
|
|
19
19
|
import * as sessions from "./sessions.mjs";
|
|
20
20
|
import * as decisions from "./decisions.mjs";
|
|
21
|
+
import * as activity from "./activity.mjs";
|
|
21
22
|
|
|
22
23
|
const KEEP = 200;
|
|
23
24
|
|
|
@@ -166,6 +167,20 @@ export async function ingest(message, { log = () => {}, live = false, waiting =
|
|
|
166
167
|
const route = entry.route;
|
|
167
168
|
const files = entry.files ?? [];
|
|
168
169
|
log(`message ${message.ts} → ${route.kind}${route.key ? ` ${route.key}` : route.id ? ` ${route.id}` : ""}`);
|
|
170
|
+
// An answer in a question's thread is logged when the question settles, as the answer it is.
|
|
171
|
+
if (route.kind !== "decision") {
|
|
172
|
+
const to = route.kind === "session" ? sessions.get(route.id) : route.kind === "progress" && route.session ? sessions.get(route.session) : null;
|
|
173
|
+
const where = { progress: "a note on the progress message", ask: "asked which agent", inbox: "saved for the next agent" }[route.kind] ?? "";
|
|
174
|
+
activity.record({
|
|
175
|
+
from: "user",
|
|
176
|
+
kind: "message",
|
|
177
|
+
agent: to ? activity.agentOf(sessions.decorate(to)) : null,
|
|
178
|
+
text: text || (files.length ? files.map((f) => f.name).join(", ") : ""),
|
|
179
|
+
detail: where,
|
|
180
|
+
at: tsIso(message.ts),
|
|
181
|
+
ref: message.ts,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
169
184
|
|
|
170
185
|
await addReaction(channel, message.ts, reactionFor(route)).catch((e) => log(`reaction failed: ${e.message}`));
|
|
171
186
|
|
|
@@ -268,6 +283,7 @@ export async function resolvePick(messageTs, sessionId, log = () => {}) {
|
|
|
268
283
|
const chosen = sessionId === NOBODY ? null : sessions.get(sessionId);
|
|
269
284
|
const route = chosen ? { kind: "session", id: chosen.id, via: "picked" } : { kind: "inbox" };
|
|
270
285
|
writeState({ ...state, messages: state.messages.map((m) => (m.ts === messageTs ? { ...m, route } : m)) });
|
|
286
|
+
activity.retarget(messageTs, chosen ? activity.agentOf(sessions.decorate(chosen)) : null, chosen ? "" : "saved for the next agent");
|
|
271
287
|
|
|
272
288
|
if (chosen) {
|
|
273
289
|
deliver(chosen.id, { text: entry.text, by: entry.user, at: tsIso(entry.ts), ts: entry.ts, channel: entry.channel, via: "picked" });
|
|
@@ -95,6 +95,81 @@ export function applyLink(link, target, plan, fsx = fs, now = new Date()) {
|
|
|
95
95
|
return { ...plan, done: true };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// ---------------------------------------------------------------- the agents' standing instructions
|
|
99
|
+
|
|
100
|
+
/** Each agent's global instructions file: what it reads at the start of every session, everywhere. */
|
|
101
|
+
export const INSTRUCTION_FILES = [
|
|
102
|
+
{ id: "claude", label: "Claude Code", dir: [".claude"], file: "CLAUDE.md" },
|
|
103
|
+
{ id: "codex", label: "Codex", dir: [".codex"], file: "AGENTS.md" },
|
|
104
|
+
{ id: "opencode", label: "OpenCode", dir: [".config", "opencode"], file: "AGENTS.md" },
|
|
105
|
+
{ id: "gemini", label: "Gemini CLI", dir: [".gemini"], file: "GEMINI.md" },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
const MARK_START = "<!-- roger-roger:start -->";
|
|
109
|
+
const MARK_END = "<!-- roger-roger:end -->";
|
|
110
|
+
|
|
111
|
+
/** The standing instruction, between marks so it can be replaced or removed without touching anything else. */
|
|
112
|
+
export const INSTRUCTIONS = [
|
|
113
|
+
MARK_START,
|
|
114
|
+
"- Always use the `roger-roger` skill (the `roger-roger` command, or `node <skill-dir>/scripts/roger-roger.mjs`): at the end of every turn, whatever its size, send a notification as the last action before the final reply, and use `ask` when blocked on a decision. Don't skip it because the task seemed quick or the user seems to be at the keyboard. On every call pass `--session` with what the work is, in three to six words (`--session \"billing refactor tests\"`), and `--agent-model`: the work names your Slack messages and your Herdr pane. Never put code, commands, paths or URLs in the spoken line (`--say`): say what the thing is and why, and leave the command itself to the written message. If the skill isn't available, say so once.",
|
|
115
|
+
MARK_END,
|
|
116
|
+
].join("\n");
|
|
117
|
+
|
|
118
|
+
/** Where each agent's file is, whether that agent is here, and whether the block is already in it. */
|
|
119
|
+
export function instructionFiles(home = os.homedir(), fsx = fs) {
|
|
120
|
+
return INSTRUCTION_FILES.map((a) => {
|
|
121
|
+
const root = path.join(home, ...a.dir);
|
|
122
|
+
const file = path.join(root, a.file);
|
|
123
|
+
let text = "";
|
|
124
|
+
try {
|
|
125
|
+
text = fsx.readFileSync(file, "utf8");
|
|
126
|
+
} catch {}
|
|
127
|
+
return { ...a, root, path: file, present: fsx.existsSync(root), has: text.includes(MARK_START) };
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const BLOCK = new RegExp(`\\n?${MARK_START}[\\s\\S]*?${MARK_END}\\n?`);
|
|
132
|
+
|
|
133
|
+
/** The text with the block in it, replacing an older one or added at the end. */
|
|
134
|
+
export function withInstructions(text) {
|
|
135
|
+
const body = String(text ?? "");
|
|
136
|
+
if (body.includes(MARK_START) && body.includes(MARK_END)) return body.replace(BLOCK, `\n${INSTRUCTIONS}\n`).replace(/^\n/, "");
|
|
137
|
+
if (!body.trim()) return `${INSTRUCTIONS}\n`;
|
|
138
|
+
return `${body.replace(/\s*$/, "")}\n\n${INSTRUCTIONS}\n`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The text without the block; anything else in it is left exactly as it was. */
|
|
142
|
+
export function withoutInstructions(text) {
|
|
143
|
+
const body = String(text ?? "");
|
|
144
|
+
if (!body.includes(MARK_START)) return body;
|
|
145
|
+
return body.replace(BLOCK, "\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "").replace(/\n+$/, "\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function addInstructions(file, fsx = fs) {
|
|
149
|
+
let text = "";
|
|
150
|
+
try {
|
|
151
|
+
text = fsx.readFileSync(file, "utf8");
|
|
152
|
+
} catch {}
|
|
153
|
+
const had = text.includes(MARK_START);
|
|
154
|
+
fsx.mkdirSync(path.dirname(file), { recursive: true });
|
|
155
|
+
fsx.writeFileSync(file, withInstructions(text), "utf8");
|
|
156
|
+
return { file, updated: had, added: !had };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function removeInstructions(file, fsx = fs) {
|
|
160
|
+
let text;
|
|
161
|
+
try {
|
|
162
|
+
text = fsx.readFileSync(file, "utf8");
|
|
163
|
+
} catch {
|
|
164
|
+
return { file, removed: false };
|
|
165
|
+
}
|
|
166
|
+
if (!text.includes(MARK_START)) return { file, removed: false };
|
|
167
|
+
const rest = withoutInstructions(text);
|
|
168
|
+
if (rest.trim()) fsx.writeFileSync(file, rest, "utf8");
|
|
169
|
+
else fsx.rmSync(file, { force: true }); // ours was all there was
|
|
170
|
+
return { file, removed: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
98
173
|
// ---------------------------------------------------------------- the global copy
|
|
99
174
|
|
|
100
175
|
/**
|
|
@@ -478,6 +553,38 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
|
|
|
478
553
|
}
|
|
479
554
|
}
|
|
480
555
|
|
|
556
|
+
// ---- the standing instruction, so agents use it without being told each time
|
|
557
|
+
const instructionTargets = instructionFiles(home).filter((f) => f.present);
|
|
558
|
+
if (instructionTargets.length) {
|
|
559
|
+
const already = instructionTargets.filter((f) => f.has);
|
|
560
|
+
const yes = await ui.confirm({
|
|
561
|
+
message: already.length === instructionTargets.length ? "Keep telling your agents to always use roger-roger?" : "Tell your agents to always use roger-roger?",
|
|
562
|
+
yes: "Yes (recommended)",
|
|
563
|
+
hint: `a marked block in ${instructionTargets.map((f) => `~/${[...f.dir, f.file].join("/")}`).join(", ")}`,
|
|
564
|
+
initial: true,
|
|
565
|
+
});
|
|
566
|
+
if (yes) {
|
|
567
|
+
for (const f of instructionTargets) {
|
|
568
|
+
try {
|
|
569
|
+
const r = addInstructions(f.path);
|
|
570
|
+
ui.done(`${f.label}: ${r.added ? "instructions added" : "instructions refreshed"}`, f.path);
|
|
571
|
+
summary.push(`instructions: ${f.label}`);
|
|
572
|
+
} catch (e) {
|
|
573
|
+
ui.failed(`${f.label}: could not write ${f.path}`, e.message);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
} else if (already.length) {
|
|
577
|
+
for (const f of already) {
|
|
578
|
+
try {
|
|
579
|
+
removeInstructions(f.path);
|
|
580
|
+
ui.done(`${f.label}: instructions removed`, f.path);
|
|
581
|
+
} catch (e) {
|
|
582
|
+
ui.failed(`${f.label}: could not change ${f.path}`, e.message);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
481
588
|
// ---- herdr
|
|
482
589
|
let herdrDone = false;
|
|
483
590
|
if (herdr) {
|
|
@@ -748,6 +855,7 @@ export async function uninstallFindings({ home = os.homedir(), env = process.env
|
|
|
748
855
|
const global = globalCopy(env);
|
|
749
856
|
return {
|
|
750
857
|
links: skillFootprint(home),
|
|
858
|
+
instructions: instructionFiles(home).filter((f) => f.has),
|
|
751
859
|
herdr: herdr && herdrHasPlugin(herdr) ? herdr : null,
|
|
752
860
|
hookAgents,
|
|
753
861
|
home: [rogerRogerHome(env), path.join(home, ".attention")].filter((d) => fs.existsSync(d)),
|
|
@@ -759,7 +867,15 @@ export async function uninstallFindings({ home = os.homedir(), env = process.env
|
|
|
759
867
|
/** Everything but the Slack CLI, with no questions. Returns what was done. */
|
|
760
868
|
export async function quietUninstall({ home = os.homedir(), env = process.env, hooks = null, script = "", slackCli = false } = {}) {
|
|
761
869
|
const found = await uninstallFindings({ home, env, hooks, script });
|
|
762
|
-
const done = { daemonStopped: await stopDaemon(env), hooks: [], links: [], herdr: null, home: [], slackCli: false, global: null };
|
|
870
|
+
const done = { daemonStopped: await stopDaemon(env), hooks: [], links: [], instructions: [], herdr: null, home: [], slackCli: false, global: null };
|
|
871
|
+
for (const f of found.instructions) {
|
|
872
|
+
try {
|
|
873
|
+
removeInstructions(f.path);
|
|
874
|
+
done.instructions.push(f.path);
|
|
875
|
+
} catch (e) {
|
|
876
|
+
done.instructions.push({ file: f.path, error: e.message });
|
|
877
|
+
}
|
|
878
|
+
}
|
|
763
879
|
for (const name of found.hookAgents) {
|
|
764
880
|
try {
|
|
765
881
|
hooks.agents[name].uninstall();
|
|
@@ -813,6 +929,7 @@ export async function runUninstall(args, { script, hooks, input = process.stdin,
|
|
|
813
929
|
const labels = { claude: "Claude Code", opencode: "OpenCode", codex: "Codex" };
|
|
814
930
|
const choices = [];
|
|
815
931
|
if (found.links.length) choices.push({ value: "links", label: "The skill, for agents", hint: found.links.map((a) => a.label).join(", "), checked: true });
|
|
932
|
+
if (found.instructions.length) choices.push({ value: "instructions", label: "The standing instruction", hint: found.instructions.map((f) => `~/${[...f.dir, f.file].join("/")}`).join(", "), checked: true });
|
|
816
933
|
if (found.hookAgents.length) choices.push({ value: "hooks", label: "Terminal-question hooks", hint: found.hookAgents.map((n) => labels[n] ?? n).join(", "), checked: true });
|
|
817
934
|
if (found.herdr) choices.push({ value: "herdr", label: "The Herdr plugin", hint: "herdr plugin unlink", checked: true });
|
|
818
935
|
if (found.home.length) choices.push({ value: "home", label: "Settings, Slack tokens, state", hint: found.home.join(", "), checked: true });
|
|
@@ -846,6 +963,17 @@ export async function runUninstall(args, { script, hooks, input = process.stdin,
|
|
|
846
963
|
}
|
|
847
964
|
}
|
|
848
965
|
}
|
|
966
|
+
if (what.includes("instructions")) {
|
|
967
|
+
for (const f of found.instructions) {
|
|
968
|
+
try {
|
|
969
|
+
removeInstructions(f.path);
|
|
970
|
+
ui.done(`${f.label}: instruction removed`, f.path);
|
|
971
|
+
removed.push(`instructions: ${f.label}`);
|
|
972
|
+
} catch (e) {
|
|
973
|
+
ui.failed(`${f.label}: could not change ${f.path}`, e.message);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
849
977
|
if (what.includes("herdr")) {
|
|
850
978
|
const r = herdrUnlink(found.herdr);
|
|
851
979
|
if (r.ok) {
|
|
@@ -889,7 +1017,7 @@ export async function runUninstall(args, { script, hooks, input = process.stdin,
|
|
|
889
1017
|
"",
|
|
890
1018
|
...removed.map((s) => `${c.green("•")} ${s}`),
|
|
891
1019
|
"",
|
|
892
|
-
c.dim("
|
|
1020
|
+
c.dim("Only the marked roger-roger block was taken out of your CLAUDE.md or AGENTS.md; anything you wrote yourself is still there."),
|
|
893
1021
|
`Back any time: ${c.cyan("npx roger-roger@latest install")}`,
|
|
894
1022
|
]);
|
|
895
1023
|
return 0;
|
|
@@ -535,8 +535,63 @@ export function slackText({ kind = "info", message, project, mention }) {
|
|
|
535
535
|
|
|
536
536
|
/** What gets spoken: the agent's `--say` line, unless the user wants the message read verbatim. */
|
|
537
537
|
export function speechText({ mode = "auto", message, say }) {
|
|
538
|
-
|
|
539
|
-
|
|
538
|
+
return speakable(mode === "same" ? message : say || message);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// A word that only makes sense on a screen: shell punctuation, a flag, a path, a quote, a template.
|
|
542
|
+
const CODEISH = /[{}$|;<>=\\`"']|^--?[a-z]|[\w.~-]\/[\w.-]|^https?:|^[\w-]+\.[a-z]{2,4}[,.]?$|^\w+\(|\)$/i;
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Does this stretch read as code rather than as a sentence? `quoted` is for text already marked as
|
|
546
|
+
* code (in backticks): there one flag or path is enough. Loose text needs two, and a fair share.
|
|
547
|
+
*/
|
|
548
|
+
export function looksLikeCode(text, { quoted = false } = {}) {
|
|
549
|
+
const words = String(text).trim().split(/\s+/).filter(Boolean);
|
|
550
|
+
if (!words.length) return false;
|
|
551
|
+
const codeish = words.filter((w) => CODEISH.test(w)).length;
|
|
552
|
+
if (words.length === 1) return words[0].length > 24 || /[{}$|;<>=\\/]/.test(words[0]);
|
|
553
|
+
if (quoted) return codeish >= 1;
|
|
554
|
+
return codeish >= 2 && codeish / words.length >= 0.3;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** What a piece of code is, in words: "an ssh command", "a file", or just "a command". */
|
|
558
|
+
function nameCode(code) {
|
|
559
|
+
const s = String(code).trim();
|
|
560
|
+
if (!/\s/.test(s) && /[\/\\]/.test(s) && !/^https?:/i.test(s)) return "a file";
|
|
561
|
+
const first = s.split(/\s+/)[0].replace(/^[^\w]+/, "");
|
|
562
|
+
// "an ssh", "an npm", "a git": a word with no vowel is spelt out, and f, h, l, m, n, r, s, x start with a vowel sound.
|
|
563
|
+
const an = /^[aeiou]/i.test(first) || (!/[aeiouy]/i.test(first) && /^[fhlmnrsx]/i.test(first));
|
|
564
|
+
if (/^[a-z][\w-]{1,15}$/i.test(first) && !/^(the|a|an|and|or|to|then)$/i.test(first)) return `${an ? "an" : "a"} ${first} command`;
|
|
565
|
+
return "a command";
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The line as it should be heard. Nobody wants a voice reading out `ssh -o BatchMode=yes …` or a
|
|
570
|
+
* URL, so code is said as what it is: a fenced block becomes "the details are in Slack", inline
|
|
571
|
+
* code that is really code becomes "an lsblk command" or "a file", a link becomes "a link", and a
|
|
572
|
+
* stretch of bare command line becomes what it is too. Markdown emphasis goes; tone tags stay.
|
|
573
|
+
*/
|
|
574
|
+
export function speakable(text) {
|
|
575
|
+
let s = String(text ?? "");
|
|
576
|
+
s = s.replace(/```[\s\S]*?(```|$)/g, " (the details are in Slack) ");
|
|
577
|
+
s = s.replace(/`([^`\n]+)`/g, (_, code) => (looksLikeCode(code, { quoted: true }) ? nameCode(code) : code));
|
|
578
|
+
s = s.replace(/<?https?:\/\/[^\s>|]+(\|[^>]*)?>?/gi, "a link");
|
|
579
|
+
s = s
|
|
580
|
+
.split(/\n+|(?<=[.!?])\s+(?=[A-Z[])/)
|
|
581
|
+
.map((part) => {
|
|
582
|
+
// A sentence that ends in a command ("Can I run ssh -o … 'df -h'") keeps its words and names the command.
|
|
583
|
+
const words = part.trim().split(/\s+/);
|
|
584
|
+
const start = words.findIndex((w, i) => looksLikeCode(words.slice(i).join(" ")) && CODEISH.test(words[Math.min(i + 1, words.length - 1)]));
|
|
585
|
+
if (start < 0) return part;
|
|
586
|
+
return [...words.slice(0, start), nameCode(words.slice(start).join(" "))].join(" ");
|
|
587
|
+
})
|
|
588
|
+
.join(" ");
|
|
589
|
+
return s
|
|
590
|
+
.replace(/(^|\s)[*_~]+(?=\S)|(?<=\S)[*_~]+(?=\s|[,.!?;:]|$)/g, "$1")
|
|
591
|
+
.replace(/^\s*[>•-]\s+/gm, "")
|
|
592
|
+
.replace(/\s+([,.!?;:])/g, "$1")
|
|
593
|
+
.replace(/\s{2,}/g, " ")
|
|
594
|
+
.trim();
|
|
540
595
|
}
|
|
541
596
|
|
|
542
597
|
// Several agents share one pair of speakers, and a voice from the next room with no name on it is
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
rogerRogerHome, inQuietHours, listSounds, snoozeUntil,
|
|
12
12
|
} from "./lib.mjs";
|
|
13
13
|
import * as decisions from "./decisions.mjs";
|
|
14
|
+
import * as activity from "./activity.mjs";
|
|
14
15
|
import * as progressStore from "./progress.mjs";
|
|
15
16
|
import * as sessions from "./sessions.mjs";
|
|
16
17
|
import { PROVIDERS, catalog, keyFor, providerOf } from "./tts.mjs";
|
|
@@ -46,6 +47,8 @@ export function snapshot({ config = null, daemon = null, now = Date.now() } = {}
|
|
|
46
47
|
return {
|
|
47
48
|
updatedAt: new Date(now).toISOString(),
|
|
48
49
|
daemon: daemon ? { pid: daemon.pid, slackConnected: Boolean(daemon.slackConnected) } : null,
|
|
50
|
+
// Who is speaking right now ({ who, since }), so the panel can offer to stop it.
|
|
51
|
+
speaking: daemon?.speaking ?? null,
|
|
49
52
|
muted: {
|
|
50
53
|
snoozeUntil: snoozeUntil(config, new Date(now)),
|
|
51
54
|
quietHours: config?.quietHours ?? null,
|
|
@@ -61,6 +64,8 @@ export function snapshot({ config = null, daemon = null, now = Date.now() } = {}
|
|
|
61
64
|
expiresAt: d.expiresAt,
|
|
62
65
|
})),
|
|
63
66
|
working: progressStore.active(now).map((p) => ({ key: p.key, headline: p.headline, nickname: p.nickname ?? null })),
|
|
67
|
+
// Who said what lately, newest first: the Log view.
|
|
68
|
+
log: activity.recent(60),
|
|
64
69
|
// Everything `setup` can change, so the panel can offer it without asking anyone. Tokens live
|
|
65
70
|
// in slack.json and the environment and are deliberately not here.
|
|
66
71
|
settings: config
|
|
@@ -191,6 +191,8 @@ function tooltip(state, stale) {
|
|
|
191
191
|
*/
|
|
192
192
|
function menu(state, spot) {
|
|
193
193
|
const items = [];
|
|
194
|
+
// While a voice plays, the first thing on the menu stops it.
|
|
195
|
+
if (state.speaking) items.push({ id: "hush", label: state.speaking.who ? `Stop ${state.speaking.who} speaking` : "Stop the voice" }, { id: "sep0", label: "-" });
|
|
194
196
|
if (state.muted?.snoozeUntil) {
|
|
195
197
|
items.push({ id: "snooze:off", label: "Stop snoozing" });
|
|
196
198
|
} else {
|
|
@@ -203,9 +205,10 @@ function menu(state, spot) {
|
|
|
203
205
|
return { items };
|
|
204
206
|
}
|
|
205
207
|
|
|
206
|
-
const menuKey = (state, spot) => `${state.muted?.snoozeUntil ? "snoozed" : "awake"}:${spot ? "spot" : "free"}`;
|
|
208
|
+
const menuKey = (state, spot) => `${state.muted?.snoozeUntil ? "snoozed" : "awake"}:${spot ? "spot" : "free"}:${state.speaking ? `speaking-${state.speaking.who ?? ""}` : "silent"}`;
|
|
207
209
|
|
|
208
210
|
function onMenu(id) {
|
|
211
|
+
if (id === "hush") return run("hush");
|
|
209
212
|
if (id === "snooze:off") return run("snooze", "off");
|
|
210
213
|
if (id?.startsWith("snooze:")) return run("snooze", id.slice("snooze:".length));
|
|
211
214
|
return null;
|
|
@@ -463,6 +466,7 @@ async function main() {
|
|
|
463
466
|
return log(`the panel said something I could not read: ${message?.body ?? message}`);
|
|
464
467
|
}
|
|
465
468
|
if (ask.do === "snooze") return run("snooze", ask.value || "1h");
|
|
469
|
+
if (ask.do === "hush") return run("hush");
|
|
466
470
|
if (ask.do === "hide") return hidePanel();
|
|
467
471
|
if (ask.do === "keep-open") {
|
|
468
472
|
keepOpen = Boolean(ask.value);
|