vibeaudio 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/package.json +2 -2
- package/src/card.js +129 -0
- package/src/cli.js +58 -28
- package/src/history.js +46 -1
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.
|
|
@@ -423,6 +425,30 @@ Installing the Claude Code hooks also adds a `/vibe` command (`~/.claude/command
|
|
|
423
425
|
|
|
424
426
|
It asks Claude to run the matching `vibe` command, so Claude Code will ask permission for that command the first time. If you already have your own `vibe.md` there, VibeAudio leaves it alone and says so; `--uninstall-hooks` removes only its own.
|
|
425
427
|
|
|
428
|
+
### `/vibeaudio`: instant controls and a pane (Claude Code mod)
|
|
429
|
+
|
|
430
|
+
`/vibe` is a prompt, so Claude has to read it, run a command and answer: it costs a turn and a few tokens, and it waits for the current turn to end. The optional **VibeAudio mod** runs inside Claude Code instead, so `/vibeaudio mute` takes effect at once, even while Claude is working, and costs no tokens.
|
|
431
|
+
|
|
432
|
+
```text
|
|
433
|
+
claude plugin marketplace add kiril6/vibeaudio
|
|
434
|
+
claude plugin install vibeaudio-mod@vibeaudio
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Then `/reload-plugins` or a new session; `/plugin` shows `1 mod active`.
|
|
438
|
+
|
|
439
|
+
```text
|
|
440
|
+
/vibeaudio open the pane: mute, music, genre, volume, speech, banners, test signal
|
|
441
|
+
/vibeaudio mute 30 silence for 30 minutes (unmute ends it)
|
|
442
|
+
/vibeaudio genre jazz also: volume <n>, music on|off, speak off|auto|always, notify on|off, stop
|
|
443
|
+
/vibeaudio card make your week card; the pane shows it in terminals that draw images
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
While you are muted, a line above the prompt says how many minutes are left, so a forgotten mute does not look like a broken tool. Nothing is shown otherwise.
|
|
447
|
+
|
|
448
|
+
- **It changes nothing itself.** Every action runs the `vibe` command with a fixed argument list (no shell), so settings stay in `~/.vibeaudio/config.json` and the pane cannot disagree with the CLI. It needs `vibe` on your `PATH` (`npm i -g vibeaudio`) and **Claude Code 2.1.287 or later** (Desktop app 2.1.286). It draws in the terminal and the Desktop app; in the VS Code panel and `claude -p` the same commands answer in text. It makes no network request.
|
|
449
|
+
- **A mod is code, not configuration.** It runs inside Claude Code with your permissions and is not sandboxed, which is why `--install-hooks` never installs it for you. Read it first: it is [`mods/vibe/hooks/register.js`](mods/vibe/hooks/register.js), and `claude plugin validate mods/vibe` lists every call it makes (it reads two files in `~/.vibeaudio/`, runs `vibe`, and draws).
|
|
450
|
+
- **`/vibe` stays.** It works on every Claude Code version and surface; the mod is the faster path where it loads. Remove the mod with `claude plugin uninstall vibeaudio-mod@vibeaudio`.
|
|
451
|
+
|
|
426
452
|
### Changing the sound later
|
|
427
453
|
|
|
428
454
|
One command, and it reaches everything:
|
|
@@ -494,6 +520,8 @@ plus where the time went by project and, for windows up to two weeks, by day. "Y
|
|
|
494
520
|
|
|
495
521
|
</details>
|
|
496
522
|
|
|
523
|
+
**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.
|
|
524
|
+
|
|
497
525
|
### The chime is in the music's key
|
|
498
526
|
|
|
499
527
|
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.
|
|
@@ -832,6 +860,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
832
860
|
| `--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 |
|
|
833
861
|
| `--announcements <on\|off>` | Turn `vibe --announce` on or off for good. Saved to `config.json` | on |
|
|
834
862
|
| `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
|
|
863
|
+
| `--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 |
|
|
835
864
|
| `--statusline` | The same state as one plain line for tmux or a shell prompt (`working ×2`, `waiting: api`); prints nothing when idle | — |
|
|
836
865
|
| `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | — |
|
|
837
866
|
| `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | — |
|
|
@@ -1040,3 +1069,5 @@ Support development: <a href="https://ko-fi.com/K3K2X0ERJ"><img src="https://ko-
|
|
|
1040
1069
|
|
|
1041
1070
|
## 📄 License
|
|
1042
1071
|
MIT © 2026
|
|
1072
|
+
|
|
1073
|
+
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.21.0",
|
|
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",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "node test/test-synth.js",
|
|
12
12
|
"start": "node bin/vibeaudio.js",
|
|
13
|
-
"version": "node -e \"const fs=require('fs'),v=require('./package.json').version;for(const f of ['.claude-plugin/plugin.json','.codex-plugin/plugin.json']){const j=JSON.parse(fs.readFileSync(f,'utf8'));j.version=v;fs.writeFileSync(f,JSON.stringify(j,null,2)+'\\n')}\" && git add .claude-plugin/plugin.json .codex-plugin/plugin.json"
|
|
13
|
+
"version": "node -e \"const fs=require('fs'),v=require('./package.json').version;for(const f of ['.claude-plugin/plugin.json','.codex-plugin/plugin.json','mods/vibe/.claude-plugin/plugin.json']){const j=JSON.parse(fs.readFileSync(f,'utf8'));j.version=v;fs.writeFileSync(f,JSON.stringify(j,null,2)+'\\n')}\" && git add .claude-plugin/plugin.json .codex-plugin/plugin.json mods/vibe/.claude-plugin/plugin.json"
|
|
14
14
|
},
|
|
15
15
|
"publishConfig": {
|
|
16
16
|
"registry": "https://registry.npmjs.org/",
|
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
|
@@ -86,6 +86,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
86
86
|
--speak-always Like --speak, but every time, even with one session
|
|
87
87
|
--announcements <on|off> Turn vibe --announce on or off for good (a saved setting; on by default)
|
|
88
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
|
|
89
90
|
--state Print what every agent is doing as JSON: idle, working, stuck or waiting
|
|
90
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)
|
|
91
92
|
--outcome <o> With --announce: success (default), failure or attention
|
|
@@ -190,7 +191,8 @@ const VALUE_FLAGS = new Set([
|
|
|
190
191
|
"--announce",
|
|
191
192
|
"--outcome",
|
|
192
193
|
"--exit-code",
|
|
193
|
-
"--port"
|
|
194
|
+
"--port",
|
|
195
|
+
"--out"
|
|
194
196
|
]);
|
|
195
197
|
|
|
196
198
|
function parseArgs(argv) {
|
|
@@ -238,6 +240,8 @@ function parseArgs(argv) {
|
|
|
238
240
|
let speakFlag = null;
|
|
239
241
|
let announcementsFlag = null;
|
|
240
242
|
let reportDays = null;
|
|
243
|
+
let cardFlag = false;
|
|
244
|
+
let cardOut = null;
|
|
241
245
|
let stopFlag = false;
|
|
242
246
|
let muteFlag = null;
|
|
243
247
|
let muteMinutes = null;
|
|
@@ -432,6 +436,18 @@ function parseArgs(argv) {
|
|
|
432
436
|
continue;
|
|
433
437
|
}
|
|
434
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
|
+
|
|
435
451
|
if (arg === "--notify" || arg === "--no-notify") {
|
|
436
452
|
notifyFlag = arg === "--notify";
|
|
437
453
|
i += 1;
|
|
@@ -636,6 +652,8 @@ function parseArgs(argv) {
|
|
|
636
652
|
speak: speakFlag,
|
|
637
653
|
announcements: announcementsFlag,
|
|
638
654
|
report: reportDays,
|
|
655
|
+
card: cardFlag,
|
|
656
|
+
cardOut,
|
|
639
657
|
stop: stopFlag,
|
|
640
658
|
mute: muteFlag,
|
|
641
659
|
muteMinutes,
|
|
@@ -1125,15 +1143,6 @@ function doctorChecks() {
|
|
|
1125
1143
|
return checks;
|
|
1126
1144
|
}
|
|
1127
1145
|
|
|
1128
|
-
/** 95 -> "1m 35s", 4_000_000 -> "1h 06m". */
|
|
1129
|
-
function formatDuration(ms) {
|
|
1130
|
-
const s = Math.round(ms / 1000);
|
|
1131
|
-
if (s < 60) return `${s}s`;
|
|
1132
|
-
const m = Math.floor(s / 60);
|
|
1133
|
-
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
1134
|
-
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
1146
|
/**
|
|
1138
1147
|
* The summary of ~/.vibeaudio/history.jsonl. "You waited" is the agent's own
|
|
1139
1148
|
* working time - a turn's length minus the stretches it spent blocked on a
|
|
@@ -1143,7 +1152,7 @@ function formatDuration(ms) {
|
|
|
1143
1152
|
* over RETURN_WINDOW_MS read as breaks and left out.
|
|
1144
1153
|
*/
|
|
1145
1154
|
function printReport(days, now = Date.now()) {
|
|
1146
|
-
const { readTurns, returnTimes } = require("./history");
|
|
1155
|
+
const { readTurns, returnTimes, formatDuration, working, summarize, dayTotals } = require("./history");
|
|
1147
1156
|
const turns = readTurns(days, now);
|
|
1148
1157
|
const dim = (s) => `\x1b[90m${s}\x1b[0m`;
|
|
1149
1158
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
@@ -1157,14 +1166,7 @@ function printReport(days, now = Date.now()) {
|
|
|
1157
1166
|
return;
|
|
1158
1167
|
}
|
|
1159
1168
|
|
|
1160
|
-
const
|
|
1161
|
-
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
|
|
1162
|
-
const sorted = turns.map((t) => t.ms).sort((a, b) => a - b);
|
|
1163
|
-
const median = sorted[Math.floor(sorted.length / 2)];
|
|
1164
|
-
const longest = turns.reduce((a, b) => (b.ms > a.ms ? b : a));
|
|
1165
|
-
const failed = turns.filter((t) => t.outcome === "failure").length;
|
|
1166
|
-
const waitedOnAgents = sum(turns.map(working));
|
|
1167
|
-
const blocked = sum(turns.map((t) => t.blockedMs || 0));
|
|
1169
|
+
const { failed, waited: waitedOnAgents, blocked, median, longest } = summarize(turns);
|
|
1168
1170
|
|
|
1169
1171
|
// Two projects called `api` under different parents stay apart.
|
|
1170
1172
|
const names = new Map();
|
|
@@ -1211,15 +1213,9 @@ function printReport(days, now = Date.now()) {
|
|
|
1211
1213
|
|
|
1212
1214
|
if (days <= 14) {
|
|
1213
1215
|
console.log(`\n${bold("By day")}`);
|
|
1214
|
-
const
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
byDay.set(key, (byDay.get(key) || 0) + working(t));
|
|
1218
|
-
}
|
|
1219
|
-
const maxDay = Math.max(...byDay.values()) || 1;
|
|
1220
|
-
for (let d = days - 1; d >= 0; d--) {
|
|
1221
|
-
const date = new Date(now - d * 86400000);
|
|
1222
|
-
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) {
|
|
1223
1219
|
const name = `${date.toLocaleDateString("en-US", { weekday: "short" })} ${date.getDate()}`;
|
|
1224
1220
|
console.log(` ${name.padEnd(8)}${(ms ? formatDuration(ms) : "—").padStart(8)} ${"█".repeat(ms ? Math.max(1, Math.round((ms / maxDay) * 14)) : 0)}`);
|
|
1225
1221
|
}
|
|
@@ -1227,6 +1223,31 @@ function printReport(days, now = Date.now()) {
|
|
|
1227
1223
|
console.log();
|
|
1228
1224
|
}
|
|
1229
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
|
+
|
|
1230
1251
|
function printDoctor() {
|
|
1231
1252
|
const mark = { ok: "\x1b[32m✔\x1b[0m", warn: "\x1b[33m!\x1b[0m", fail: "\x1b[31m✘\x1b[0m" };
|
|
1232
1253
|
const checks = doctorChecks();
|
|
@@ -1796,6 +1817,8 @@ async function run() {
|
|
|
1796
1817
|
speak: speakChange,
|
|
1797
1818
|
announcements: announcementsChange,
|
|
1798
1819
|
report: reportDays,
|
|
1820
|
+
card: cardFlag,
|
|
1821
|
+
cardOut,
|
|
1799
1822
|
stop: shouldStop,
|
|
1800
1823
|
mute: muteChange,
|
|
1801
1824
|
muteMinutes,
|
|
@@ -1841,6 +1864,7 @@ async function run() {
|
|
|
1841
1864
|
: speakChange !== null ? (speakChange === "off" ? "--no-speak" : speakChange === "always" ? "--speak-always" : "--speak")
|
|
1842
1865
|
: announcementsChange !== null ? "--announcements"
|
|
1843
1866
|
: announceReq.text !== null ? "--announce"
|
|
1867
|
+
: cardFlag ? "--card"
|
|
1844
1868
|
: null;
|
|
1845
1869
|
if (own && cmdArgs.length > 0) {
|
|
1846
1870
|
console.error(`\x1b[31m[vibeaudio] ${own} does not wrap a command, so '${cmdArgs.join(" ")}' was not run.\x1b[0m ` +
|
|
@@ -1979,6 +2003,12 @@ async function run() {
|
|
|
1979
2003
|
return;
|
|
1980
2004
|
}
|
|
1981
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
|
+
|
|
1982
2012
|
if (reportDays !== null) {
|
|
1983
2013
|
return printReport(reportDays);
|
|
1984
2014
|
}
|
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 };
|