vibeaudio 0.19.0 β 0.20.1
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 +56 -4
- package/package.json +1 -1
- package/src/card.js +129 -0
- package/src/cli.js +153 -32
- package/src/history.js +46 -1
- package/src/hooks.js +57 -2
- package/src/interactive.js +94 -9
- package/src/speech.js +260 -0
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# π§ VibeAudio
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<img src="docs/og.png?v=
|
|
4
|
+
<img src="docs/og.png?v=3" alt="VibeAudio β Focus music while your AI codes" width="720">
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
[](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml) [](https://www.npmjs.com/package/vibeaudio) [](LICENSE) [](https://nodejs.org) [](https://npm-stat.com/charts.html?package=vibeaudio) [](https://m8ven.ai/mcp/kiril6/vibeaudio?s=readme)
|
|
8
8
|
|
|
9
|
+
<a href="https://www.producthunt.com/products/vibeaudio/reviews/new?utm_source=badge-product_review&utm_medium=badge&utm_source=badge-vibeaudio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/product_review.svg?product_id=1317092&theme=light" alt="VibeAudio - Focus music while your AI codes. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
|
10
|
+
|
|
9
11
|
> **Procedural focus music while your AI coding tools think.**
|
|
10
12
|
> Every project gets its own arrangement. Zero dependencies, zero audio files.
|
|
11
13
|
> Works with Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code, Windsurf, Antigravity, Aider β and any terminal command.
|
|
@@ -147,7 +149,7 @@ Want the music to follow what the agent is doing β calm while it reads, busies
|
|
|
147
149
|
vibe --reactive --install-hooks
|
|
148
150
|
```
|
|
149
151
|
|
|
150
|
-
Prefer a guided setup? Run `vibe` with nothing after it: a menu asks for your agent, genre and volume (press `p` to hear a genre first) and installs the hooks for you. Installing hooks doesn't start the agent: open it whenever you like, and the music plays from your first prompt. ("Just launch it" and "This session only" do start it.)
|
|
152
|
+
Prefer a guided setup? Run `vibe` with nothing after it: a menu asks for your agent, genre and volume (press `p` to hear a genre first) and installs the hooks for you. The menu's last entry, **Alerts & extras**, flips the saved toggles (banners, spoken announcements, `vibe --announce`, music, a one-hour mute) without a flag to remember. Installing hooks doesn't start the agent: open it whenever you like, and the music plays from your first prompt. ("Just launch it" and "This session only" do start it.)
|
|
151
153
|
|
|
152
154
|
Now run your agent normally, with no prefix. Music starts when you submit a prompt and stops with a chime when the agent finishes. [Details below.](#-agent-hooks-no-wrapper-needed)
|
|
153
155
|
|
|
@@ -314,7 +316,8 @@ Every session of an agent is tracked separately, by the session id in its hook p
|
|
|
314
316
|
* **The music plays while any session is working.** A second prompt doesn't restart it, and it stops only when the last working session finishes.
|
|
315
317
|
* **Every session gets its own chime.** A quick question that finishes while another agent is still busy chimes "done" and the music carries on underneath.
|
|
316
318
|
|
|
317
|
-
* **Which one?** With three terminals going, a chime says something finished and leaves you alt-tabbing to find out what. `vibe --notify` adds a desktop banner
|
|
319
|
+
* **Which one?** With three terminals going, a chime says something finished and leaves you alt-tabbing to find out what. `vibe --notify` adds a desktop banner (`api: finished`, `web: needs you (Bash)`) and [`vibe --speak`](#-spoken-announcements-vibe---speak) adds a spoken line. Both are off by default. [Banner details below.](#-desktop-banners-vibe---notify)
|
|
320
|
+
* **Say it out loud.** `vibe --speak` adds a spoken line after the chime (*"api: done."*) when two or more sessions are going. [Details below.](#-spoken-announcements-vibe---speak)
|
|
318
321
|
|
|
319
322
|
<details>
|
|
320
323
|
<summary><b>The rest of how sessions share one stream</b></summary>
|
|
@@ -333,6 +336,44 @@ Sessions with no id in their payload share a single slot, so they behave as one.
|
|
|
333
336
|
|
|
334
337
|
> **One player is shared.** Prompt two agents at once and the last prompt owns the music. One person, one set of speakers β deliberate, not a limitation being worked around.
|
|
335
338
|
|
|
339
|
+
### π Desktop banners: `vibe --notify`
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
vibe --notify # on
|
|
343
|
+
vibe --no-notify # off again (the default)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
A banner names the project when a turn **finishes**, **fails**, **needs you** (a permission dialog or a question) or **looks stuck**: `api: finished`, `web: needs you (Bash)`. It is saved, so it applies from your next prompt with nothing to reinstall. `vibe --status` shows it on the `notify` line, the **Alerts & extras** screen in the `vibe` menu toggles it, and `VIBE_NOTIFY=1` turns it on for one shell.
|
|
347
|
+
|
|
348
|
+
- **Platforms.** macOS through `osascript`, Linux through `notify-send` (it needs a notification daemon, which most desktops have). **Windows has no banner yet**; `vibe --doctor` says so when `--notify` is on and nothing can show one. The macOS command runs without error, but how the banner looks on screen has not been checked, and the Linux path has not been run.
|
|
349
|
+
- **Off by default**, because a banner is more intrusive than a sound.
|
|
350
|
+
- **Safe by construction.** The project name reaches the notifier as an argument, never as part of a script, because a directory name is whatever its owner called it.
|
|
351
|
+
- **Mute.** `--mute` and `VIBE_DISABLE` silence banners with everything else.
|
|
352
|
+
- **Scripts.** With it on, `vibe --announce "deploy finished"` shows its text as a banner too.
|
|
353
|
+
|
|
354
|
+
### π Spoken announcements: `vibe --speak`
|
|
355
|
+
|
|
356
|
+
A chime says that something finished, not which project. With `--speak`, the system voice adds one short sentence after the chime:
|
|
357
|
+
|
|
358
|
+
> "api: done." Β· "api: failed." Β· "web needs you: Bash." Β· "payments looks stuck."
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
vibe --speak # on: only when two or more sessions are going
|
|
362
|
+
vibe --speak-always # on: every time, even with one session
|
|
363
|
+
vibe --no-speak # off again (the default)
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
It is saved, so it applies from your next prompt with nothing to reinstall, and `vibe --status` shows the current setting on its `speak` line. The `vibe` menu has it too: **Alerts & extras**, then Enter on "Spoken announcements" steps through off, auto and always. `VIBE_SPEAK=off` turns it off for one shell.
|
|
367
|
+
|
|
368
|
+
**To hear it once:** `vibe --speak-always`, then run any agent turn (and `vibe --unmute` first if you muted).
|
|
369
|
+
|
|
370
|
+
- **Why only with two sessions by default.** The sentence answers "which one?". With a single session the chime already told you, so `--speak` stays quiet and `--speak-always` is the opt-in for every time.
|
|
371
|
+
- **What it says.** A project name and one of four fixed states. It never reads the agent's output, and it names a tool only if it is one of the built-in ones (`Bash`, `Edit`, ...), not an MCP tool whose name someone else chose. Names like `my-app_v2` are spoken as "my app v2".
|
|
372
|
+
- **Voices.** macOS `say`, Linux `spd-say` (or `espeak-ng` / `espeak`), Windows `System.Speech` through PowerShell. Nothing is installed by VibeAudio; with no voice present it stays silent, and `vibe --doctor` says so. Verified against the real macOS `say` and stub voices; **not run on Linux or Windows**.
|
|
373
|
+
- **Safety.** The text reaches the voice as an argument (an environment variable on Windows), never as part of a shell command or script, and embedded speech commands such as `[[volm 0]]` are stripped.
|
|
374
|
+
- **Several at once.** Sessions that finish together speak one after another, not over each other, and the same sentence twice within five seconds is said once. Not done: merging them into "api and web: done", and lowering the music while it speaks.
|
|
375
|
+
- **Muting.** `--mute` and `VIBE_DISABLE` silence it with everything else. `vibe --announce "deploy finished"` text is spoken as well when speech is on.
|
|
376
|
+
|
|
336
377
|
### As a plugin (Claude Code and Codex)
|
|
337
378
|
|
|
338
379
|
If Claude Code or Codex is the agent you use, the plugin installs the same hooks with no npm step. Inside a Claude Code session, type:
|
|
@@ -455,6 +496,8 @@ plus where the time went by project and, for windows up to two weeks, by day. "Y
|
|
|
455
496
|
|
|
456
497
|
</details>
|
|
457
498
|
|
|
499
|
+
**Share it:** `vibe --report --card` (or `--report 30 --card`) saves the same totals as a square image to `~/.vibeaudio/card.png` (`--out <path>` writes it elsewhere). It shows your time with agents, turns, typical and longest turn, a bar per day, the agents VibeAudio hooks into, and where to get it. It carries totals only β never a project name β and is written locally, never uploaded: attach it to a post yourself. The PNG needs macOS; elsewhere you get the SVG, which any browser opens. One file, replaced on each run.
|
|
500
|
+
|
|
458
501
|
### The chime is in the music's key
|
|
459
502
|
|
|
460
503
|
The success chime is the tonic chord of whatever key your genre sits in β C major for lofi, 8bit, jazz and piano, D minor for synthwave and electronic, D major for zen, A minor for drone β so it lands as the resolution of the piece rather than a bell over it. `rain`, `ocean` and `random` have no single key to match and keep the original C major chime.
|
|
@@ -601,10 +644,12 @@ vibe --announce "deploy" --outcome attention # the "needs you" chime
|
|
|
601
644
|
gh run watch; vibe --announce "CI finished" --exit-code $?
|
|
602
645
|
```
|
|
603
646
|
|
|
604
|
-
It plays the chime for the outcome, shows the `--notify` banner if you have
|
|
647
|
+
It plays the chime for the outcome, shows the `--notify` banner and says the text aloud if you have those on, and appends an `announced` event (`source: "cli"`, the text and the outcome) to `~/.vibeaudio/events.jsonl`, so a light or dashboard built on `--events` reacts too. It never starts or stops music and never touches an agent session. A mute silences the sound and banner but not the event, like every event.
|
|
605
648
|
|
|
606
649
|
It always exits 0, so it can't turn a pipeline red, and the text is only ever passed as an argument, never through a shell. Text is capped at 200 characters, and the same text announced again within five seconds is dropped, so a loop can't machine-gun your speakers. `--no-chime` keeps it to the banner and event.
|
|
607
650
|
|
|
651
|
+
To switch it off for good, for a script you can't edit: `vibe --announcements off` (or `VIBE_ANNOUNCE=off` for one shell, or the **Alerts & extras** screen in the `vibe` menu). It then does nothing and still exits 0, without even writing the event. A mute is different: it silences the sound but keeps the event.
|
|
652
|
+
|
|
608
653
|
## π₯οΈ Everything Else (Claude Desktop, VS Codeβ¦ via MCP)
|
|
609
654
|
|
|
610
655
|
**Claude Code, [Codex](https://github.com/openai/codex), [Cursor](https://cursor.com/docs/hooks), [Grok](https://docs.x.ai/build/features/hooks), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Copilot CLI](https://docs.github.com/en/copilot/reference/hooks-configuration), [Qwen Code](https://github.com/QwenLM/qwen-code) [Windsurf](https://docs.devin.ai/desktop/cascade/hooks) and [Antigravity](https://antigravity.google/docs/hooks) have hook systems, and `--install-hooks` writes to all nine** β use [hooks](#-agent-hooks-no-wrapper-needed) there, they're strictly better. This section is for everything else. MCP is the way in: it's plain stdio JSON-RPC, so the setup is identical everywhere and only the config file differs.
|
|
@@ -788,7 +833,10 @@ The full order, highest first: **a flag** β **an environment variable** β **
|
|
|
788
833
|
| `--status` | Show what's installed, running and detected, then exit | β |
|
|
789
834
|
| `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | β |
|
|
790
835
|
| `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
|
|
836
|
+
| `--speak` / `--speak-always` / `--no-speak` | Say which project finished, failed or needs you, after the chime: only with two or more sessions going (`--speak`), or every time (`--speak-always`). Saved to `config.json` | off |
|
|
837
|
+
| `--announcements <on\|off>` | Turn `vibe --announce` on or off for good. Saved to `config.json` | on |
|
|
791
838
|
| `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
|
|
839
|
+
| `--card [--out <path>]` | With `--report`: the totals as a shareable image - PNG on macOS, SVG anywhere - saved to `~/.vibeaudio/card.png`, replaced each run | off |
|
|
792
840
|
| `--statusline` | The same state as one plain line for tmux or a shell prompt (`working Γ2`, `waiting: api`); prints nothing when idle | β |
|
|
793
841
|
| `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | β |
|
|
794
842
|
| `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | β |
|
|
@@ -820,6 +868,8 @@ export VIBE_SEED=7 # Same arrangement everywhere, ignoring the dire
|
|
|
820
868
|
export VIBE_DISABLE=1 # Mute, without uninstalling anything
|
|
821
869
|
export VIBE_MUSIC=off # Signals only: chimes and banners, no music, for this shell
|
|
822
870
|
export VIBE_NOTIFY=1 # Desktop banner naming the project, for this shell
|
|
871
|
+
export VIBE_SPEAK=auto # Spoken announcements (auto, always or off), for this shell
|
|
872
|
+
export VIBE_ANNOUNCE=off # Make vibe --announce do nothing, for this shell
|
|
823
873
|
export VIBE_NO_HISTORY=1 # Do not log finished turns (what --report reads)
|
|
824
874
|
export VIBE_NO_UPDATE_CHECK=1 # Never ask npm whether a newer version is out
|
|
825
875
|
export VIBE_NO_FADE=1 # macOS: play with plain afplay, no fade in/out
|
|
@@ -995,3 +1045,5 @@ Support development: <a href="https://ko-fi.com/K3K2X0ERJ"><img src="https://ko-
|
|
|
995
1045
|
|
|
996
1046
|
## π License
|
|
997
1047
|
MIT Β© 2026
|
|
1048
|
+
|
|
1049
|
+
VibeAudio is not affiliated with or endorsed by Claude Code, Codex, Cursor, Grok, Gemini CLI, GitHub Copilot CLI, Qwen Code, Windsurf, Antigravity or any other tool it works with. Their names are used only to say what it works with.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think β a different arrangement per project.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vibeaudio": "bin/vibeaudio.js",
|
package/src/card.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vibe --report --card`: the report as a picture you can post. The SVG is
|
|
3
|
+
* built as a string (pure, no I/O, so a test can read it); macOS also gets a
|
|
4
|
+
* PNG through Quick Look, since every other platform would need a rasterizer
|
|
5
|
+
* and this package has no dependencies.
|
|
6
|
+
*
|
|
7
|
+
* It carries totals only, never a project name or path: a card is made to be
|
|
8
|
+
* shared, and a project is the user's to name. It is written locally and
|
|
9
|
+
* sent nowhere.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const os = require("os");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const { spawnSync } = require("child_process");
|
|
16
|
+
const { formatDuration, summarize, dayTotals } = require("./history");
|
|
17
|
+
const { TARGETS } = require("./hooks");
|
|
18
|
+
|
|
19
|
+
const CARD_DIR = path.join(os.homedir(), ".vibeaudio");
|
|
20
|
+
// Square, because Quick Look scales a thumbnail to fit its height and crops the rest.
|
|
21
|
+
const SIZE = 1080;
|
|
22
|
+
const MAX_BARS = 14;
|
|
23
|
+
|
|
24
|
+
/** The card's SVG for a non-empty list of turns over the last `days` days. */
|
|
25
|
+
function renderCard(turns, days, now = Date.now()) {
|
|
26
|
+
const s = summarize(turns);
|
|
27
|
+
const rows = dayTotals(turns, Math.min(days, MAX_BARS), now);
|
|
28
|
+
const peak = Math.max(...rows.map((r) => r.ms)) || 1;
|
|
29
|
+
const period = days === 1 ? "today" : `last ${days} days`;
|
|
30
|
+
|
|
31
|
+
const slot = 920 / rows.length;
|
|
32
|
+
const barW = Math.min(46, slot * 0.6);
|
|
33
|
+
const bars = rows.map((r, i) => {
|
|
34
|
+
const h = r.ms ? Math.max(10, Math.round((r.ms / peak) * 160)) : 4;
|
|
35
|
+
const x = Math.round(80 + i * slot + (slot - barW) / 2);
|
|
36
|
+
return `<rect x="${x}" y="${780 - h}" width="${barW}" height="${h}" rx="${Math.min(barW / 2, 12)}" fill="${r.ms ? "url(#bar)" : "#3c2a5e"}"/>`;
|
|
37
|
+
}).join("") + rows.map((r, i) =>
|
|
38
|
+
`<text x="${Math.round(80 + i * slot + slot / 2)}" y="806" class="d" text-anchor="middle">${r.date.toLocaleDateString("en-US", { weekday: "short" })}</text>`).join("");
|
|
39
|
+
|
|
40
|
+
// Pills, not the agents' logos: those are their owners' marks, and a pill
|
|
41
|
+
// from TARGETS cannot fall out of step with what we actually hook into.
|
|
42
|
+
let cx = 80;
|
|
43
|
+
let cy = 864;
|
|
44
|
+
const pills = Object.values(TARGETS).map(({ name }) => {
|
|
45
|
+
const w = Math.round(name.length * 12 + 34);
|
|
46
|
+
if (cx + w > 1000) { cx = 80; cy += 50; }
|
|
47
|
+
const out = `<rect x="${cx}" y="${cy}" width="${w}" height="38" rx="10" fill="#120b1f" stroke="#3c2a5e" stroke-width="2"/><text x="${cx + w / 2}" y="${cy + 26}" class="c" text-anchor="middle">${name}</text>`;
|
|
48
|
+
cx += w + 12;
|
|
49
|
+
return out;
|
|
50
|
+
}).join("");
|
|
51
|
+
|
|
52
|
+
const stat = (x, value, label) =>
|
|
53
|
+
`<text x="${x}" y="552" class="v">${value}</text><text x="${x}" y="586" class="l">${label}</text>`;
|
|
54
|
+
|
|
55
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SIZE} ${SIZE}" width="${SIZE}" height="${SIZE}">
|
|
56
|
+
<defs>
|
|
57
|
+
<linearGradient id="hero" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#ff2e97"/><stop offset="1" stop-color="#ffb454"/></linearGradient>
|
|
58
|
+
<linearGradient id="bar" x1="0" y1="1" x2="0" y2="0"><stop offset="0" stop-color="#a855f7"/><stop offset="0.55" stop-color="#ff2e97"/><stop offset="1" stop-color="#ffb454"/></linearGradient>
|
|
59
|
+
<radialGradient id="gp" cx="0.08" cy="0.04" r="0.65"><stop offset="0" stop-color="#ff2e97" stop-opacity="0.24"/><stop offset="1" stop-color="#ff2e97" stop-opacity="0"/></radialGradient>
|
|
60
|
+
<radialGradient id="gc" cx="0.98" cy="0.06" r="0.55"><stop offset="0" stop-color="#00e5ff" stop-opacity="0.16"/><stop offset="1" stop-color="#00e5ff" stop-opacity="0"/></radialGradient>
|
|
61
|
+
<radialGradient id="gv" cx="0.5" cy="1.02" r="0.7"><stop offset="0" stop-color="#a855f7" stop-opacity="0.16"/><stop offset="1" stop-color="#a855f7" stop-opacity="0"/></radialGradient>
|
|
62
|
+
<clipPath id="card"><rect width="${SIZE}" height="${SIZE}" rx="48"/></clipPath>
|
|
63
|
+
<filter id="glow" x="-20%" y="-40%" width="140%" height="180%"><feGaussianBlur stdDeviation="7" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
|
64
|
+
<style>text{font-family:ui-monospace,'SF Mono',Menlo,Monaco,monospace;fill:#f2ecff}.t{font-size:60px;font-weight:800}.h{font-size:84px;font-weight:800}.v{font-size:44px;font-weight:700}.l{font-size:21px;fill:#b3a3d6}.s{font-size:26px;fill:#b3a3d6}.m{font-size:30px;font-weight:800;letter-spacing:2px}.f{font-size:20px;fill:#9584be}.c{font-size:20px}.u{font-size:23px;font-weight:700;fill:#ff2e97}.n{font-size:14px;fill:#8272ad}.d{font-size:16px;fill:#9584be}.i{font-size:20px;font-weight:700;fill:#00e5ff}</style>
|
|
65
|
+
</defs>
|
|
66
|
+
<g clip-path="url(#card)"><rect width="${SIZE}" height="${SIZE}" fill="#07050d"/><rect width="${SIZE}" height="${SIZE}" fill="url(#gp)"/><rect width="${SIZE}" height="${SIZE}" fill="url(#gc)"/><rect width="${SIZE}" height="${SIZE}" fill="url(#gv)"/></g>
|
|
67
|
+
<rect x="3" y="3" width="${SIZE - 6}" height="${SIZE - 6}" rx="46" fill="none" stroke="#3c2a5e" stroke-width="3"/>
|
|
68
|
+
<g transform="translate(80 78) scale(0.17) translate(-60 -100)"><path d="M120 300V272a136 136 0 0 1 272 0V300" fill="none" stroke="url(#bar)" stroke-width="30" stroke-linecap="round"/><rect x="86" y="286" width="76" height="136" rx="32" fill="url(#bar)"/><rect x="350" y="286" width="76" height="136" rx="32" fill="url(#bar)"/><g fill="#00e5ff"><rect x="212" y="326" width="22" height="56" rx="11"/><rect x="245" y="296" width="22" height="116" rx="11"/><rect x="278" y="318" width="22" height="72" rx="11"/></g></g>
|
|
69
|
+
<text x="164" y="126" class="m">VIBE<tspan fill="#ff2e97" style="fill:#ff2e97" filter="url(#glow)">AUDIO</tspan></text>
|
|
70
|
+
<text x="1000" y="124" class="u" text-anchor="end">kiril6.github.io/vibeaudio</text>
|
|
71
|
+
<text x="80" y="224" class="t" filter="url(#glow)">Focus music</text>
|
|
72
|
+
<text x="80" y="298" class="t" filter="url(#glow)">while your <tspan style="fill:#00e5ff">AI</tspan> codes.</text>
|
|
73
|
+
<text x="80" y="420" class="h" style="fill:url(#hero)">${formatDuration(s.waited)}</text>
|
|
74
|
+
<text x="80" y="462" class="s">with my <tspan style="fill:#00e5ff" font-weight="800">AI</tspan> agents working Β· ${period}</text>
|
|
75
|
+
${stat(80, String(s.count), s.count === 1 ? "turn" : "turns")}${stat(360, formatDuration(s.median), "typical turn")}${stat(680, formatDuration(s.longest.ms), "longest turn")}
|
|
76
|
+
${bars}
|
|
77
|
+
${rows.length < days ? `<text x="1000" y="842" class="f" text-anchor="end">bars: last ${rows.length} days</text>` : ""}
|
|
78
|
+
<text x="80" y="842" class="f">works as a hook for</text>
|
|
79
|
+
${pills}
|
|
80
|
+
<rect x="80" y="972" width="578" height="52" rx="10" fill="#0a1a22" stroke="#00e5ff" stroke-opacity="0.45" stroke-width="2"/>
|
|
81
|
+
<text x="100" y="1005" class="i">npm i -g vibeaudio && vibe --install-hooks</text>
|
|
82
|
+
<text x="1000" y="1000" class="n" text-anchor="end">Not affiliated with or endorsed</text>
|
|
83
|
+
<text x="1000" y="1020" class="n" text-anchor="end">by the tools named.</text>
|
|
84
|
+
</svg>
|
|
85
|
+
`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Write `file` by renaming a temp file into place, so an interrupted run leaves no torn card. */
|
|
89
|
+
function writeAtomic(file, data) {
|
|
90
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
91
|
+
fs.writeFileSync(tmp, data);
|
|
92
|
+
fs.renameSync(tmp, file);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Writes the card as one fixed file, replaced on every run, so nothing
|
|
97
|
+
* accumulates. `out` is a user-chosen path (kept as given); without it the card
|
|
98
|
+
* lives in ~/.vibeaudio. Returns the SVG path and the PNG path (null off macOS
|
|
99
|
+
* or when Quick Look could not render).
|
|
100
|
+
*/
|
|
101
|
+
function writeCard(turns, days, { out = null, now = Date.now(), dir = CARD_DIR } = {}) {
|
|
102
|
+
const base = out ? path.resolve(out.replace(/\.(svg|png)$/i, "")) : path.join(dir, "card");
|
|
103
|
+
fs.mkdirSync(path.dirname(base), { recursive: true });
|
|
104
|
+
const svgPath = `${base}.svg`;
|
|
105
|
+
writeAtomic(svgPath, renderCard(turns, days, now));
|
|
106
|
+
|
|
107
|
+
let pngPath = null;
|
|
108
|
+
if (process.platform === "darwin") {
|
|
109
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "vibe-card-"));
|
|
110
|
+
try {
|
|
111
|
+
// SIGKILL as well as a timeout: a hung system helper ignores SIGTERM.
|
|
112
|
+
const r = spawnSync("qlmanage", ["-t", "-s", String(SIZE), "-o", scratch, svgPath], { stdio: "ignore", timeout: 15000, killSignal: "SIGKILL" });
|
|
113
|
+
const rendered = path.join(scratch, `${path.basename(svgPath)}.png`);
|
|
114
|
+
if (r.status === 0 && fs.existsSync(rendered)) {
|
|
115
|
+
// Copy, not rename: the scratch dir and `--out` may be on different volumes.
|
|
116
|
+
const png = `${base}.png`;
|
|
117
|
+
writeAtomic(png, fs.readFileSync(rendered));
|
|
118
|
+
pngPath = png;
|
|
119
|
+
}
|
|
120
|
+
} catch (e) {
|
|
121
|
+
pngPath = null;
|
|
122
|
+
} finally {
|
|
123
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { svgPath, pngPath };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { CARD_DIR, renderCard, writeCard };
|
package/src/cli.js
CHANGED
|
@@ -61,7 +61,9 @@ Procedural focus music while your AI coding tools think.
|
|
|
61
61
|
vibe --preview jazz
|
|
62
62
|
gh run watch; vibe --announce "CI finished" --exit-code $? \x1b[90m# the chime for a CI run or a script\x1b[0m
|
|
63
63
|
vibe --render \x1b[90m# save this repo's sound as a .wav to share\x1b[0m
|
|
64
|
-
vibe
|
|
64
|
+
vibe --notify \x1b[90m# a desktop banner naming the project when a turn finishes or needs you (macOS, Linux)\x1b[0m
|
|
65
|
+
vibe --speak \x1b[90m# a voice names the project after the chime (--no-speak turns it off)\x1b[0m
|
|
66
|
+
vibe \x1b[90m# menu: pick a tool and a sound (p auditions a genre); "Alerts & extras" flips banners, speech, announce, music, mute\x1b[0m
|
|
65
67
|
|
|
66
68
|
\x1b[1mOPTIONS:\x1b[0m
|
|
67
69
|
-g, --genre <name> Select genre: lofi (default), synthwave, 8bit, electronic, jazz, zen, piano, drone, rain, ocean, random
|
|
@@ -79,10 +81,14 @@ Procedural focus music while your AI coding tools think.
|
|
|
79
81
|
--preview <genre> Play one loop of a genre and exit
|
|
80
82
|
--render [file] Write this project's music to a .wav and exit (full scale, ignores --volume)
|
|
81
83
|
--status Show what is installed, running and detected, then exit
|
|
82
|
-
--notify | --no-notify Also show a desktop banner naming the project when a turn finishes
|
|
84
|
+
--notify | --no-notify Also show a desktop banner naming the project when a turn finishes, fails, needs you or looks stuck (macOS and Linux; off by default)
|
|
85
|
+
--speak | --no-speak Also say which project finished, failed or needs you, after the chime - with two or more sessions going (off by default)
|
|
86
|
+
--speak-always Like --speak, but every time, even with one session
|
|
87
|
+
--announcements <on|off> Turn vibe --announce on or off for good (a saved setting; on by default)
|
|
83
88
|
--report [days] How long you waited on agents, and where (default: 7 days)
|
|
89
|
+
--card [--out <path>] With --report: save the totals as an image (PNG on macOS, SVG anywhere) to ~/.vibeaudio/card.*, replaced each run
|
|
84
90
|
--state Print what every agent is doing as JSON: idle, working, stuck or waiting
|
|
85
|
-
--announce <text> Play the signals for something that is not an agent (CI, a deploy, a script), then exit
|
|
91
|
+
--announce <text> Play the signals for something that is not an agent (CI, a deploy, a script), then exit (spoken too when --speak is on)
|
|
86
92
|
--outcome <o> With --announce: success (default), failure or attention
|
|
87
93
|
--exit-code <n> With --announce: 0 is success, anything else failure (use $?)
|
|
88
94
|
--dashboard Serve a read-only live view of every agent on this machine, and open it
|
|
@@ -139,6 +145,8 @@ Procedural focus music while your AI coding tools think.
|
|
|
139
145
|
VIBE_GRACE_MS=<ms> Override the saved grace window, in ms
|
|
140
146
|
VIBE_MUSIC=<on|off> Override the saved music setting for this shell
|
|
141
147
|
VIBE_NOTIFY=<on|off> Override the saved desktop-banner setting for this shell
|
|
148
|
+
VIBE_SPEAK=<auto|always|off> Override the saved spoken-announcement setting for this shell
|
|
149
|
+
VIBE_ANNOUNCE=<on|off> Turn vibe --announce off for this shell
|
|
142
150
|
VIBE_SEED=<n> Pin the arrangement instead of deriving it from the directory
|
|
143
151
|
VIBE_DISABLE=1 Mute automatic playback without uninstalling anything
|
|
144
152
|
VIBE_NO_UPDATE_CHECK=1 Never check npm for a newer version
|
|
@@ -176,13 +184,15 @@ const VALUE_FLAGS = new Set([
|
|
|
176
184
|
"-cv", "--chime-volume",
|
|
177
185
|
"--grace",
|
|
178
186
|
"--music",
|
|
187
|
+
"--announcements",
|
|
179
188
|
"--seed",
|
|
180
189
|
"--tools",
|
|
181
190
|
"--event",
|
|
182
191
|
"--announce",
|
|
183
192
|
"--outcome",
|
|
184
193
|
"--exit-code",
|
|
185
|
-
"--port"
|
|
194
|
+
"--port",
|
|
195
|
+
"--out"
|
|
186
196
|
]);
|
|
187
197
|
|
|
188
198
|
function parseArgs(argv) {
|
|
@@ -227,7 +237,11 @@ function parseArgs(argv) {
|
|
|
227
237
|
let eventsFlag = false;
|
|
228
238
|
let doctorFlag = false;
|
|
229
239
|
let notifyFlag = null;
|
|
240
|
+
let speakFlag = null;
|
|
241
|
+
let announcementsFlag = null;
|
|
230
242
|
let reportDays = null;
|
|
243
|
+
let cardFlag = false;
|
|
244
|
+
let cardOut = null;
|
|
231
245
|
let stopFlag = false;
|
|
232
246
|
let muteFlag = null;
|
|
233
247
|
let muteMinutes = null;
|
|
@@ -422,12 +436,41 @@ function parseArgs(argv) {
|
|
|
422
436
|
continue;
|
|
423
437
|
}
|
|
424
438
|
|
|
439
|
+
if (arg === "--card") {
|
|
440
|
+
cardFlag = true;
|
|
441
|
+
i += 1;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (arg === "--out") {
|
|
446
|
+
cardOut = args[i + 1];
|
|
447
|
+
i += 2;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
425
451
|
if (arg === "--notify" || arg === "--no-notify") {
|
|
426
452
|
notifyFlag = arg === "--notify";
|
|
427
453
|
i += 1;
|
|
428
454
|
continue;
|
|
429
455
|
}
|
|
430
456
|
|
|
457
|
+
if (arg === "--speak" || arg === "--speak-always" || arg === "--no-speak") {
|
|
458
|
+
speakFlag = arg === "--speak" ? "auto" : arg === "--speak-always" ? "always" : "off";
|
|
459
|
+
i += 1;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (arg === "--announcements") {
|
|
464
|
+
const value = args[i + 1].toLowerCase();
|
|
465
|
+
if (value !== "on" && value !== "off") {
|
|
466
|
+
console.error(`\x1b[31m[vibeaudio] --announcements takes on or off, not '${args[i + 1]}'.\x1b[0m`);
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
announcementsFlag = value === "on";
|
|
470
|
+
i += 2;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
431
474
|
if (arg === "--doctor") {
|
|
432
475
|
doctorFlag = true;
|
|
433
476
|
i += 1;
|
|
@@ -606,7 +649,11 @@ function parseArgs(argv) {
|
|
|
606
649
|
events: eventsFlag,
|
|
607
650
|
doctor: doctorFlag,
|
|
608
651
|
notify: notifyFlag,
|
|
652
|
+
speak: speakFlag,
|
|
653
|
+
announcements: announcementsFlag,
|
|
609
654
|
report: reportDays,
|
|
655
|
+
card: cardFlag,
|
|
656
|
+
cardOut,
|
|
610
657
|
stop: stopFlag,
|
|
611
658
|
mute: muteFlag,
|
|
612
659
|
muteMinutes,
|
|
@@ -853,6 +900,10 @@ function printStatus() {
|
|
|
853
900
|
console.log(` volume ${on(`${Math.round(volumeNow() * 100)}%`)}${source("VIBE_VOLUME", "volume")}`);
|
|
854
901
|
console.log(` ${off("change either with: vibe --genre <name> --volume <n>")}`);
|
|
855
902
|
console.log(` notify ${hooks.notifyEnabled() ? on("on") : off("off")}${off(" desktop banner naming the project β vibe --notify / --no-notify")}`);
|
|
903
|
+
const speech = require("./speech");
|
|
904
|
+
const speakNow = speech.speakMode(process.env, loadConfig());
|
|
905
|
+
console.log(` speak ${speakNow === "off" ? off("off") : on(speakNow)}${off(speakNow === "off" ? " a voice names the project after the chime β vibe --speak / --speak-always" : speakNow === "auto" ? " only with two or more sessions β vibe --speak-always / --no-speak" : " every time β vibe --no-speak")}${speakNow !== "off" && !speech.voiceAvailable() ? " \x1b[33mno voice found on this machine\x1b[0m" : ""}`);
|
|
906
|
+
console.log(` announce ${hooks.announceEnabled() ? on("on") : "\x1b[33moff\x1b[0m"}${off(hooks.announceEnabled() ? " vibe --announce <text> for scripts and CI β vibe --announcements off to disable" : " vibe --announce does nothing β vibe --announcements on")}`);
|
|
856
907
|
|
|
857
908
|
// Audio backend
|
|
858
909
|
const backend = detect();
|
|
@@ -1059,6 +1110,22 @@ function doctorChecks() {
|
|
|
1059
1110
|
if (playbackDisabled()) add("warn", "VIBE_DISABLE", `set to "${process.env.VIBE_DISABLE}" β automatic playback is off`, "unset VIBE_DISABLE");
|
|
1060
1111
|
if (mute === null && !playbackDisabled()) add("ok", "Mute", "not muted");
|
|
1061
1112
|
if (!musicEnabled()) add("ok", "Music", "off β chimes and banners only, by your setting (vibe --music on)");
|
|
1113
|
+
if (hooks.notifyEnabled()) {
|
|
1114
|
+
if (process.platform === "darwin") add("ok", "Desktop banners", "on, through osascript");
|
|
1115
|
+
else if (process.platform === "linux") {
|
|
1116
|
+
if (require("./interactive").isInstalled("notify-send")) add("ok", "Desktop banners", "on, through notify-send");
|
|
1117
|
+
else add("warn", "Desktop banners", "on, but notify-send was not found, so no banner appears", "install libnotify (notify-send) and a notification daemon");
|
|
1118
|
+
} else add("warn", "Desktop banners", "on, but this platform has no banner backend yet, so none appears", "vibe --no-notify");
|
|
1119
|
+
}
|
|
1120
|
+
{
|
|
1121
|
+
const speech = require("./speech");
|
|
1122
|
+
const mode = speech.speakMode(process.env, loadConfig());
|
|
1123
|
+
if (mode !== "off") {
|
|
1124
|
+
if (speech.voiceAvailable()) add("ok", "Spoken announcements", `${mode} β a voice is available`);
|
|
1125
|
+
else add("warn", "Spoken announcements", `${mode}, but no voice was found, so nothing is said`,
|
|
1126
|
+
process.platform === "linux" ? "install spd-say, espeak-ng or espeak" : "this system has no speech command on PATH");
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1062
1129
|
|
|
1063
1130
|
const pid = readDaemonPid(hooks.PID_FILE);
|
|
1064
1131
|
if (pid === null) add("ok", "Background player", "not running");
|
|
@@ -1076,15 +1143,6 @@ function doctorChecks() {
|
|
|
1076
1143
|
return checks;
|
|
1077
1144
|
}
|
|
1078
1145
|
|
|
1079
|
-
/** 95 -> "1m 35s", 4_000_000 -> "1h 06m". */
|
|
1080
|
-
function formatDuration(ms) {
|
|
1081
|
-
const s = Math.round(ms / 1000);
|
|
1082
|
-
if (s < 60) return `${s}s`;
|
|
1083
|
-
const m = Math.floor(s / 60);
|
|
1084
|
-
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
1085
|
-
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
1146
|
/**
|
|
1089
1147
|
* The summary of ~/.vibeaudio/history.jsonl. "You waited" is the agent's own
|
|
1090
1148
|
* working time - a turn's length minus the stretches it spent blocked on a
|
|
@@ -1094,7 +1152,7 @@ function formatDuration(ms) {
|
|
|
1094
1152
|
* over RETURN_WINDOW_MS read as breaks and left out.
|
|
1095
1153
|
*/
|
|
1096
1154
|
function printReport(days, now = Date.now()) {
|
|
1097
|
-
const { readTurns, returnTimes } = require("./history");
|
|
1155
|
+
const { readTurns, returnTimes, formatDuration, working, summarize, dayTotals } = require("./history");
|
|
1098
1156
|
const turns = readTurns(days, now);
|
|
1099
1157
|
const dim = (s) => `\x1b[90m${s}\x1b[0m`;
|
|
1100
1158
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
@@ -1108,14 +1166,7 @@ function printReport(days, now = Date.now()) {
|
|
|
1108
1166
|
return;
|
|
1109
1167
|
}
|
|
1110
1168
|
|
|
1111
|
-
const
|
|
1112
|
-
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
|
|
1113
|
-
const sorted = turns.map((t) => t.ms).sort((a, b) => a - b);
|
|
1114
|
-
const median = sorted[Math.floor(sorted.length / 2)];
|
|
1115
|
-
const longest = turns.reduce((a, b) => (b.ms > a.ms ? b : a));
|
|
1116
|
-
const failed = turns.filter((t) => t.outcome === "failure").length;
|
|
1117
|
-
const waitedOnAgents = sum(turns.map(working));
|
|
1118
|
-
const blocked = sum(turns.map((t) => t.blockedMs || 0));
|
|
1169
|
+
const { failed, waited: waitedOnAgents, blocked, median, longest } = summarize(turns);
|
|
1119
1170
|
|
|
1120
1171
|
// Two projects called `api` under different parents stay apart.
|
|
1121
1172
|
const names = new Map();
|
|
@@ -1162,15 +1213,9 @@ function printReport(days, now = Date.now()) {
|
|
|
1162
1213
|
|
|
1163
1214
|
if (days <= 14) {
|
|
1164
1215
|
console.log(`\n${bold("By day")}`);
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
byDay.set(key, (byDay.get(key) || 0) + working(t));
|
|
1169
|
-
}
|
|
1170
|
-
const maxDay = Math.max(...byDay.values()) || 1;
|
|
1171
|
-
for (let d = days - 1; d >= 0; d--) {
|
|
1172
|
-
const date = new Date(now - d * 86400000);
|
|
1173
|
-
const ms = byDay.get(date.toLocaleDateString("en-CA")) || 0;
|
|
1216
|
+
const rows = dayTotals(turns, days, now);
|
|
1217
|
+
const maxDay = Math.max(...rows.map((r) => r.ms)) || 1;
|
|
1218
|
+
for (const { date, ms } of rows) {
|
|
1174
1219
|
const name = `${date.toLocaleDateString("en-US", { weekday: "short" })} ${date.getDate()}`;
|
|
1175
1220
|
console.log(` ${name.padEnd(8)}${(ms ? formatDuration(ms) : "β").padStart(8)} ${"β".repeat(ms ? Math.max(1, Math.round((ms / maxDay) * 14)) : 0)}`);
|
|
1176
1221
|
}
|
|
@@ -1178,6 +1223,31 @@ function printReport(days, now = Date.now()) {
|
|
|
1178
1223
|
console.log();
|
|
1179
1224
|
}
|
|
1180
1225
|
|
|
1226
|
+
/** `--report --card`: the report as an image, written locally and never uploaded. */
|
|
1227
|
+
function printCard(days, out) {
|
|
1228
|
+
const { readTurns } = require("./history");
|
|
1229
|
+
const turns = readTurns(days, Date.now());
|
|
1230
|
+
if (!turns.length) {
|
|
1231
|
+
console.log(`\n No turns recorded in the last ${days === 1 ? "day" : `${days} days`}, so there is nothing to draw yet.`);
|
|
1232
|
+
console.log(`\x1b[90m Turns are logged when your agent's hooks fire (vibe --install-hooks).\x1b[0m\n`);
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
const { writeCard } = require("./card");
|
|
1236
|
+
let written;
|
|
1237
|
+
try {
|
|
1238
|
+
written = writeCard(turns, days, { out });
|
|
1239
|
+
} catch (e) {
|
|
1240
|
+
console.error(`\x1b[31m[vibeaudio] Could not write the card: ${e.message}\x1b[0m`);
|
|
1241
|
+
process.exit(1);
|
|
1242
|
+
}
|
|
1243
|
+
const { formatDuration, summarize } = require("./history");
|
|
1244
|
+
const shown = written.pngPath || written.svgPath;
|
|
1245
|
+
console.log(`\n Card saved: \x1b[1m${shown}\x1b[0m`);
|
|
1246
|
+
if (!written.pngPath) console.log(`\x1b[90m PNG needs macOS; the SVG opens in any browser.\x1b[0m`);
|
|
1247
|
+
console.log(`\x1b[90m Totals only - no project names. It stays on this machine; attach it to a post yourself.\x1b[0m`);
|
|
1248
|
+
console.log(`\n ${formatDuration(summarize(turns).waited)} with my AI agents working, with focus music from VibeAudio: https://kiril6.github.io/vibeaudio (npm i -g vibeaudio && vibe --install-hooks)\n`);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1181
1251
|
function printDoctor() {
|
|
1182
1252
|
const mark = { ok: "\x1b[32mβ\x1b[0m", warn: "\x1b[33m!\x1b[0m", fail: "\x1b[31mβ\x1b[0m" };
|
|
1183
1253
|
const checks = doctorChecks();
|
|
@@ -1718,7 +1788,9 @@ function applyMenuSelection(selection, volume) {
|
|
|
1718
1788
|
`\n\x1b[32mβ VibeAudio v${pkg.version} is hooked into: ${names} (${saved.genre} @ ${saved.volume}%).\x1b[0m ` +
|
|
1719
1789
|
`Nothing was started: open ${hooked.length > 1 ? "any of them" : "it"} whenever you like (\x1b[1m${cmds}\x1b[0m), and the music plays from your first prompt.\n` +
|
|
1720
1790
|
` \x1b[90mMore agents? Run vibe again for each, or vibe --install-hooks to set up every one it finds.\x1b[0m\n` +
|
|
1721
|
-
` \x1b[90mStay current: run npm i -g ${pkg.name} now and then (hooks keep working); vibe --status tells you when a newer version is out.\x1b[0m\n`
|
|
1791
|
+
` \x1b[90mStay current: run npm i -g ${pkg.name} now and then (hooks keep working); vibe --status tells you when a newer version is out.\x1b[0m\n` +
|
|
1792
|
+
` \x1b[90mOptional: vibe --speak adds a spoken line naming the project after the chime (vibe --no-speak turns it off).\x1b[0m\n` +
|
|
1793
|
+
` \x1b[90mAll commands and options: vibe --help\x1b[0m\n`
|
|
1722
1794
|
);
|
|
1723
1795
|
printUpdateNotice();
|
|
1724
1796
|
return false;
|
|
@@ -1742,7 +1814,11 @@ async function run() {
|
|
|
1742
1814
|
events: followEvents,
|
|
1743
1815
|
doctor: showDoctor,
|
|
1744
1816
|
notify: notifyChange,
|
|
1817
|
+
speak: speakChange,
|
|
1818
|
+
announcements: announcementsChange,
|
|
1745
1819
|
report: reportDays,
|
|
1820
|
+
card: cardFlag,
|
|
1821
|
+
cardOut,
|
|
1746
1822
|
stop: shouldStop,
|
|
1747
1823
|
mute: muteChange,
|
|
1748
1824
|
muteMinutes,
|
|
@@ -1779,6 +1855,24 @@ async function run() {
|
|
|
1779
1855
|
}
|
|
1780
1856
|
}
|
|
1781
1857
|
|
|
1858
|
+
// These act on their own and then exit. With a command after them the command
|
|
1859
|
+
// used to be dropped without a word and the exit code was 0, so
|
|
1860
|
+
// `vibe --notify npm test && deploy` deployed without running the tests.
|
|
1861
|
+
{
|
|
1862
|
+
const own = muteChange !== null ? (muteChange ? "--mute" : "--unmute")
|
|
1863
|
+
: notifyChange !== null ? (notifyChange ? "--notify" : "--no-notify")
|
|
1864
|
+
: speakChange !== null ? (speakChange === "off" ? "--no-speak" : speakChange === "always" ? "--speak-always" : "--speak")
|
|
1865
|
+
: announcementsChange !== null ? "--announcements"
|
|
1866
|
+
: announceReq.text !== null ? "--announce"
|
|
1867
|
+
: cardFlag ? "--card"
|
|
1868
|
+
: null;
|
|
1869
|
+
if (own && cmdArgs.length > 0) {
|
|
1870
|
+
console.error(`\x1b[31m[vibeaudio] ${own} does not wrap a command, so '${cmdArgs.join(" ")}' was not run.\x1b[0m ` +
|
|
1871
|
+
(own === "--announce" ? `Put it after the command instead: ${cmdArgs.join(" ")}; vibe --announce "..." --exit-code $?` : `Run ${own} on its own, then the command.`));
|
|
1872
|
+
process.exit(1);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1782
1876
|
if (announceReq.text === null && (announceReq.outcome !== null || announceReq.exitCode !== null)) {
|
|
1783
1877
|
console.error("\x1b[31m[vibeaudio] --outcome and --exit-code go with --announce <text>.\x1b[0m");
|
|
1784
1878
|
process.exit(1);
|
|
@@ -1836,6 +1930,25 @@ async function run() {
|
|
|
1836
1930
|
return;
|
|
1837
1931
|
}
|
|
1838
1932
|
|
|
1933
|
+
if (speakChange !== null) {
|
|
1934
|
+
saveConfig({ speak: speakChange });
|
|
1935
|
+
console.log(speakChange === "off"
|
|
1936
|
+
? "\x1b[90mSpoken announcements off.\x1b[0m"
|
|
1937
|
+
: `\x1b[32mβ Spoken announcements on${speakChange === "always" ? " (every time)" : " (when two or more sessions are going)"}.\x1b[0m After the chime, a voice says which project finished, failed or needs you.`);
|
|
1938
|
+
if (speakChange !== "off" && !require("./speech").voiceAvailable()) {
|
|
1939
|
+
console.log(" \x1b[33mNo voice found on this machine\x1b[0m, so nothing will be said. Linux needs spd-say, espeak-ng or espeak.");
|
|
1940
|
+
}
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
if (announcementsChange !== null) {
|
|
1945
|
+
saveConfig({ announce: announcementsChange });
|
|
1946
|
+
console.log(announcementsChange
|
|
1947
|
+
? "\x1b[32mβ vibe --announce is on.\x1b[0m"
|
|
1948
|
+
: "\x1b[90mvibe --announce is off: it now does nothing, and exits 0.\x1b[0m");
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1839
1952
|
if (shouldStop) {
|
|
1840
1953
|
const hooks = require("./hooks");
|
|
1841
1954
|
const stopped = hooks.stopDaemon();
|
|
@@ -1890,6 +2003,12 @@ async function run() {
|
|
|
1890
2003
|
return;
|
|
1891
2004
|
}
|
|
1892
2005
|
|
|
2006
|
+
if (cardOut !== null && !cardFlag) {
|
|
2007
|
+
console.error("\x1b[31m[vibeaudio] --out goes with --card.\x1b[0m");
|
|
2008
|
+
process.exit(1);
|
|
2009
|
+
}
|
|
2010
|
+
if (cardFlag) return printCard(reportDays ?? 7, cardOut);
|
|
2011
|
+
|
|
1893
2012
|
if (reportDays !== null) {
|
|
1894
2013
|
return printReport(reportDays);
|
|
1895
2014
|
}
|
|
@@ -1939,6 +2058,8 @@ async function run() {
|
|
|
1939
2058
|
process.exit(0);
|
|
1940
2059
|
}
|
|
1941
2060
|
|
|
2061
|
+
if (selection.settings) return; // the toggles were saved on the way out of that screen
|
|
2062
|
+
|
|
1942
2063
|
const chosenVol = selection.volume !== undefined ? selection.volume : volume;
|
|
1943
2064
|
if (!applyMenuSelection(selection, chosenVol)) return;
|
|
1944
2065
|
|
package/src/history.js
CHANGED
|
@@ -85,4 +85,49 @@ function returnTimes(turns) {
|
|
|
85
85
|
return gaps;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
/** 95 -> "1m 35s", 4_000_000 -> "1h 06m". */
|
|
89
|
+
function formatDuration(ms) {
|
|
90
|
+
const s = Math.round(ms / 1000);
|
|
91
|
+
if (s < 60) return `${s}s`;
|
|
92
|
+
const m = Math.floor(s / 60);
|
|
93
|
+
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
94
|
+
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A turn's working time: its length minus the stretches it spent blocked on you. */
|
|
98
|
+
const working = (t) => t.ms - (t.blockedMs || 0);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The headline numbers of a non-empty list of turns. `--report` and
|
|
102
|
+
* `--report --card` both read these, so the card can never disagree with the
|
|
103
|
+
* text it is a picture of.
|
|
104
|
+
*/
|
|
105
|
+
function summarize(turns) {
|
|
106
|
+
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
|
|
107
|
+
const sorted = turns.map((t) => t.ms).sort((a, b) => a - b);
|
|
108
|
+
return {
|
|
109
|
+
count: turns.length,
|
|
110
|
+
failed: turns.filter((t) => t.outcome === "failure").length,
|
|
111
|
+
waited: sum(turns.map(working)),
|
|
112
|
+
blocked: sum(turns.map((t) => t.blockedMs || 0)),
|
|
113
|
+
median: sorted[Math.floor(sorted.length / 2)],
|
|
114
|
+
longest: turns.reduce((a, b) => (b.ms > a.ms ? b : a))
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Working time per calendar day for the last `days` days, oldest first. */
|
|
119
|
+
function dayTotals(turns, days, now = Date.now()) {
|
|
120
|
+
const byDay = new Map();
|
|
121
|
+
for (const t of turns) {
|
|
122
|
+
const key = new Date(t.at).toLocaleDateString("en-CA");
|
|
123
|
+
byDay.set(key, (byDay.get(key) || 0) + working(t));
|
|
124
|
+
}
|
|
125
|
+
const out = [];
|
|
126
|
+
for (let d = days - 1; d >= 0; d--) {
|
|
127
|
+
const date = new Date(now - d * 86400000);
|
|
128
|
+
out.push({ date, ms: byDay.get(date.toLocaleDateString("en-CA")) || 0 });
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { HISTORY_FILE, RETURN_WINDOW_MS, recordTurn, readTurns, returnTimes, formatDuration, working, summarize, dayTotals };
|
package/src/hooks.js
CHANGED
|
@@ -17,6 +17,7 @@ const path = require("path");
|
|
|
17
17
|
const { spawn, execFileSync } = require("child_process");
|
|
18
18
|
const { AudioPlayer, loadConfig, playbackDisabled } = require("./player");
|
|
19
19
|
const { recordTurn } = require("./history");
|
|
20
|
+
const speech = require("./speech");
|
|
20
21
|
|
|
21
22
|
const STATE_DIR = path.join(os.homedir(), ".vibeaudio");
|
|
22
23
|
const PID_FILE = path.join(STATE_DIR, "daemon.pid");
|
|
@@ -1159,6 +1160,37 @@ function notify(raw, message) {
|
|
|
1159
1160
|
return banner(`${sessionLabel(parsePayload(raw))}: ${message}`);
|
|
1160
1161
|
}
|
|
1161
1162
|
|
|
1163
|
+
/**
|
|
1164
|
+
* `--announce` can be switched off for good (menu, `--announcements off`,
|
|
1165
|
+
* `VIBE_ANNOUNCE=off`) for a script that cannot be edited. On unless said
|
|
1166
|
+
* otherwise; env beats the saved setting.
|
|
1167
|
+
*/
|
|
1168
|
+
function announceEnabled(env = process.env, config = loadConfig()) {
|
|
1169
|
+
const raw = String(env.VIBE_ANNOUNCE ?? config.announce ?? "").trim().toLowerCase();
|
|
1170
|
+
return !["0", "false", "off", "no"].includes(raw);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* The spoken line for a hook event (#33), after its chime. `sessionCount` is
|
|
1175
|
+
* how many sessions are in flight, this one included: in `auto` mode it takes
|
|
1176
|
+
* two to be worth saying, since the point is "which one?". `chimed` says a
|
|
1177
|
+
* chime was started detached, so the sentence waits for it; one that already
|
|
1178
|
+
* played to the end (the Stop hook plays it inline) needs no wait. Only a
|
|
1179
|
+
* built-in tool is named: an MCP tool's name is third-party text.
|
|
1180
|
+
*/
|
|
1181
|
+
function speakEvent(raw, kind, sessionCount, { chimed = false, volume = 0.4, tool = "" } = {}) {
|
|
1182
|
+
try {
|
|
1183
|
+
if (!speech.shouldSpeak(speech.speakMode(process.env, loadConfig()), sessionCount)) return false;
|
|
1184
|
+
const named = tool && Object.hasOwn(TOOL_TIERS, tool) ? tool : "";
|
|
1185
|
+
return speech.speak(speech.phrase(kind, sessionLabel(parsePayload(raw)), named), {
|
|
1186
|
+
delayMs: speech.chimeDelayMs(kind, chimed),
|
|
1187
|
+
volume
|
|
1188
|
+
});
|
|
1189
|
+
} catch (e) {
|
|
1190
|
+
return false; // speech must never fail the hook
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1162
1194
|
const ANNOUNCE_FILE = path.join(STATE_DIR, "announce.json");
|
|
1163
1195
|
const ANNOUNCE_MAX_CHARS = 200;
|
|
1164
1196
|
const ANNOUNCE_REPEAT_MS = 5000;
|
|
@@ -1180,6 +1212,7 @@ const ANNOUNCE_OUTCOMES = ["success", "failure", "attention"];
|
|
|
1180
1212
|
*/
|
|
1181
1213
|
function announce(text, { outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, genre } = {}) {
|
|
1182
1214
|
try {
|
|
1215
|
+
if (!announceEnabled()) return false; // switched off: inert, event included
|
|
1183
1216
|
const clean = String(text).replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, ANNOUNCE_MAX_CHARS);
|
|
1184
1217
|
const now = Date.now();
|
|
1185
1218
|
try {
|
|
@@ -1193,6 +1226,11 @@ function announce(text, { outcome = "success", volume = 0.4, chimeVolume = null,
|
|
|
1193
1226
|
banner(`${clean}${outcome === "failure" ? " (failed)" : outcome === "attention" ? " (needs you)" : ""}`);
|
|
1194
1227
|
// Detached: a pipeline should not wait out the chime.
|
|
1195
1228
|
if (!noChime) new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume, genre, detach: true });
|
|
1229
|
+
// The caller's own text, read after the chime. No session count to wait on:
|
|
1230
|
+
// a script has no "which one", so `auto` and `always` both speak it.
|
|
1231
|
+
if (speech.speakMode(process.env, loadConfig()) !== "off") {
|
|
1232
|
+
speech.speak(clean, { delayMs: speech.chimeDelayMs(outcome, !noChime), volume: chimeVolume !== null ? chimeVolume : volume });
|
|
1233
|
+
}
|
|
1196
1234
|
return true;
|
|
1197
1235
|
} catch (e) {
|
|
1198
1236
|
return true; // a notifier must never fail the script that called it
|
|
@@ -1201,6 +1239,7 @@ function announce(text, { outcome = "success", volume = 0.4, chimeVolume = null,
|
|
|
1201
1239
|
|
|
1202
1240
|
function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, genre, raw = "" } = {}) {
|
|
1203
1241
|
const id = sessionId(payloadSession(parsePayload(raw)));
|
|
1242
|
+
const active = listSessions().length; // this session included, until its file goes
|
|
1204
1243
|
// A turn that ends while paused for the user - a denied tool that nothing
|
|
1205
1244
|
// resumed after - still finished, so its session counts either way.
|
|
1206
1245
|
const session = readSession(id);
|
|
@@ -1223,10 +1262,16 @@ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChi
|
|
|
1223
1262
|
if (!others) fs.rmSync(INTENSITY_FILE, { force: true });
|
|
1224
1263
|
if (!(tracked || wasPlaying)) return false;
|
|
1225
1264
|
notify(raw, outcome === "failure" ? "failed" : "finished");
|
|
1226
|
-
|
|
1265
|
+
const speakVolume = chimeVolume !== null ? chimeVolume : volume;
|
|
1266
|
+
if (noChime) {
|
|
1267
|
+
speakEvent(raw, outcome === "failure" ? "failure" : "success", active, { volume: speakVolume });
|
|
1268
|
+
return false;
|
|
1269
|
+
}
|
|
1227
1270
|
|
|
1228
|
-
// Chime plays in this short-lived hook process
|
|
1271
|
+
// Chime plays in this short-lived hook process, to the end; the sentence
|
|
1272
|
+
// starts after it, so it needs no delay of its own.
|
|
1229
1273
|
new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume, genre });
|
|
1274
|
+
speakEvent(raw, outcome === "failure" ? "failure" : "success", active, { volume: speakVolume });
|
|
1230
1275
|
return true;
|
|
1231
1276
|
}
|
|
1232
1277
|
|
|
@@ -1278,6 +1323,11 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
1278
1323
|
const tool = payloadToolName(raw);
|
|
1279
1324
|
notify(raw, tool ? `needs you (${tool})` : "needs you");
|
|
1280
1325
|
if (!noChime) new AudioPlayer().stop({ playChime: true, outcome: "attention", volume, chimeVolume, detach: true });
|
|
1326
|
+
speakEvent(raw, "attention", listSessions().length, {
|
|
1327
|
+
chimed: !noChime && !playbackDisabled(),
|
|
1328
|
+
volume: chimeVolume !== null ? chimeVolume : volume,
|
|
1329
|
+
tool
|
|
1330
|
+
});
|
|
1281
1331
|
return true;
|
|
1282
1332
|
}
|
|
1283
1333
|
|
|
@@ -1314,6 +1364,10 @@ function hookResume(raw, genre, volume, { reactive = false, follow = false, musi
|
|
|
1314
1364
|
// loop to put one under, so the pulse plays once on its own. Detached: the
|
|
1315
1365
|
// agent waits on this hook.
|
|
1316
1366
|
if (!music && !noChime) new AudioPlayer().stop({ playChime: true, outcome: "stuck", volume, chimeVolume, detach: true });
|
|
1367
|
+
speakEvent(raw, "stuck", listSessions().length, {
|
|
1368
|
+
chimed: !music && !noChime && !playbackDisabled(),
|
|
1369
|
+
volume: chimeVolume !== null ? chimeVolume : volume
|
|
1370
|
+
});
|
|
1317
1371
|
}
|
|
1318
1372
|
|
|
1319
1373
|
// An empty key is a wait that named nothing (a Notification): the next tool
|
|
@@ -1671,6 +1725,7 @@ module.exports = {
|
|
|
1671
1725
|
qwenHome,
|
|
1672
1726
|
grokHome,
|
|
1673
1727
|
announce,
|
|
1728
|
+
announceEnabled,
|
|
1674
1729
|
recentEvents,
|
|
1675
1730
|
emitEvent,
|
|
1676
1731
|
ANNOUNCE_OUTCOMES,
|
package/src/interactive.js
CHANGED
|
@@ -3,15 +3,36 @@
|
|
|
3
3
|
* Zero dependencies - Pure Node.js ANSI and Readline
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
7
8
|
const readline = require("readline");
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Is `cmd` an executable on PATH? Read off the filesystem, not by asking
|
|
12
|
+
* `which`/`where`: a lookup is a process spawn, and on CI runners that spawn
|
|
13
|
+
* has hung and missed its timeout (#58), where a directory scan cannot. It
|
|
14
|
+
* also needs no `which` to exist, and nothing in `cmd` reaches a shell.
|
|
15
|
+
* A name with a separator is a path, as `which` treats it.
|
|
16
|
+
*/
|
|
17
|
+
function isInstalled(cmd, env = process.env) {
|
|
18
|
+
if (typeof cmd !== "string" || !cmd) return false;
|
|
19
|
+
const win = process.platform === "win32";
|
|
20
|
+
const exts = win ? String(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
|
|
21
|
+
const dirs = /[\\/]/.test(cmd) ? [""] : String(env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
|
|
22
|
+
const hasExt = win && exts.some((e) => cmd.toLowerCase().endsWith(e.toLowerCase()));
|
|
23
|
+
for (const dir of dirs) {
|
|
24
|
+
for (const ext of hasExt ? ["", ...exts] : exts) {
|
|
25
|
+
const file = path.join(dir, cmd + ext);
|
|
26
|
+
try {
|
|
27
|
+
if (!fs.statSync(file).isFile()) continue;
|
|
28
|
+
if (!win) fs.accessSync(file, fs.constants.X_OK);
|
|
29
|
+
return true;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
// Not here, or not executable: keep looking.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
15
36
|
}
|
|
16
37
|
|
|
17
38
|
// Convenience only - the wrapper runs any command, and "Custom command..."
|
|
@@ -35,7 +56,8 @@ const AI_TOOLS = [
|
|
|
35
56
|
{ name: "Antigravity CLI", cmd: ["agy"], check: "agy", hookTarget: "antigravity" },
|
|
36
57
|
{ name: "Aider", cmd: ["aider"], check: "aider" },
|
|
37
58
|
{ name: "Ollama (Llama 3)", cmd: ["ollama", "run", "llama3"], check: "ollama" },
|
|
38
|
-
{ name: "Custom command...", cmd: null }
|
|
59
|
+
{ name: "Custom command...", cmd: null },
|
|
60
|
+
{ name: "βοΈ Alerts & extras...", cmd: null, settings: true }
|
|
39
61
|
];
|
|
40
62
|
|
|
41
63
|
const GENRES = [
|
|
@@ -294,6 +316,64 @@ const REACTIVE = [
|
|
|
294
316
|
{ id: true, name: "Reactive", desc: "Intensity follows the tool in use - noticeable, by design" }
|
|
295
317
|
];
|
|
296
318
|
|
|
319
|
+
const NEXT_SPEAK = { off: "auto", auto: "always", always: "off" };
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The saved toggles that have no flag a person would think to look for:
|
|
323
|
+
* banners, the spoken line, `vibe --announce`, music and the mute. One screen,
|
|
324
|
+
* not a question per setting in the setup flow, which runs every time a tool
|
|
325
|
+
* is picked. Enter flips the highlighted one and the list comes back with its
|
|
326
|
+
* new value; every change is saved at once and lands on the next prompt, like
|
|
327
|
+
* `vibe --genre`. A variable set in this shell outranks the saved value, so the
|
|
328
|
+
* row says so rather than letting the toggle look dead.
|
|
329
|
+
*/
|
|
330
|
+
async function promptSettings() {
|
|
331
|
+
const { loadConfig, saveConfig, muteState, setMuted, musicEnabled, DEFAULT_MUTE_MINUTES } = require("./player");
|
|
332
|
+
const hooks = require("./hooks");
|
|
333
|
+
const speech = require("./speech");
|
|
334
|
+
|
|
335
|
+
for (;;) {
|
|
336
|
+
const config = loadConfig();
|
|
337
|
+
const pinned = (name) => (process.env[name] !== undefined ? ` (${name} is set in this shell and wins)` : "");
|
|
338
|
+
const speak = speech.speakMode(process.env, config);
|
|
339
|
+
const items = [
|
|
340
|
+
{ id: "notify", name: "Desktop banners", value: hooks.notifyEnabled(process.env, config) ? "on" : "off",
|
|
341
|
+
desc: `name the project when a turn finishes or needs you${pinned("VIBE_NOTIFY")}` },
|
|
342
|
+
{ id: "speak", name: "Spoken announcements", value: speak,
|
|
343
|
+
desc: `a voice names the project after the chime: off, auto (two or more sessions) or always${pinned("VIBE_SPEAK")}${speak !== "off" && !speech.voiceAvailable() ? " - no voice found on this machine" : ""}` },
|
|
344
|
+
{ id: "announce", name: "vibe --announce", value: hooks.announceEnabled(process.env, config) ? "on" : "off",
|
|
345
|
+
desc: `the chime for CI runs and scripts${pinned("VIBE_ANNOUNCE")}` },
|
|
346
|
+
{ id: "music", name: "Music", value: musicEnabled() ? "on" : "off",
|
|
347
|
+
desc: `off keeps the chimes, banners and events, and drops the music${pinned("VIBE_MUSIC")}` },
|
|
348
|
+
{ id: "mute", name: "Mute", value: muteState() !== null ? "on" : "off",
|
|
349
|
+
desc: `silence everything for ${DEFAULT_MUTE_MINUTES} minutes (vibe --unmute ends it)` },
|
|
350
|
+
{ id: "done", name: "Done", value: "", desc: "" }
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
const chosen = await selectMenu(
|
|
354
|
+
"Alerts & extras \x1b[0m\x1b[90m(Enter flips the highlighted one)\x1b[0m",
|
|
355
|
+
items,
|
|
356
|
+
(item, num) => item.id === "done"
|
|
357
|
+
? `${num}. ${item.name}`
|
|
358
|
+
: `${num}. ${item.name}: ${item.value === "off" ? "\x1b[33moff\x1b[0m" : `\x1b[32m${item.value}\x1b[0m`} \x1b[90mβ ${item.desc}\x1b[0m`
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
if (chosen.id === "done") break;
|
|
362
|
+
if (chosen.id === "notify") saveConfig({ notify: chosen.value !== "on" });
|
|
363
|
+
else if (chosen.id === "speak") saveConfig({ speak: NEXT_SPEAK[chosen.value] });
|
|
364
|
+
else if (chosen.id === "announce") saveConfig({ announce: chosen.value !== "on" });
|
|
365
|
+
else if (chosen.id === "music") saveConfig({ music: chosen.value !== "on" });
|
|
366
|
+
else if (chosen.id === "mute") {
|
|
367
|
+
if (chosen.value === "on") setMuted(false);
|
|
368
|
+
else {
|
|
369
|
+
setMuted(true, DEFAULT_MUTE_MINUTES);
|
|
370
|
+
hooks.stopDaemon({ keepSessions: true }); // silence what is playing now, not just the next prompt
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
console.log("\n\x1b[32mβ Saved.\x1b[0m Changes apply from your next prompt. Nothing was started.\n");
|
|
375
|
+
}
|
|
376
|
+
|
|
297
377
|
/**
|
|
298
378
|
* `hooksInstalledFor` is asked about the tool the user picked, not about
|
|
299
379
|
* Claude: the caller cannot know which tool that is until this menu runs, and
|
|
@@ -323,6 +403,11 @@ async function promptInteractive({ hooksInstalledFor = () => false } = {}) {
|
|
|
323
403
|
}
|
|
324
404
|
);
|
|
325
405
|
|
|
406
|
+
if (selectedTool.settings) {
|
|
407
|
+
await promptSettings();
|
|
408
|
+
return { settings: true };
|
|
409
|
+
}
|
|
410
|
+
|
|
326
411
|
let finalCmd = selectedTool.cmd;
|
|
327
412
|
if (!finalCmd) {
|
|
328
413
|
finalCmd = await promptCustomCommand();
|
|
@@ -387,4 +472,4 @@ async function promptInteractive({ hooksInstalledFor = () => false } = {}) {
|
|
|
387
472
|
};
|
|
388
473
|
}
|
|
389
474
|
|
|
390
|
-
module.exports = { promptInteractive, tokenizeCommand, isInstalled, hookTargetOf, playPreview, stopPreview, AI_TOOLS };
|
|
475
|
+
module.exports = { promptInteractive, promptSettings, tokenizeCommand, isInstalled, hookTargetOf, playPreview, stopPreview, AI_TOOLS };
|
package/src/speech.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spoken announcements (#33): the sentence after the chime that says *which*
|
|
3
|
+
* project finished, failed or needs you. A chime says that something happened
|
|
4
|
+
* and leaves you alt-tabbing to find out what.
|
|
5
|
+
*
|
|
6
|
+
* Opt-in (`--speak`, `speak` in config.json, `VIBE_SPEAK`). Three modes:
|
|
7
|
+
* `auto` speaks only when two or more sessions are going - the one case where
|
|
8
|
+
* the chime cannot answer "which one?"; with a single session the sentence
|
|
9
|
+
* would repeat what the chime already said. `always` speaks every time.
|
|
10
|
+
*
|
|
11
|
+
* The voice is the operating system's own (`say`, `spd-say` / `espeak`,
|
|
12
|
+
* `System.Speech`), so nothing is installed and nothing leaves the machine.
|
|
13
|
+
* The words are fixed: a project name and a state, never the agent's output.
|
|
14
|
+
* The project name is user-controlled text, so it travels as argv (or an
|
|
15
|
+
* environment variable on Windows), never spliced into a shell or a script.
|
|
16
|
+
*
|
|
17
|
+
* Speech runs in a detached process of its own (`node speech.js --run ...`):
|
|
18
|
+
* the agent waits on the hook, and the sentence has to start after the chime,
|
|
19
|
+
* not on top of it. That process takes a lock for the length of the sentence,
|
|
20
|
+
* so sessions that finish together speak one after another, not over each
|
|
21
|
+
* other. Merging them into one sentence is not done.
|
|
22
|
+
*/
|
|
23
|
+
const fs = require("fs");
|
|
24
|
+
const os = require("os");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
const { spawn } = require("child_process");
|
|
27
|
+
|
|
28
|
+
const STATE_DIR = path.join(os.homedir(), ".vibeaudio");
|
|
29
|
+
const LOCK_FILE = path.join(STATE_DIR, "speech.lock");
|
|
30
|
+
const LAST_FILE = path.join(STATE_DIR, "speech.json");
|
|
31
|
+
|
|
32
|
+
const SPEECH_MAX_CHARS = 200;
|
|
33
|
+
const REPEAT_MS = 5000; // the same sentence inside this window is dropped
|
|
34
|
+
const LOCK_STALE_MS = 30000; // a holder this old died without releasing
|
|
35
|
+
const LOCK_WAIT_MS = 20000; // a queue this long is not worth joining
|
|
36
|
+
const SPEAK_TIMEOUT_MS = 15000; // a voice that hangs is killed
|
|
37
|
+
|
|
38
|
+
// How long each chime plays, plus a breath: the sentence starts after it.
|
|
39
|
+
const CHIME_MS = { success: 1600, failure: 1800, attention: 1300, stuck: 1800 };
|
|
40
|
+
const CHIME_GAP_MS = 150;
|
|
41
|
+
|
|
42
|
+
const MODES = ["off", "auto", "always"];
|
|
43
|
+
|
|
44
|
+
function parseMode(value) {
|
|
45
|
+
const raw = String(value ?? "").trim().toLowerCase();
|
|
46
|
+
if (raw === "always") return "always";
|
|
47
|
+
if (["1", "true", "on", "yes", "auto"].includes(raw)) return "auto";
|
|
48
|
+
return "off";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Env beats the saved setting, like every other one. */
|
|
52
|
+
function speakMode(env = process.env, config = {}) {
|
|
53
|
+
return parseMode(env.VIBE_SPEAK ?? config.speak);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether to speak now: `auto` needs two or more sessions to be worth it. */
|
|
57
|
+
function shouldSpeak(mode, sessionCount) {
|
|
58
|
+
return mode === "always" || (mode === "auto" && sessionCount >= 2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Text that is safe to hand a voice: no control characters, and no embedded
|
|
63
|
+
* speech commands. macOS `say` reads `[[volm 0]]` and its kin out of the text,
|
|
64
|
+
* so a project directory called that would otherwise steer the voice.
|
|
65
|
+
*/
|
|
66
|
+
function cleanText(text, max = SPEECH_MAX_CHARS) {
|
|
67
|
+
let out = String(text).replace(/[\u0000-\u001f\u007f]+/g, " ");
|
|
68
|
+
while (/\[\[[^\]]*\]\]/.test(out)) out = out.replace(/\[\[[^\]]*\]\]/g, " ");
|
|
69
|
+
out = out.replace(/\[\[|\]\]/g, " "); // what nesting left behind
|
|
70
|
+
return out.replace(/\s+/g, " ").trim().slice(0, max);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** "my-app_v2" reads better as "my app v2" than as "my dash app underscore v2". */
|
|
74
|
+
function speakableLabel(label) {
|
|
75
|
+
return cleanText(String(label).replace(/[-_.]+/g, " "), 60) || "a session";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The fixed sentences. `tool` is only passed for a built-in tool name: an MCP
|
|
80
|
+
* tool's name is third-party text, and the line never speaks it.
|
|
81
|
+
*/
|
|
82
|
+
function phrase(kind, label, tool = "") {
|
|
83
|
+
const who = speakableLabel(label);
|
|
84
|
+
const what = cleanText(String(tool).replace(/_/g, " "), 40);
|
|
85
|
+
switch (kind) {
|
|
86
|
+
case "failure": return `${who}: failed.`;
|
|
87
|
+
case "attention": return what ? `${who} needs you: ${what}.` : `${who} needs you.`;
|
|
88
|
+
case "stuck": return `${who} looks stuck.`;
|
|
89
|
+
default: return `${who}: done.`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const POWERSHELL_SCRIPT =
|
|
94
|
+
"Add-Type -AssemblyName System.Speech; " +
|
|
95
|
+
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; " +
|
|
96
|
+
"$s.Volume = [int]$env:VIBE_SPEECH_VOLUME; " +
|
|
97
|
+
"$s.Speak($env:VIBE_SPEECH_TEXT)";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The command that speaks `text`, or null where this machine has no voice.
|
|
101
|
+
* `volume` is VibeAudio's 0-1 setting where the voice has a way to take it:
|
|
102
|
+
* macOS `say` follows the system volume unless told otherwise, and `spd-say`
|
|
103
|
+
* has no usable scale, so it speaks at its own level.
|
|
104
|
+
*/
|
|
105
|
+
function voiceCommand(platform, text, volume = 0.4, has = () => false) {
|
|
106
|
+
const v = Math.max(0.05, Math.min(1, Number(volume) || 0.4));
|
|
107
|
+
if (platform === "darwin") {
|
|
108
|
+
return has("say") ? { cmd: "say", args: ["--", `[[volm ${v.toFixed(2)}]] ${text}`] } : null;
|
|
109
|
+
}
|
|
110
|
+
if (platform === "win32") {
|
|
111
|
+
return {
|
|
112
|
+
cmd: "powershell.exe",
|
|
113
|
+
args: ["-NoProfile", "-NonInteractive", "-Command", POWERSHELL_SCRIPT],
|
|
114
|
+
env: { VIBE_SPEECH_TEXT: text, VIBE_SPEECH_VOLUME: String(Math.round(v * 100)) }
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (has("spd-say")) return { cmd: "spd-say", args: ["-w", "--", text] };
|
|
118
|
+
for (const cmd of ["espeak-ng", "espeak"]) {
|
|
119
|
+
if (has(cmd)) return { cmd, args: ["-a", String(Math.round(v * 200)), "--", text] };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function currentVoice(text, volume) {
|
|
125
|
+
const { isInstalled } = require("./interactive");
|
|
126
|
+
return voiceCommand(process.platform, text, volume, isInstalled);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `--doctor`: is there anything to speak with here? */
|
|
130
|
+
function voiceAvailable() {
|
|
131
|
+
return currentVoice("test", 0.4) !== null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Hand a sentence to a detached process that waits `delayMs` (for the chime),
|
|
136
|
+
* takes its turn on the lock, and speaks. Returns false when nothing was
|
|
137
|
+
* started. Never throws and never waits: the caller is a hook.
|
|
138
|
+
*/
|
|
139
|
+
function speak(text, { delayMs = 0, volume = 0.4 } = {}) {
|
|
140
|
+
try {
|
|
141
|
+
const { playbackDisabled } = require("./player");
|
|
142
|
+
if (playbackDisabled()) return false; // a mute means quiet, speech included
|
|
143
|
+
const clean = cleanText(text);
|
|
144
|
+
if (!clean || !currentVoice(clean, volume)) return false;
|
|
145
|
+
const child = spawn(process.execPath, [__filename, "--run", String(Math.max(0, Math.round(delayMs))), String(volume), clean], {
|
|
146
|
+
detached: true,
|
|
147
|
+
stdio: "ignore"
|
|
148
|
+
});
|
|
149
|
+
child.on("error", () => {});
|
|
150
|
+
child.unref();
|
|
151
|
+
return true;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** How long to wait so a sentence starts after the chime that was just played. */
|
|
158
|
+
function chimeDelayMs(outcome, played = true) {
|
|
159
|
+
return played ? (CHIME_MS[outcome] || CHIME_MS.success) + CHIME_GAP_MS : 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
163
|
+
|
|
164
|
+
function pidAlive(pid) {
|
|
165
|
+
try {
|
|
166
|
+
process.kill(pid, 0);
|
|
167
|
+
return true;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return e.code === "EPERM";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One speaker at a time across processes: exclusive create, with a stale check. */
|
|
174
|
+
async function acquireLock(waitMs = LOCK_WAIT_MS) {
|
|
175
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
176
|
+
const deadline = Date.now() + waitMs;
|
|
177
|
+
for (;;) {
|
|
178
|
+
try {
|
|
179
|
+
fs.writeFileSync(LOCK_FILE, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: "wx" });
|
|
180
|
+
return () => fs.rmSync(LOCK_FILE, { force: true });
|
|
181
|
+
} catch (e) {
|
|
182
|
+
if (e.code !== "EEXIST") return null;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const held = JSON.parse(fs.readFileSync(LOCK_FILE, "utf8"));
|
|
186
|
+
if (Date.now() - held.at > LOCK_STALE_MS || !pidAlive(held.pid)) fs.rmSync(LOCK_FILE, { force: true });
|
|
187
|
+
} catch (e) {
|
|
188
|
+
// Unreadable (being written, or just removed): look again.
|
|
189
|
+
}
|
|
190
|
+
if (Date.now() > deadline) return null;
|
|
191
|
+
await sleep(100);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function speakNow(text, volume) {
|
|
196
|
+
return new Promise((resolve) => {
|
|
197
|
+
const voice = currentVoice(text, volume);
|
|
198
|
+
if (!voice) return resolve(false);
|
|
199
|
+
let done = false;
|
|
200
|
+
const finish = (ok) => {
|
|
201
|
+
if (done) return;
|
|
202
|
+
done = true;
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
resolve(ok);
|
|
205
|
+
};
|
|
206
|
+
let child;
|
|
207
|
+
try {
|
|
208
|
+
child = spawn(voice.cmd, voice.args, { stdio: "ignore", env: { ...process.env, ...(voice.env || {}) } });
|
|
209
|
+
} catch (e) {
|
|
210
|
+
return resolve(false);
|
|
211
|
+
}
|
|
212
|
+
const timer = setTimeout(() => {
|
|
213
|
+
try { child.kill("SIGKILL"); } catch (e) { /* gone already */ }
|
|
214
|
+
finish(false);
|
|
215
|
+
}, SPEAK_TIMEOUT_MS);
|
|
216
|
+
child.on("error", () => finish(false));
|
|
217
|
+
child.on("close", (code) => finish(code === 0));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The body of the detached process. */
|
|
222
|
+
async function run(delayMs, volume, text) {
|
|
223
|
+
const { playbackDisabled } = require("./player");
|
|
224
|
+
await sleep(delayMs);
|
|
225
|
+
const release = await acquireLock();
|
|
226
|
+
if (!release) return; // could not get a turn: drop it rather than talk over someone
|
|
227
|
+
try {
|
|
228
|
+
if (playbackDisabled()) return; // muted while it waited
|
|
229
|
+
try {
|
|
230
|
+
const last = JSON.parse(fs.readFileSync(LAST_FILE, "utf8"));
|
|
231
|
+
if (last.text === text && Date.now() - last.at < REPEAT_MS) return;
|
|
232
|
+
} catch (e) { /* first one, or unreadable: nothing to compare */ }
|
|
233
|
+
fs.writeFileSync(LAST_FILE, JSON.stringify({ text, at: Date.now() }));
|
|
234
|
+
await speakNow(text, volume);
|
|
235
|
+
} finally {
|
|
236
|
+
release();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (require.main === module && process.argv[2] === "--run") {
|
|
241
|
+
run(Number(process.argv[3]) || 0, Number(process.argv[4]) || 0.4, String(process.argv[5] || ""))
|
|
242
|
+
.catch(() => {})
|
|
243
|
+
.finally(() => process.exit(0));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
MODES,
|
|
248
|
+
speakMode,
|
|
249
|
+
shouldSpeak,
|
|
250
|
+
cleanText,
|
|
251
|
+
phrase,
|
|
252
|
+
voiceCommand,
|
|
253
|
+
voiceAvailable,
|
|
254
|
+
speak,
|
|
255
|
+
chimeDelayMs,
|
|
256
|
+
acquireLock,
|
|
257
|
+
LOCK_FILE,
|
|
258
|
+
LAST_FILE,
|
|
259
|
+
SPEECH_MAX_CHARS
|
|
260
|
+
};
|