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 CHANGED
@@ -1,11 +1,13 @@
1
1
  # 🎧 VibeAudio
2
2
 
3
3
  <p align="center">
4
- <img src="docs/og.png?v=2" alt="VibeAudio — Focus music while your AI codes" width="720">
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
  [![test](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml/badge.svg)](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml) [![npm](https://img.shields.io/npm/v/vibeaudio)](https://www.npmjs.com/package/vibeaudio) [![license](https://img.shields.io/npm/l/vibeaudio)](LICENSE) [![node](https://img.shields.io/node/v/vibeaudio)](https://nodejs.org) [![downloads/month](https://img.shields.io/npm/dm/vibeaudio)](https://npm-stat.com/charts.html?package=vibeaudio) [![M8ven Score](https://m8ven.ai/badge/mcp/kiril6/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&#0032;music&#0032;while&#0032;your&#0032;AI&#0032;codes&#0046; | 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.20.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 &amp;&amp; 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 working = (t) => t.ms - (t.blockedMs || 0);
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 byDay = new Map();
1215
- for (const t of turns) {
1216
- const key = new Date(t.at).toLocaleDateString("en-CA");
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
- module.exports = { HISTORY_FILE, RETURN_WINDOW_MS, recordTurn, readTurns, returnTimes };
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 };