roger-roger 0.1.4 → 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 +1 -1
- package/package.json +1 -1
- package/skills/roger-roger/SKILL.md +6 -3
- 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 +1 -0
- 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 +1 -1
- 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
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ There is no always-on service. `ask` starts a small watcher process, and one wat
|
|
|
97
97
|
- **Talk to an agent**: write "sage, rerun the tests" (or "hey sage rerun the tests", or "sage: ..."). With one agent running, a plain message goes to it. With several, but only one of them listening, it goes to that one and Slack says so, with a button to redirect it. With several, the bot asks which, with a button per agent — and remembers your answer for that thread.
|
|
98
98
|
- **You can hear who it is**: a spoken line starts with the agent's name — "Amber here.", "This is Rose.", "Jade speaking." — so three agents sharing one pair of speakers stay tellable apart (`setup --say-who off`).
|
|
99
99
|
- **Reachable while idle**: an agent that has finished can run `listen` in the background instead of going quiet. It costs nothing to wait, and when you write, it wakes up and answers. Slack shows 👀 when an agent is listening and 💤 when your message is saved for one that isn't, so you always know which.
|
|
100
|
-
- **In the tray**: `tray install` puts an icon in the system tray, started with the daemon from then on (`tray start` starts it straight away; `setup --tray off` stops it starting by itself) — every agent at a glance, a translucent panel on click, snooze on right-click. It reads the same state the daemon already keeps and runs ordinary commands, so it can be closed, killed or never installed and nothing else changes.
|
|
100
|
+
- **In the tray**: `tray install` puts an icon in the system tray, started with the daemon from then on (`tray start` starts it straight away; `setup --tray off` stops it starting by itself) — every agent at a glance, a translucent panel on click, snooze on right-click, a Stop button while a voice is speaking (`roger-roger hush` from a terminal), and a log of who said what lately: every notification and question from the agents, and every message and answer from you, per agent or all together. It reads the same state the daemon already keeps and runs ordinary commands, so it can be closed, killed or never installed and nothing else changes.
|
|
101
101
|
- **Questions asked in the terminal**: when the agent asks you something with its own question tool (Claude Code's question dialog, OpenCode's question tool, Codex's `request_user_input`) or wants a plan approved, it's stuck until you answer and can't tell you itself. `hooks install` hooks into each of those agents it finds: once the question has gone unanswered for a little while, it arrives in Slack with its options, and when you answer in the terminal the message turns into your answer. Claude Code gets three hooks in `~/.claude/settings.json`; OpenCode a small plugin in `~/.config/opencode/plugins/`; Codex three hooks in `~/.codex/hooks.json`, which you trust once in Codex's `/hooks`. Claude Code knows when you've stopped typing; OpenCode and Codex don't, so they wait 20 seconds first (`setup --terminal-wait`). `setup --permission-pings on` does the same for ordinary permission prompts. `hooks uninstall` takes it all out again.
|
|
102
102
|
- **Quiet hours**: `setup --quiet-hours 22:00-08:00` mutes sound and speech overnight. It's off by default.
|
|
103
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roger-roger",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Bring yourself back to the conversation: your coding agents reach you by Slack, a sound or a spoken line when they finish, get stuck, or need a decision, and you answer from your phone.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@ Every command prints a single JSON object.
|
|
|
25
25
|
|
|
26
26
|
**Your name.** The daemon gives every agent session a short colour nickname — 🌿 sage, 🌹 rose, 🍇 plum — shown on all your Slack messages with a matching colour bar down their edge. That is how the user tells three agents apart and how they address you: "sage, rerun the tests". `sessions` lists who is running; `status` tells you your own name.
|
|
27
27
|
|
|
28
|
-
**In Herdr, your pane is named after you — by the skill, not by you.** When the session runs inside a [Herdr](https://herdr.dev) pane (`HERDR_ENV=1`), the first command labels the pane's tab with your badge (`🍯 amber`) and its agent with your kind and your work (`claude-build-process-improvements`: Herdr allows only lowercase letters, digits, `-` and `_` there, 32 characters at most), so the Herdr sidebar reads `code-red 🍯 amber claude-build-process-improvements` and matches your Slack messages. The work part is your `--session` name, which is one more reason to pass it, and the labels follow a `sessions rename` or a new `--session` name by themselves. Don't run `herdr tab rename` or `herdr agent rename` yourself; `status` shows what was put on under `daemon.me.herdr`, and a label Herdr refused (a name another pane already has) is in `~/.roger-roger/daemon.log`.
|
|
28
|
+
**In Herdr, your pane is named after you — by the skill, not by you.** When the session runs inside a [Herdr](https://herdr.dev) pane (`HERDR_ENV=1`), the first command labels the pane's tab with your badge (`🍯 amber`) and its agent with your kind and your work (`claude-build-process-improvements`: Herdr allows only lowercase letters, digits, `-` and `_` there, 32 characters at most), so the Herdr sidebar reads `code-red 🍯 amber claude-build-process-improvements` and matches your Slack messages. The work part is your `--session` name, which is one more reason to pass it (without one, the skill falls back to your terminal title, which may be vague or out of date), and the labels follow a `sessions rename` or a new `--session` name by themselves. Don't run `herdr tab rename` or `herdr agent rename` yourself; `status` shows what was put on under `daemon.me.herdr`, and a label Herdr refused (a name another pane already has) is in `~/.roger-roger/daemon.log`.
|
|
29
29
|
|
|
30
30
|
**Always pass `--session`**, the same 3–6 word name on every call. It is not only a label: for agents that don't publish a session id of their own it is *how you are recognised* between commands, so leaving it off can make one piece of work look like two. Nothing here is specific to any agent — if your environment publishes a session id (anything like `*_SESSION_ID`) it is used automatically; otherwise `--session` plus the working directory identifies you, and the terminal is the last resort. If you know better than all of that, pass `--session-id ID` (and `--agent-pid PID`, so a crash is noticed straight away).
|
|
31
31
|
|
|
@@ -78,7 +78,7 @@ roger-roger notify --kind done --message "Refactor of the billing module is fini
|
|
|
78
78
|
|---|---|
|
|
79
79
|
| `--kind` | `done`, `blocked`, `input`, `milestone`, `error`, `info`. Sets the Slack emoji. |
|
|
80
80
|
| `--message` | The written message for Slack. Make it self-contained, because the user may read it on their phone without context. Say what happened and what, if anything, you need from them. Slack mrkdwn works. |
|
|
81
|
-
| `--say` | The spoken line
|
|
81
|
+
| `--say` | The spoken line. Keep it short, one or two sentences, and **never put code in it**: no commands, flags, paths, URLs, hostnames or JSON. Say what the thing is and why, the way you would to someone across the room: not "ssh -o BatchMode=yes … lsblk …" but "Can I run lsblk on the server, to see which disks it has?". The command itself goes in `--message` (in a code block), where it can be read. Pass `--say` whenever the message has anything technical in it, because without it the message is what gets spoken. Ignored when the user's speech mode is `same`. With `brief`, always pass a one-sentence `--say`. |
|
|
82
82
|
| `--only` | Restrict this one call to some methods, e.g. `--only sound` when the user said "just ding me when done". |
|
|
83
83
|
| `--project` | Label shown in Slack. Defaults to the current directory name. |
|
|
84
84
|
| `--session` | **Always pass it.** A short name for this session, made up by you from what the session is about: 3–6 plain words, e.g. `"roger-roger skill slack integration"` or `"billing refactor tests"`. It's shown in the small `machine · agent · model · session` line under Slack messages, so the user can tell sessions apart. Decide it on your first call and reuse the exact same name for every later call in this session. Never use IDs or hashes. |
|
|
@@ -173,7 +173,9 @@ Examples:
|
|
|
173
173
|
- `--say "[positive] Deployment finished. [short pause] Everything is green."`
|
|
174
174
|
- `--say "[serious] I'm blocked. I need the production database password to continue."`
|
|
175
175
|
|
|
176
|
-
Tags must be separated by words or punctuation, never placed back to back. The script strips tags from Slack text and from the local-voice fallback, so they are safe to include.
|
|
176
|
+
Tags must be separated by words or punctuation, never placed back to back. The script strips tags from Slack text and from the local-voice fallback, so they are safe to include.
|
|
177
|
+
|
|
178
|
+
Code is never read aloud. As a safety net, anything that reaches the speakers still looking like code is replaced with what it is ("an ssh command", "a file", "a link", "the details are in Slack"), but that is a fallback, not a way of writing: a line the safety net had to fix still sounds clumsy. Write the spoken line as plain speech in the first place, and name commands by what they do. Very short or odd lines (a single word) are sometimes refused by Gemini, so write a natural sentence.
|
|
177
179
|
|
|
178
180
|
## 3. Asking the user to choose
|
|
179
181
|
|
|
@@ -269,6 +271,7 @@ Judge it by what happens if you are wrong, not by whether it feels routine. A fo
|
|
|
269
271
|
- `end [name]`: this session is finished. Do this when your work is done, so messages meant for someone else don't come to you.
|
|
270
272
|
- `tray status|start|stop|install`: an icon in the system tray showing every agent, with a translucent panel behind it and snooze on the right-click menu. The native part is a separate package installed on request into `~/.roger-roger` (about 4 MB, nothing compiled); everything here works without it. Once installed, the daemon starts it by itself whenever it starts (`setup --tray off` to stop that).
|
|
271
273
|
- `snooze [1h|off]`: mute sound and speech for a while; Slack keeps working. For "not now" moments that quiet hours don't cover.
|
|
274
|
+
- `hush`: stop the voice or sound playing right now (the user's Stop button in the tray does the same). Only that announcement; the next one plays, and the Slack message stays. If a notification comes back with `results.interrupted`, the user stopped it: don't send it again.
|
|
272
275
|
- `hooks status|install|uninstall [claude|opencode|codex|all]`: the terminal-question hooks. When you use your own question tool (Claude Code's question dialog, OpenCode's question tool, Codex's `request_user_input`) or present a plan to approve, you are frozen until it's answered and can't send anything yourself; with the hooks, the agent itself runs roger-roger, and once the question has gone unanswered for a little while the user gets it in Slack with its options. `roger-roger install` offers them; without a name, `hooks install` covers the agents on this machine. What each gets: Claude Code, three entries in `~/.claude/settings.json`; OpenCode, a small plugin at `~/.config/opencode/plugins/roger-roger.js` (restart OpenCode to load it); Codex, three entries in `~/.codex/hooks.json`, to trust in `/hooks`. They follow the user's `when`, quiet hours and snooze like any notification, and log each run to `~/.roger-roger/hooks.log`. Claude Code pings once nobody has typed for a few seconds; OpenCode and Codex can't tell, so they wait `setup --terminal-wait` (default 20s) and ping only if it's still unanswered. Ordinary permission prompts (leave to run a command) don't ping unless the user asks: `setup --permission-pings on` (Claude Code and OpenCode; Codex gives no signal when one is answered). Run `hooks install` again after the skill moves, so they point at the new copy; `hooks status` says if they don't.
|
|
273
276
|
- `daemon status|start|stop`: the always-on process behind everything. It starts on demand, so this is only for checking on it or restarting it after a change to the skill's code.
|
|
274
277
|
- `play [sound]`: preview a sound (defaults to the configured one).
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
id = "roger-roger"
|
|
8
8
|
name = "roger-roger"
|
|
9
|
-
version = "0.1.
|
|
9
|
+
version = "0.1.8"
|
|
10
10
|
min_herdr_version = "0.9.0"
|
|
11
11
|
description = "Slack, a sound or a spoken line from the agents in your panes, and questions you answer from your phone"
|
|
12
12
|
platforms = ["linux", "macos", "windows"]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Who said what, lately: every notification and question an agent sent, and every message and
|
|
2
|
+
// answer the user sent back, newest last. The tray shows it as a log, so "what did amber say an
|
|
3
|
+
// hour ago, and did I answer?" has an answer without scrolling Slack.
|
|
4
|
+
//
|
|
5
|
+
// Kept in ~/.roger-roger/activity.json, capped, and written through a temp file so the tray never
|
|
6
|
+
// reads half of it. Recording never throws: a log that can't be written is not a reason for a
|
|
7
|
+
// notification to fail.
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { rogerRogerHome, stripTags } from "./lib.mjs";
|
|
12
|
+
import { colourOf } from "./names.mjs";
|
|
13
|
+
|
|
14
|
+
export const MAX_ENTRIES = 200;
|
|
15
|
+
const MAX_TEXT = 600;
|
|
16
|
+
|
|
17
|
+
export const activityPath = (env = process.env) => path.join(rogerRogerHome(env), "activity.json");
|
|
18
|
+
|
|
19
|
+
export function read(env = process.env) {
|
|
20
|
+
try {
|
|
21
|
+
const entries = JSON.parse(fs.readFileSync(activityPath(env), "utf8"));
|
|
22
|
+
return Array.isArray(entries) ? entries : [];
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Who an entry is about: the agent's name and colour, or nobody yet. */
|
|
29
|
+
export function agentOf(session) {
|
|
30
|
+
if (!session?.nickname) return null;
|
|
31
|
+
const colour = colourOf(session.nickname);
|
|
32
|
+
return { nickname: session.nickname, swatch: session.swatch ?? colour.swatch, colour: session.colour ?? colour.hex, project: session.project ?? "" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const clip = (text) => {
|
|
36
|
+
const s = stripTags(String(text ?? "")).replace(/\s+/g, " ").trim();
|
|
37
|
+
return s.length > MAX_TEXT ? `${s.slice(0, MAX_TEXT - 1)}…` : s;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Add one entry. `from` is "agent" or "user"; `agent` is who the agent was, or who the user wrote
|
|
42
|
+
* to (null when that isn't known yet); `kind` is what it was: notify's kind, "question", "answer",
|
|
43
|
+
* or "message".
|
|
44
|
+
*/
|
|
45
|
+
export function record({ from, agent = null, kind, text, detail = "", at = new Date().toISOString(), ref = "" }, env = process.env) {
|
|
46
|
+
try {
|
|
47
|
+
const entry = { at, from, kind, text: clip(text), ...(detail ? { detail: clip(detail) } : {}), ...(agent ? { agent } : {}), ...(ref ? { ref } : {}) };
|
|
48
|
+
write([...read(env), entry].slice(-MAX_ENTRIES), env);
|
|
49
|
+
return entry;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The user picked who a message was for after it was logged: say so on its entry. */
|
|
56
|
+
export function retarget(ref, agent, detail = "", env = process.env) {
|
|
57
|
+
try {
|
|
58
|
+
const entries = read(env);
|
|
59
|
+
const i = entries.findLastIndex((e) => e.ref === ref);
|
|
60
|
+
if (i < 0) return false;
|
|
61
|
+
const { detail: _old, agent: _was, ...rest } = entries[i];
|
|
62
|
+
entries[i] = { ...rest, ...(agent ? { agent } : {}), ...(detail ? { detail } : {}) };
|
|
63
|
+
write(entries, env);
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function write(entries, env) {
|
|
71
|
+
const file = activityPath(env);
|
|
72
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
73
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
74
|
+
fs.writeFileSync(tmp, JSON.stringify(entries, null, 1) + "\n", "utf8");
|
|
75
|
+
fs.renameSync(tmp, file);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The newest `n`, newest first, for the tray. */
|
|
79
|
+
export function recent(n = 60, env = process.env) {
|
|
80
|
+
return read(env).slice(-n).reverse();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** How an answer reads in one line: the button, the options ticked, the typed words, or what happened. */
|
|
84
|
+
export function answerText(decision) {
|
|
85
|
+
const a = decision.answer ?? {};
|
|
86
|
+
if (decision.status === "cancelled") return "cancelled";
|
|
87
|
+
if (decision.status === "expired") return "expired, unanswered";
|
|
88
|
+
if (a.edited) return `edited the draft: ${a.text ?? ""}`;
|
|
89
|
+
if (Array.isArray(a.choices)) return a.choices.length ? a.choices.join(", ") : "none of them";
|
|
90
|
+
if (a.choice) return a.auto ? `${a.choice} (no answer, so the recommended one)` : a.choice;
|
|
91
|
+
return a.text ?? "";
|
|
92
|
+
}
|
|
@@ -9,6 +9,71 @@ import { SOUNDS_DIR, duckFactor, rogerRogerHome, stripTags } from "./lib.mjs";
|
|
|
9
9
|
import { providerOf, synthesize } from "./tts.mjs";
|
|
10
10
|
import { withSpeaker } from "./speaker.mjs";
|
|
11
11
|
|
|
12
|
+
// ---------------------------------------------------------------- what is playing, and stopping it
|
|
13
|
+
//
|
|
14
|
+
// Everything audible goes through onAir(), so the daemon knows who is speaking (the tray shows a
|
|
15
|
+
// Stop button while someone is) and interruptPlayback() can cut it off: the players are killed,
|
|
16
|
+
// the generation moves on, and anything that notices the generation changed under it stops
|
|
17
|
+
// instead of carrying on to the next sound or falling back to the computer's voice.
|
|
18
|
+
|
|
19
|
+
let generation = 0;
|
|
20
|
+
const players = new Set();
|
|
21
|
+
let onAirCount = 0;
|
|
22
|
+
let playing = null;
|
|
23
|
+
const listeners = new Set();
|
|
24
|
+
|
|
25
|
+
/** Called with what is playing (or null) whenever that changes. Returns a function to stop listening. */
|
|
26
|
+
export function onPlaybackChange(fn) {
|
|
27
|
+
listeners.add(fn);
|
|
28
|
+
return () => listeners.delete(fn);
|
|
29
|
+
}
|
|
30
|
+
const emit = () => {
|
|
31
|
+
for (const fn of listeners) {
|
|
32
|
+
try {
|
|
33
|
+
fn(playing);
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Who is speaking right now, `{ who, since }`, or null. */
|
|
39
|
+
export const nowPlaying = () => playing;
|
|
40
|
+
|
|
41
|
+
/** True once playback started at `gen` has been interrupted. */
|
|
42
|
+
export const stoppedSince = (gen) => generation !== gen;
|
|
43
|
+
|
|
44
|
+
/** Mark `fn` as audible for its duration, on behalf of `who`. `fn` gets the generation to check. */
|
|
45
|
+
export async function onAir(who, fn) {
|
|
46
|
+
const gen = generation;
|
|
47
|
+
onAirCount++;
|
|
48
|
+
if (!playing) {
|
|
49
|
+
playing = { who: who || null, since: new Date().toISOString() };
|
|
50
|
+
emit();
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return await fn(gen);
|
|
54
|
+
} finally {
|
|
55
|
+
onAirCount--;
|
|
56
|
+
if (!onAirCount) {
|
|
57
|
+
playing = null;
|
|
58
|
+
emit();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Stop whatever is playing, now. Returns whether anything was. */
|
|
64
|
+
export function interruptPlayback() {
|
|
65
|
+
const was = playing;
|
|
66
|
+
if (!was) return { stopped: false };
|
|
67
|
+
generation++;
|
|
68
|
+
for (const child of players) {
|
|
69
|
+
try {
|
|
70
|
+
child.kill();
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
session?.kill();
|
|
74
|
+
return { stopped: true, who: was.who };
|
|
75
|
+
}
|
|
76
|
+
|
|
12
77
|
/** Run a command to completion; resolves true only on a clean exit. Never rejects. */
|
|
13
78
|
function run(cmd, args, opts = {}) {
|
|
14
79
|
return new Promise((resolve) => {
|
|
@@ -18,14 +83,19 @@ function run(cmd, args, opts = {}) {
|
|
|
18
83
|
} catch {
|
|
19
84
|
return resolve(false);
|
|
20
85
|
}
|
|
21
|
-
|
|
22
|
-
child.on("
|
|
86
|
+
players.add(child);
|
|
87
|
+
child.on("error", () => (players.delete(child), resolve(false)));
|
|
88
|
+
child.on("exit", (code) => (players.delete(child), resolve(code === 0)));
|
|
23
89
|
});
|
|
24
90
|
}
|
|
25
91
|
|
|
26
|
-
/** Try each
|
|
27
|
-
async function
|
|
28
|
-
|
|
92
|
+
/** Try each in turn, but stop trying once playback was interrupted: that exit was ours, not a failure. */
|
|
93
|
+
async function firstThatWorksUnlessStopped(candidates, opts) {
|
|
94
|
+
const gen = generation;
|
|
95
|
+
for (const [cmd, args] of candidates) {
|
|
96
|
+
if (await run(cmd, args, opts)) return true;
|
|
97
|
+
if (stoppedSince(gen)) return true;
|
|
98
|
+
}
|
|
29
99
|
return false;
|
|
30
100
|
}
|
|
31
101
|
|
|
@@ -195,6 +265,14 @@ function windowsSession() {
|
|
|
195
265
|
if (session === self) session = null;
|
|
196
266
|
child.stdin.end();
|
|
197
267
|
},
|
|
268
|
+
// PlaySync and Speak block the player, so a stop can't be asked for: the player goes, and the
|
|
269
|
+
// next request starts a new one.
|
|
270
|
+
kill() {
|
|
271
|
+
fail("interrupted");
|
|
272
|
+
try {
|
|
273
|
+
child.kill();
|
|
274
|
+
} catch {}
|
|
275
|
+
},
|
|
198
276
|
};
|
|
199
277
|
session = self;
|
|
200
278
|
return self;
|
|
@@ -210,15 +288,26 @@ export function closeAudio() {
|
|
|
210
288
|
session?.close();
|
|
211
289
|
}
|
|
212
290
|
|
|
291
|
+
/** A Windows player request that, if the player was killed to stop it, just ends: stopping isn't failing. */
|
|
292
|
+
async function windowsRequest(command, gen) {
|
|
293
|
+
try {
|
|
294
|
+
await windowsSession().request(command);
|
|
295
|
+
} catch (e) {
|
|
296
|
+
if (!stoppedSince(gen)) throw e;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Interrupted playback returns as if it had finished: the caller checks stoppedSince() to know.
|
|
213
301
|
export async function playWav(file) {
|
|
214
302
|
if (!fs.existsSync(file)) throw new Error(`no such sound file: ${file}`);
|
|
303
|
+
const gen = generation;
|
|
215
304
|
let ok;
|
|
216
305
|
if (process.platform === "win32") {
|
|
217
|
-
return
|
|
306
|
+
return windowsRequest(`PLAY\t${b64(file)}`, gen);
|
|
218
307
|
} else if (process.platform === "darwin") {
|
|
219
|
-
ok = await run("afplay", [file]);
|
|
308
|
+
ok = (await run("afplay", [file])) || stoppedSince(gen);
|
|
220
309
|
} else {
|
|
221
|
-
ok = await
|
|
310
|
+
ok = await firstThatWorksUnlessStopped([
|
|
222
311
|
["paplay", [file]],
|
|
223
312
|
["pw-play", [file]],
|
|
224
313
|
["aplay", ["-q", file]],
|
|
@@ -241,13 +330,14 @@ export async function playWavBuffer(wav) {
|
|
|
241
330
|
|
|
242
331
|
export async function speakLocally(text) {
|
|
243
332
|
const plain = stripTags(text);
|
|
333
|
+
const gen = generation;
|
|
244
334
|
let ok;
|
|
245
335
|
if (process.platform === "win32") {
|
|
246
|
-
return
|
|
336
|
+
return windowsRequest(`SAY\t${b64(plain)}`, gen);
|
|
247
337
|
} else if (process.platform === "darwin") {
|
|
248
|
-
ok = await run("say", [plain]);
|
|
338
|
+
ok = (await run("say", [plain])) || stoppedSince(gen);
|
|
249
339
|
} else {
|
|
250
|
-
ok = await
|
|
340
|
+
ok = await firstThatWorksUnlessStopped([
|
|
251
341
|
["spd-say", ["-w", plain]],
|
|
252
342
|
["espeak-ng", [plain]],
|
|
253
343
|
["espeak", [plain]],
|
|
@@ -299,7 +389,13 @@ export async function withOthersQuieter(config, fn) {
|
|
|
299
389
|
try {
|
|
300
390
|
return await fn();
|
|
301
391
|
} finally {
|
|
302
|
-
|
|
392
|
+
// Stopping the voice kills this player, and with it what it saved. A fresh player reads the
|
|
393
|
+
// saved levels back from the file and puts them back, so the music doesn't stay down.
|
|
394
|
+
try {
|
|
395
|
+
await player.request("UNDUCK");
|
|
396
|
+
} catch {
|
|
397
|
+
await duckPlayer().catch(() => {});
|
|
398
|
+
}
|
|
303
399
|
fs.rmSync(duckFile(), { force: true });
|
|
304
400
|
}
|
|
305
401
|
}
|
|
@@ -330,14 +426,18 @@ export function prepareSpeech(text, config, { local = false } = {}) {
|
|
|
330
426
|
}
|
|
331
427
|
|
|
332
428
|
/** Play prepared audio; else the local synthesiser; else (if nothing else made a noise) the ding. */
|
|
333
|
-
export async function deliverSpeech(prepared, text, config, { dingIfSilent = false } = {}) {
|
|
429
|
+
export async function deliverSpeech(prepared, text, config, { dingIfSilent = false, gen = generation } = {}) {
|
|
334
430
|
const engine = providerOf(config).id;
|
|
335
431
|
const errors = prepared.error ? [`${engine}: ${prepared.error}`] : [];
|
|
432
|
+
// Stopped by the user: that is the end of it, not a reason to try the computer's voice instead.
|
|
433
|
+
const interrupted = () => ({ ok: true, engine, interrupted: true });
|
|
434
|
+
if (stoppedSince(gen)) return interrupted();
|
|
336
435
|
if (prepared.wav) {
|
|
337
436
|
try {
|
|
338
437
|
await playWavBuffer(prepared.wav);
|
|
339
|
-
return { ok: true, engine, voice: config.voice };
|
|
438
|
+
return stoppedSince(gen) ? interrupted() : { ok: true, engine, voice: config.voice };
|
|
340
439
|
} catch (e) {
|
|
440
|
+
if (stoppedSince(gen)) return interrupted();
|
|
341
441
|
errors.push(`playback: ${e.message}`);
|
|
342
442
|
}
|
|
343
443
|
}
|
|
@@ -366,15 +466,16 @@ export async function deliverSpeech(prepared, text, config, { dingIfSilent = fal
|
|
|
366
466
|
* the speaker is claimed, so a queued agent waits for playback, not for the network.
|
|
367
467
|
* The ding and the voice after it are one turn: another agent cannot cut between them.
|
|
368
468
|
*/
|
|
369
|
-
export async function playAlert({ methods, config, text, prepared }) {
|
|
469
|
+
export async function playAlert({ methods, config, text, prepared, who = null }) {
|
|
370
470
|
const results = {};
|
|
371
471
|
const wantsSpeech = methods.includes("speech");
|
|
372
472
|
if (wantsSpeech || methods.includes("sound")) warmUpAudio();
|
|
373
473
|
const speech = wantsSpeech ? await (prepared ?? prepareSpeech(text, config)) : null;
|
|
374
474
|
if (!wantsSpeech && !methods.includes("sound")) return results;
|
|
375
475
|
|
|
376
|
-
// The ding and the voice are one announcement, so everything else goes quiet for both of them
|
|
377
|
-
|
|
476
|
+
// The ding and the voice are one announcement, so everything else goes quiet for both of them,
|
|
477
|
+
// and stopping it stops both: a stop during the ding means no voice after it.
|
|
478
|
+
return withSpeaker((turn) => onAir(who, (gen) => withOthersQuieter(wantsSpeech ? config : null, async () => {
|
|
378
479
|
if (methods.includes("sound")) {
|
|
379
480
|
try {
|
|
380
481
|
await playWav(soundFile(config.sound));
|
|
@@ -384,9 +485,12 @@ export async function playAlert({ methods, config, text, prepared }) {
|
|
|
384
485
|
}
|
|
385
486
|
}
|
|
386
487
|
if (wantsSpeech) {
|
|
387
|
-
results.speech =
|
|
488
|
+
results.speech = stoppedSince(gen)
|
|
489
|
+
? { ok: true, interrupted: true }
|
|
490
|
+
: await deliverSpeech(speech, text, config, { dingIfSilent: !methods.includes("sound"), gen });
|
|
388
491
|
}
|
|
492
|
+
if (stoppedSince(gen)) results.interrupted = true;
|
|
389
493
|
if (turn.waitedMs > 0) results.queued = { waitedMs: turn.waitedMs, ...(turn.tookOver ? { tookOver: true } : {}) };
|
|
390
494
|
return results;
|
|
391
|
-
}));
|
|
495
|
+
})));
|
|
392
496
|
}
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from "./lib.mjs";
|
|
26
26
|
import { lineReader, send, socketPath, PROTOCOL_VERSION } from "./protocol.mjs";
|
|
27
27
|
import { connectSocket, openModal, permalink, postToChannel, updateMessage } from "./slack.mjs";
|
|
28
|
-
import { closeAudio, playAlert, warmUpAudio } from "./audio.mjs";
|
|
28
|
+
import { closeAudio, nowPlaying, onPlaybackChange, playAlert, warmUpAudio } from "./audio.mjs";
|
|
29
29
|
import { FROM_HOOKS, HANDLERS, owedTo, startTray } from "./handlers.mjs";
|
|
30
30
|
import * as progressStore from "./progress.mjs";
|
|
31
31
|
import * as tray from "./tray.mjs";
|
|
@@ -99,7 +99,7 @@ function refreshTray({ ifStaleMs = 0 } = {}) {
|
|
|
99
99
|
snapshotTimer = null;
|
|
100
100
|
snapshotAt = Date.now();
|
|
101
101
|
try {
|
|
102
|
-
tray.write(tray.snapshot({ config: loadConfig(), daemon: { pid: process.pid, slackConnected: state.slackConnected } }));
|
|
102
|
+
tray.write(tray.snapshot({ config: loadConfig(), daemon: { pid: process.pid, slackConnected: state.slackConnected, speaking: nowPlaying() } }));
|
|
103
103
|
} catch (e) {
|
|
104
104
|
log(`could not write the tray snapshot: ${e.message}`);
|
|
105
105
|
}
|
|
@@ -408,7 +408,7 @@ async function remindIfDue(decision) {
|
|
|
408
408
|
.catch((e) => log(`${decision.id}: reminder post failed: ${e.message}`))
|
|
409
409
|
: null;
|
|
410
410
|
try {
|
|
411
|
-
await playAlert({ methods, config, text: "[neutral] Quick reminder. I'm still waiting for your answer in Slack." });
|
|
411
|
+
await playAlert({ methods, config, text: "[neutral] Quick reminder. I'm still waiting for your answer in Slack.", who: decision.nickname ?? null });
|
|
412
412
|
} catch (e) {
|
|
413
413
|
log(`${decision.id}: reminder audio failed: ${e.message}`);
|
|
414
414
|
}
|
|
@@ -563,6 +563,8 @@ async function main() {
|
|
|
563
563
|
const stamp = codeStamp();
|
|
564
564
|
log(`daemon started on ${socketPath()}`);
|
|
565
565
|
warmUpAudio(); // so the first announcement of the day doesn't wait for a PowerShell to start
|
|
566
|
+
// The tray shows a Stop button while something plays, so it has to hear when that starts and ends.
|
|
567
|
+
onPlaybackChange(() => changed("speaking"));
|
|
566
568
|
await stopLegacyDaemon().catch((e) => log(`could not stop the old daemon: ${e.message}`));
|
|
567
569
|
autostartTray();
|
|
568
570
|
|
|
@@ -6,6 +6,7 @@ import fs from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { rogerRogerHome, outcomeText, resolvedBlocks, stripTags } from "./lib.mjs";
|
|
8
8
|
import { updateMessage } from "./slack.mjs";
|
|
9
|
+
import * as activity from "./activity.mjs";
|
|
9
10
|
|
|
10
11
|
const KEEP_RESOLVED_MS = 7 * 24 * 3600 * 1000;
|
|
11
12
|
|
|
@@ -114,6 +115,14 @@ export async function settle(id, patch, log = () => {}, { waiting = false } = {}
|
|
|
114
115
|
const owed = Boolean(current.sessionId) && patch.status === "answered";
|
|
115
116
|
const settled = { ...current, ...patch, resolvedAt: now, ...(owed ? { deliveredAt: waiting ? now : null } : {}) };
|
|
116
117
|
write(settled);
|
|
118
|
+
const byUser = settled.status === "answered" && !settled.answer?.auto;
|
|
119
|
+
activity.record({
|
|
120
|
+
from: byUser ? "user" : "agent",
|
|
121
|
+
kind: settled.status === "answered" ? "answer" : settled.status,
|
|
122
|
+
agent: activity.agentOf({ nickname: settled.nickname, swatch: settled.swatch, colour: settled.colour }),
|
|
123
|
+
text: activity.answerText(settled),
|
|
124
|
+
detail: settled.question,
|
|
125
|
+
});
|
|
117
126
|
|
|
118
127
|
try {
|
|
119
128
|
const outcome = outcomeText(settled);
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from "./lib.mjs";
|
|
21
21
|
import {
|
|
22
22
|
deliverSpeech, mapPool, playAlert, playWav, playWavBuffer, prepareSpeech, soundFile,
|
|
23
|
-
speakLocally, speechAudio, warmUpAudio, withOthersQuieter,
|
|
23
|
+
interruptPlayback, nowPlaying, onAir, speakLocally, speechAudio, stoppedSince, warmUpAudio, withOthersQuieter,
|
|
24
24
|
} from "./audio.mjs";
|
|
25
25
|
import { withSpeaker } from "./speaker.mjs";
|
|
26
26
|
import { catalog, findVoice, keyFor, missingKey, providerOf, refreshCatalog, testSample } from "./tts.mjs";
|
|
@@ -29,6 +29,7 @@ import * as progressStore from "./progress.mjs";
|
|
|
29
29
|
import * as sessions from "./sessions.mjs";
|
|
30
30
|
import * as inbox from "./inbox.mjs";
|
|
31
31
|
import * as decisions from "./decisions.mjs";
|
|
32
|
+
import * as activity from "./activity.mjs";
|
|
32
33
|
import { labelPane } from "./herdr.mjs";
|
|
33
34
|
import { answeredNotice, isQuestion, matchRequest, permissionNotice, rememberRequest } from "./hooks.mjs";
|
|
34
35
|
|
|
@@ -179,9 +180,10 @@ export async function notify(ctx, args) {
|
|
|
179
180
|
})().catch((e) => (results.slack = { ok: Boolean(results.slack?.ok), error: e.message }))
|
|
180
181
|
: null;
|
|
181
182
|
|
|
182
|
-
Object.assign(results, await playAlert({ methods, config, text, prepared }));
|
|
183
|
+
Object.assign(results, await playAlert({ methods, config, text, prepared, who: ctx.session?.nickname }));
|
|
183
184
|
await slackJob;
|
|
184
185
|
const succeeded = Object.values(results).some((r) => r.ok);
|
|
186
|
+
if (succeeded) activity.record({ from: "agent", kind, agent: activity.agentOf(ctx.session), text: message });
|
|
185
187
|
const body = {
|
|
186
188
|
ok: succeeded,
|
|
187
189
|
kind,
|
|
@@ -679,6 +681,19 @@ export async function tray(ctx, args) {
|
|
|
679
681
|
* Quiet for a while, on purpose. Quiet hours are a schedule and a meeting is not one, so this is
|
|
680
682
|
* the ad-hoc version: sound and speech off until it runs out, Slack untouched.
|
|
681
683
|
*/
|
|
684
|
+
/**
|
|
685
|
+
* Stop the voice (or sound) playing right now: the tray's Stop button, or `roger-roger hush`. Only
|
|
686
|
+
* this announcement; the next one plays as usual, and the Slack message was posted either way.
|
|
687
|
+
* It only means anything in the daemon, which is where playback happens.
|
|
688
|
+
*/
|
|
689
|
+
export function hush(ctx) {
|
|
690
|
+
if (!ctx.daemon) return ok({ ok: true, stopped: false, note: "no daemon, so nothing of roger-roger's is playing" });
|
|
691
|
+
const playing = nowPlaying();
|
|
692
|
+
const result = interruptPlayback();
|
|
693
|
+
if (result.stopped) ctx.log(`hush: stopped ${playing?.who ?? "playback"}`);
|
|
694
|
+
return ok({ ok: true, stopped: result.stopped, ...(result.stopped && playing?.who ? { who: playing.who } : {}) });
|
|
695
|
+
}
|
|
696
|
+
|
|
682
697
|
export function snooze(ctx, args) {
|
|
683
698
|
const value = (args._ ?? [])[0] ?? args.for ?? "1h";
|
|
684
699
|
// Snoozing is not setting up: saving here would write the defaults and call that the user's choice.
|
|
@@ -835,8 +850,9 @@ export async function ask(ctx, args) {
|
|
|
835
850
|
});
|
|
836
851
|
sessions.bindThread(ts, ctx.session?.id);
|
|
837
852
|
setState(ctx, "waiting");
|
|
853
|
+
activity.record({ from: "agent", kind: "question", agent: activity.agentOf(ctx.session), text: question, detail: choices.join(" / ") });
|
|
838
854
|
|
|
839
|
-
const audio = await playAlert({ methods: audible, config, text, prepared });
|
|
855
|
+
const audio = await playAlert({ methods: audible, config, text, prepared, who: ctx.session?.nickname });
|
|
840
856
|
const extra = { audio, ...(attached ? { attached } : {}), ...(muted.length ? { mutedByQuietHours: muted } : {}) };
|
|
841
857
|
if (args.wait !== undefined) return waitFor(ctx, id, parseDuration(args.wait, Infinity), extra);
|
|
842
858
|
// Waiting costs the agent nothing: run it in the background and carry on. It always comes back,
|
|
@@ -956,7 +972,7 @@ export async function play(ctx, args) {
|
|
|
956
972
|
const name = (args._ ?? [])[0] ?? ctx.config?.sound ?? DEFAULTS.sound;
|
|
957
973
|
const sounds = listSounds();
|
|
958
974
|
if (!sounds.includes(name)) fail(`unknown sound "${name}"; available: ${sounds.join(", ")}`);
|
|
959
|
-
await withSpeaker(() => playWav(soundFile(name)));
|
|
975
|
+
await withSpeaker(() => onAir("preview", () => playWav(soundFile(name))));
|
|
960
976
|
return ok({ ok: true, played: name });
|
|
961
977
|
}
|
|
962
978
|
|
|
@@ -989,11 +1005,11 @@ export async function say(ctx, args) {
|
|
|
989
1005
|
} catch (e) {
|
|
990
1006
|
fail(`${provider.label} failed with the key from ${key.name}: ${e.message}`);
|
|
991
1007
|
}
|
|
992
|
-
await withSpeaker(() => withOthersQuieter(config, () => playWavBuffer(sample.wav)));
|
|
1008
|
+
await withSpeaker(() => onAir("voice test", () => withOthersQuieter(config, () => playWavBuffer(sample.wav))));
|
|
993
1009
|
return ok({ ok: true, engine: provider.id, voice: config.voice, model: config.model, keyVar: key.name, cached: sample.cached });
|
|
994
1010
|
}
|
|
995
1011
|
const prepared = await prepareSpeech(text, config, { local: Boolean(args.local) });
|
|
996
|
-
const result = await withSpeaker(() => deliverSpeech(prepared, text, config));
|
|
1012
|
+
const result = await withSpeaker(() => onAir("preview", (gen) => deliverSpeech(prepared, text, config, { gen })));
|
|
997
1013
|
return ok(result, result.ok ? 0 : 1);
|
|
998
1014
|
}
|
|
999
1015
|
|
|
@@ -1059,9 +1075,10 @@ export async function previewSounds(ctx, args) {
|
|
|
1059
1075
|
const audio = args.local ? sounds.map(() => ({ error: "--local" })) : await prefetch(sounds.map(soundAnnouncement), config);
|
|
1060
1076
|
|
|
1061
1077
|
// The whole run-through is one turn at the speakers, so another agent can't cut in mid-list.
|
|
1062
|
-
const played = await withSpeaker(async () => {
|
|
1078
|
+
const played = await withSpeaker(() => onAir("preview", async (gen) => {
|
|
1063
1079
|
const done = [];
|
|
1064
1080
|
for (const [i, name] of sounds.entries()) {
|
|
1081
|
+
if (stoppedSince(gen)) break;
|
|
1065
1082
|
const entry = { sound: name };
|
|
1066
1083
|
try {
|
|
1067
1084
|
if (audio[i].wav) await playWavBuffer(audio[i].wav);
|
|
@@ -1078,7 +1095,7 @@ export async function previewSounds(ctx, args) {
|
|
|
1078
1095
|
done.push(entry);
|
|
1079
1096
|
}
|
|
1080
1097
|
return done;
|
|
1081
|
-
});
|
|
1098
|
+
}));
|
|
1082
1099
|
const succeeded = played.some((p) => p.ok);
|
|
1083
1100
|
return ok({ ok: succeeded, order: sounds, played }, succeeded ? 0 : 1);
|
|
1084
1101
|
}
|
|
@@ -1097,9 +1114,10 @@ export async function previewVoices(ctx, args) {
|
|
|
1097
1114
|
const audio = await mapPool(voices, 4, (voice) =>
|
|
1098
1115
|
speechAudio(text(voice), { ...config, voice }).then((wav) => ({ wav }), (e) => ({ error: e.message })));
|
|
1099
1116
|
|
|
1100
|
-
const played = await withSpeaker(async () => {
|
|
1117
|
+
const played = await withSpeaker(() => onAir("preview", async (gen) => {
|
|
1101
1118
|
const done = [];
|
|
1102
1119
|
for (const [i, voice] of voices.entries()) {
|
|
1120
|
+
if (stoppedSince(gen)) break;
|
|
1103
1121
|
const entry = { voice, name: byId[voice]?.name ?? voice, ...(byId[voice]?.description ? { character: byId[voice].description } : {}) };
|
|
1104
1122
|
if (audio[i].error) {
|
|
1105
1123
|
Object.assign(entry, { ok: false, error: audio[i].error });
|
|
@@ -1114,7 +1132,7 @@ export async function previewVoices(ctx, args) {
|
|
|
1114
1132
|
done.push(entry);
|
|
1115
1133
|
}
|
|
1116
1134
|
return done;
|
|
1117
|
-
});
|
|
1135
|
+
}));
|
|
1118
1136
|
const succeeded = played.some((p) => p.ok);
|
|
1119
1137
|
return ok({ ok: succeeded, order: voices, played }, succeeded ? 0 : 1);
|
|
1120
1138
|
}
|
|
@@ -1134,6 +1152,7 @@ export const HANDLERS = {
|
|
|
1134
1152
|
messages,
|
|
1135
1153
|
listen,
|
|
1136
1154
|
snooze,
|
|
1155
|
+
hush,
|
|
1137
1156
|
tray,
|
|
1138
1157
|
ask,
|
|
1139
1158
|
wait,
|
|
@@ -66,6 +66,7 @@ export const COMMANDS = [
|
|
|
66
66
|
{ section: "Reaching the user", name: "control", usage: "[--key NAME] [--wait DURATION]", summary: "Read Pause, Stop and new messages without posting. --wait blocks while paused." },
|
|
67
67
|
{ section: "Reaching the user", name: "listen", usage: "[--wait 25m]", summary: "Park until the user says something. Run it in the background as the last thing you do." },
|
|
68
68
|
{ section: "Reaching the user", name: "snooze", usage: "[1h|off]", summary: "Sound and speech off for a while; Slack keeps working." },
|
|
69
|
+
{ section: "Reaching the user", name: "hush", usage: "", summary: "Stop the voice or sound playing right now. Only this announcement: the next plays as usual, and the Slack message stays. The tray's Stop button does the same." },
|
|
69
70
|
|
|
70
71
|
// ---- asking
|
|
71
72
|
{
|
|
@@ -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" });
|
|
@@ -111,7 +111,7 @@ const MARK_END = "<!-- roger-roger:end -->";
|
|
|
111
111
|
/** The standing instruction, between marks so it can be replaced or removed without touching anything else. */
|
|
112
112
|
export const INSTRUCTIONS = [
|
|
113
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.
|
|
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
115
|
MARK_END,
|
|
116
116
|
].join("\n");
|
|
117
117
|
|
|
@@ -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);
|
|
@@ -141,6 +141,40 @@
|
|
|
141
141
|
.slider input[type="range"] { flex: 1; accent-color: var(--accent); padding: 0; border: 0; background: transparent; }
|
|
142
142
|
.slider output { width: 40px; text-align: right; font-size: 11px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
|
143
143
|
.flash.is-ok { border-color: #7fd1a0; }
|
|
144
|
+
|
|
145
|
+
/* The log: who said what, newest first. The agent's colour runs down the left edge. */
|
|
146
|
+
.row.is-agent { cursor: pointer; }
|
|
147
|
+
.filters { display: flex; gap: 4px; flex-wrap: wrap; padding: 6px 4px 8px; }
|
|
148
|
+
.entry { position: relative; padding: 8px 8px 8px 12px; border-bottom: 1px solid var(--border-subtle); cursor: pointer; }
|
|
149
|
+
.entry::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 2px; background: var(--edge, var(--text-muted)); }
|
|
150
|
+
.entry.from-user::before { background: var(--accent); }
|
|
151
|
+
.entry:hover { background: color-mix(in srgb, var(--bg-section) 60%, transparent); }
|
|
152
|
+
.entry .top { gap: 6px; font-size: 11px; color: var(--text-dim); }
|
|
153
|
+
.entry .name { font-weight: 600; color: var(--text); }
|
|
154
|
+
.entry .arrow { color: var(--text-muted); }
|
|
155
|
+
.entry .kind { padding: 0 5px; border: 1px solid var(--border-subtle); font-size: 10px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-muted); }
|
|
156
|
+
.entry .kind.k-blocked, .entry .kind.k-error, .entry .kind.k-question { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, transparent); }
|
|
157
|
+
.entry .kind.k-done, .entry .kind.k-answer { color: #7fd1a0; border-color: #7fd1a055; }
|
|
158
|
+
.entry .text { margin-top: 4px; line-height: 1.4; color: var(--text); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
|
|
159
|
+
.entry.is-open .text { display: block; }
|
|
160
|
+
.entry .detail { margin-top: 3px; font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
161
|
+
.entry.is-open .detail { white-space: normal; }
|
|
162
|
+
.day { padding: 12px 8px 4px; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
|
|
163
|
+
|
|
164
|
+
/* On air: shown only while a voice or sound plays, with the one thing you might want — stop it. */
|
|
165
|
+
.onair { display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 14px; border-bottom: 1px solid var(--border-subtle); background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
|
166
|
+
.onair[hidden] { display: none; }
|
|
167
|
+
.onair .bars { display: flex; align-items: flex-end; gap: 2px; height: 14px; }
|
|
168
|
+
.onair .bars i { width: 3px; background: var(--accent); animation: level 900ms ease-in-out infinite; }
|
|
169
|
+
.onair .bars i:nth-child(2) { animation-delay: -300ms; }
|
|
170
|
+
.onair .bars i:nth-child(3) { animation-delay: -600ms; }
|
|
171
|
+
@keyframes level { 0%, 100% { height: 4px; } 50% { height: 14px; } }
|
|
172
|
+
@media (prefers-reduced-motion: reduce) { .onair .bars i { animation: none; height: 10px; } }
|
|
173
|
+
.onair .label { flex: 1; min-width: 0; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
174
|
+
.onair .label strong { font-weight: 600; }
|
|
175
|
+
.onair button { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; font-weight: 600; color: #101317; background: var(--accent); border-color: var(--accent); }
|
|
176
|
+
.onair button:hover { filter: brightness(1.08); }
|
|
177
|
+
.onair button svg { width: 11px; height: 11px; fill: currentColor; stroke: none; }
|
|
144
178
|
svg { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
145
179
|
</style>
|
|
146
180
|
</head>
|
|
@@ -151,6 +185,9 @@
|
|
|
151
185
|
<span class="count" id="count"></span>
|
|
152
186
|
<span class="spacer"></span>
|
|
153
187
|
<span class="badge" id="badge" hidden></span>
|
|
188
|
+
<button class="icon-btn icon-btn-sm" id="log" title="Log: who said what">
|
|
189
|
+
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path><path d="M8 9h8"></path><path d="M8 13h5"></path></svg>
|
|
190
|
+
</button>
|
|
154
191
|
<button class="icon-btn icon-btn-sm" id="pin" title="Keep open when I click elsewhere">
|
|
155
192
|
<svg viewBox="0 0 24 24"><path d="M12 17v5"></path><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"></path></svg>
|
|
156
193
|
</button>
|
|
@@ -158,6 +195,13 @@
|
|
|
158
195
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6h.09A1.65 1.65 0 0 0 10 3.09V3a2 2 0 1 1 4 0v.09A1.65 1.65 0 0 0 15 4.6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9v.09a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
|
159
196
|
</button>
|
|
160
197
|
</header>
|
|
198
|
+
<div class="onair" id="onair" hidden>
|
|
199
|
+
<span class="bars" aria-hidden="true"><i></i><i></i><i></i></span>
|
|
200
|
+
<span class="label" id="onair-label"></span>
|
|
201
|
+
<button data-do="hush" title="Stop this announcement (the Slack message stays)">
|
|
202
|
+
<svg viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14"></rect></svg>Stop
|
|
203
|
+
</button>
|
|
204
|
+
</div>
|
|
161
205
|
<main id="body"></main>
|
|
162
206
|
<div class="flash" id="flash" hidden></div>
|
|
163
207
|
<footer id="actions"></footer>
|
|
@@ -168,9 +212,15 @@
|
|
|
168
212
|
const plural = (n, one, many) => n + " " + (n === 1 ? one : many);
|
|
169
213
|
const send = (body) => window.ipc.postMessage(JSON.stringify(body));
|
|
170
214
|
|
|
171
|
-
let latest = { agents: [], questions: [], working: [] };
|
|
215
|
+
let latest = { agents: [], questions: [], working: [], log: [] };
|
|
172
216
|
let settingsOpen = false;
|
|
173
217
|
let advancedOpen = false;
|
|
218
|
+
// The log view, which agent it is narrowed to (null: everyone), and which entries are opened out.
|
|
219
|
+
let logOpen = false;
|
|
220
|
+
let logFilter = null;
|
|
221
|
+
// Stop was pressed: hide the strip at once rather than waiting for the daemon to say it stopped.
|
|
222
|
+
let hushedAt = 0;
|
|
223
|
+
const openEntries = new Set();
|
|
174
224
|
|
|
175
225
|
function ago(ms) {
|
|
176
226
|
const s = Math.round((ms ?? 0) / 1000);
|
|
@@ -197,7 +247,7 @@
|
|
|
197
247
|
if (a.queued) bits.push(plural(a.queued, "message", "messages") + " unread");
|
|
198
248
|
if (a.owes) bits.push(plural(a.owes, "answer", "answers") + " to collect");
|
|
199
249
|
return (
|
|
200
|
-
'<div class="row"><div class="top">' +
|
|
250
|
+
'<div class="row is-agent" data-agent="' + esc(a.nickname) + '" title="Show what ' + esc(a.nickname) + ' said"><div class="top">' +
|
|
201
251
|
'<span class="status-dot"' + colour + "></span>" +
|
|
202
252
|
'<span class="who">' + esc(a.nickname) + "</span>" +
|
|
203
253
|
'<span class="state">' + esc(a.state) + "</span>" +
|
|
@@ -217,6 +267,70 @@
|
|
|
217
267
|
);
|
|
218
268
|
}
|
|
219
269
|
|
|
270
|
+
// ---------------------------------------------------------------- log
|
|
271
|
+
|
|
272
|
+
const KIND_LABEL = { done: "done", blocked: "blocked", input: "needs you", milestone: "milestone", error: "error", info: "info", question: "question", answer: "answer", message: "message", cancelled: "cancelled", expired: "expired" };
|
|
273
|
+
|
|
274
|
+
function clock(iso) {
|
|
275
|
+
const d = new Date(iso);
|
|
276
|
+
return isFinite(d) ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function dayOf(iso) {
|
|
280
|
+
const d = new Date(iso);
|
|
281
|
+
if (!isFinite(d)) return "";
|
|
282
|
+
const today = new Date();
|
|
283
|
+
const yesterday = new Date(today.getTime() - 864e5);
|
|
284
|
+
if (d.toDateString() === today.toDateString()) return "Today";
|
|
285
|
+
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
|
286
|
+
return d.toLocaleDateString([], { weekday: "long", day: "numeric", month: "short" });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function entryRow(e, i) {
|
|
290
|
+
const name = (a) => '<span class="name">' + esc(a ? (a.swatch ? a.swatch + " " : "") + a.nickname : "an agent") + "</span>";
|
|
291
|
+
const who = e.from === "user"
|
|
292
|
+
? '<span class="name">you</span>' + (e.agent ? '<span class="arrow">→</span>' + name(e.agent) : "")
|
|
293
|
+
: name(e.agent) + '<span class="arrow">→</span><span class="name">you</span>';
|
|
294
|
+
const key = e.at + "|" + i;
|
|
295
|
+
const edge = e.agent && e.agent.colour ? ' style="--edge:' + esc(e.agent.colour) + '"' : "";
|
|
296
|
+
const detail = e.kind === "answer" && e.detail ? "to: " + e.detail : e.detail;
|
|
297
|
+
return (
|
|
298
|
+
'<div class="entry' + (e.from === "user" ? " from-user" : "") + (openEntries.has(key) ? " is-open" : "") + '" data-entry="' + esc(key) + '"' + edge + ">" +
|
|
299
|
+
'<div class="top">' + who +
|
|
300
|
+
'<span class="kind k-' + esc(e.kind) + '">' + esc(KIND_LABEL[e.kind] || e.kind) + "</span>" +
|
|
301
|
+
'<span class="age" title="' + esc(new Date(e.at).toLocaleString()) + '">' + clock(e.at) + "</span></div>" +
|
|
302
|
+
(e.text ? '<div class="text">' + esc(e.text) + "</div>" : "") +
|
|
303
|
+
(detail ? '<div class="detail">' + esc(detail) + "</div>" : "") +
|
|
304
|
+
"</div>"
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function logView(log) {
|
|
309
|
+
const names = [...new Set(log.map((e) => e.agent && e.agent.nickname).filter(Boolean))];
|
|
310
|
+
const shown = logFilter ? log.filter((e) => e.agent && e.agent.nickname === logFilter) : log;
|
|
311
|
+
const parts = [];
|
|
312
|
+
if (names.length > 1 || logFilter) {
|
|
313
|
+
parts.push('<div class="filters">' +
|
|
314
|
+
'<button class="chip' + (logFilter ? "" : " is-active") + '" data-filter="">Everyone</button>' +
|
|
315
|
+
names.map((n) => '<button class="chip' + (logFilter === n ? " is-active" : "") + '" data-filter="' + esc(n) + '">' + esc(n) + "</button>").join("") +
|
|
316
|
+
"</div>");
|
|
317
|
+
}
|
|
318
|
+
if (!shown.length) {
|
|
319
|
+
parts.push('<p class="empty">' + (logFilter ? "Nothing from " + esc(logFilter) + " yet." : "Nothing said yet. Notifications, questions, your answers and your messages show up here.") + "</p>");
|
|
320
|
+
return parts.join("");
|
|
321
|
+
}
|
|
322
|
+
let day = "";
|
|
323
|
+
shown.forEach((e, i) => {
|
|
324
|
+
const d = dayOf(e.at);
|
|
325
|
+
if (d !== day) {
|
|
326
|
+
day = d;
|
|
327
|
+
parts.push('<p class="day">' + esc(d) + "</p>");
|
|
328
|
+
}
|
|
329
|
+
parts.push(entryRow(e, i));
|
|
330
|
+
});
|
|
331
|
+
return parts.join("");
|
|
332
|
+
}
|
|
333
|
+
|
|
220
334
|
// ---------------------------------------------------------------- settings
|
|
221
335
|
|
|
222
336
|
const setting = (label, control, hint) =>
|
|
@@ -363,6 +477,8 @@
|
|
|
363
477
|
|
|
364
478
|
document.getElementById("count").textContent = settingsOpen
|
|
365
479
|
? "settings"
|
|
480
|
+
: logOpen
|
|
481
|
+
? "log" + (logFilter ? " · " + logFilter : "")
|
|
366
482
|
: agents.length
|
|
367
483
|
? plural(agents.length, "agent", "agents") + (questions.length ? " · " + plural(questions.length, "question", "questions") : "")
|
|
368
484
|
: "nothing running";
|
|
@@ -371,12 +487,27 @@
|
|
|
371
487
|
badge.hidden = !snoozed && !quiet;
|
|
372
488
|
badge.textContent = snoozed ? "snoozed" : "quiet hours";
|
|
373
489
|
document.getElementById("gear").classList.toggle("is-active", settingsOpen);
|
|
490
|
+
document.getElementById("log").classList.toggle("is-active", logOpen && !settingsOpen);
|
|
491
|
+
|
|
492
|
+
// Who is speaking, and the Stop button, only while something is.
|
|
493
|
+
const speaking = state.speaking;
|
|
494
|
+
const onair = document.getElementById("onair");
|
|
495
|
+
onair.hidden = !speaking || hushedAt > Date.now() - 2500;
|
|
496
|
+
if (speaking) {
|
|
497
|
+
const who = speaking.who;
|
|
498
|
+
const agent = agents.find((a) => a.nickname === who);
|
|
499
|
+
document.getElementById("onair-label").innerHTML = who && agent
|
|
500
|
+
? "<strong>" + esc((agent.swatch ? agent.swatch + " " : "") + who) + "</strong> is speaking"
|
|
501
|
+
: who ? "Playing a " + esc(who) : "Speaking";
|
|
502
|
+
}
|
|
374
503
|
|
|
375
504
|
const parts = [];
|
|
376
505
|
// The tray says so when the file it reads has stopped being kept up to date.
|
|
377
506
|
if (state.stale) parts.push('<p class="stale">Daemon not running — state may be old.</p>');
|
|
378
507
|
if (settingsOpen) {
|
|
379
508
|
parts.push(settingsView(state.settings, state.options || {}));
|
|
509
|
+
} else if (logOpen) {
|
|
510
|
+
parts.push(logView(state.log ?? []));
|
|
380
511
|
} else if (!agents.length && !questions.length && !working.length) {
|
|
381
512
|
parts.push('<p class="empty">Nothing is running.</p>');
|
|
382
513
|
} else {
|
|
@@ -455,10 +586,40 @@
|
|
|
455
586
|
|
|
456
587
|
document.getElementById("gear").addEventListener("click", () => {
|
|
457
588
|
settingsOpen = !settingsOpen;
|
|
589
|
+
if (settingsOpen) logOpen = false;
|
|
590
|
+
draw();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
document.getElementById("log").addEventListener("click", () => {
|
|
594
|
+
logOpen = settingsOpen ? true : !logOpen;
|
|
595
|
+
settingsOpen = false;
|
|
596
|
+
logFilter = null;
|
|
597
|
+
document.getElementById("body").scrollTop = 0;
|
|
458
598
|
draw();
|
|
459
599
|
});
|
|
460
600
|
|
|
461
601
|
document.addEventListener("click", (e) => {
|
|
602
|
+
// An agent's row opens its log; a filter narrows the log; an entry opens out to its full text.
|
|
603
|
+
const agent = e.target.closest("[data-agent]");
|
|
604
|
+
if (agent) {
|
|
605
|
+
logOpen = true;
|
|
606
|
+
logFilter = agent.dataset.agent;
|
|
607
|
+
document.getElementById("body").scrollTop = 0;
|
|
608
|
+
return draw();
|
|
609
|
+
}
|
|
610
|
+
const filter = e.target.closest("[data-filter]");
|
|
611
|
+
if (filter) {
|
|
612
|
+
logFilter = filter.dataset.filter || null;
|
|
613
|
+
return draw();
|
|
614
|
+
}
|
|
615
|
+
const entry = e.target.closest("[data-entry]");
|
|
616
|
+
if (entry) {
|
|
617
|
+
const key = entry.dataset.entry;
|
|
618
|
+
if (openEntries.has(key)) openEntries.delete(key);
|
|
619
|
+
else openEntries.add(key);
|
|
620
|
+
entry.classList.toggle("is-open");
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
462
623
|
const action = e.target.closest("[data-do]");
|
|
463
624
|
if (action) {
|
|
464
625
|
e.preventDefault();
|
|
@@ -466,6 +627,10 @@
|
|
|
466
627
|
action.disabled = true;
|
|
467
628
|
action.textContent = "Speaking…";
|
|
468
629
|
}
|
|
630
|
+
if (action.dataset.do === "hush") {
|
|
631
|
+
hushedAt = Date.now();
|
|
632
|
+
document.getElementById("onair").hidden = true;
|
|
633
|
+
}
|
|
469
634
|
return send({ do: action.dataset.do, value: action.dataset.value });
|
|
470
635
|
}
|
|
471
636
|
const chip = e.target.closest("[data-chip]");
|