# Companion music Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** The companion DJs on a track start/change, grooves instead of looking bored while music plays, and says per-artist lines edited in a settings page. **Architecture:** `Mood.js` (pure) gains `djAt`, `music`, `groove` inputs. `Lines.js` gains artist defaults and a substring matcher, with its app-set helpers taking a defaults argument. `Companion.qml` reads the existing `Player` singleton (`modules/sound/Player.qml`) for track changes and drives a generic frame cycle. `PhrasesPage.qml` gets three text properties so a new `ArtistsModule.qml` reuses it, saving through new `Prefs.companionArtists`. **Tech Stack:** Quickshell 0.3.1 QML, QML JS libraries tested with node, FLUX.2 klein frames via `gen-frames`. Spec: `docs/superpowers/specs/2026-10-23-companion-music-design.md`. Rules from AGENTS.md that apply: no `flatMap`, `Object.fromEntries`, `matchAll` in QML JS; never name a property after the enclosing id; editing `shell.qml` needs `./reload.sh desktop` from the user's terminal, never from an agent; process is `qs` (`pgrep -cx qs`). Commits are GPG-signed, end with the Co-Authored-By line, and carry the reasoning. All commands run from the repo root `~/Programming/GIT/quickshell`. --- ### Task 1: Mood.js, dj and groove **Files:** - Modify: `desktop/companion/Mood.js` (T table, `pick`) - Test: `desktop/companion/test_mood.js` - [ ] **Step 1: Write the failing tests** In `test_mood.js`, extend the `calm` defaults (the object starting `leaving: false, now: NOW, ...`) with `djAt: 0, music: false, groove: ""` on the line that holds `singAt: 0,`: ```js repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, admireAt: 0, singAt: 0, djAt: 0, music: false, groove: "", ``` In `ladder`, insert directly after `["sing", { singAt: NOW - 10 }],`: ```js ["dj", { djAt: NOW - 10 }], ``` After the `// Idle levels and their frames.` block, add: ```js // Music while bored: she grooves with the picked frame instead, whistling // when nothing was picked; yawning and lying down are unchanged. assert.deepStrictEqual(M.pick(calm({ idle: 1, music: true, groove: "headbang" })), { state: "groove", frame: "headbang" }); assert.deepStrictEqual(M.pick(calm({ idle: 1, music: true })), { state: "groove", frame: "whistle" }); assert.deepStrictEqual(M.pick(calm({ idle: 1, groove: "headbang" })), { state: "bored", frame: "sleepy" }); assert.strictEqual(state({ idle: 2, music: true }), "yawn"); assert.strictEqual(state({ idle: 3, music: true }), "lying"); // Music while awake is the rest pose; the DJ is only the burst. assert.strictEqual(state({ music: true }), "base"); assert.deepStrictEqual(M.pick(calm({ djAt: NOW - 10 })), { state: "dj", frame: "dj" }); ``` After `assert.strictEqual(state({ singAt: NOW - M.T.singHold }), "base");` add: ```js assert.strictEqual(state({ djAt: NOW - M.T.djHold }), "base"); ``` - [ ] **Step 2: Run, expect failure** Run: `node desktop/companion/test_mood.js` Expected: AssertionError on the ladder (`everything from dj down`). - [ ] **Step 3: Implement** In `Mood.js`, in `T` after `singHold`: ```js djHold: 6000, // behind the decks when a track starts or changes ``` In `pick`, after the `sing` line: ```js if (s.now - s.djAt < T.djHold) return st("dj", "dj"); ``` Replace `if (s.idle === 1) return st("bored", "sleepy");` with: ```js // Music turns boredom into a groove picked by Companion.qml; the // deeper idle stages still win, so music does not keep her awake. if (s.idle === 1) return s.music ? st("groove", s.groove || "whistle") : st("bored", "sleepy"); ``` - [ ] **Step 4: Run, expect pass** Run: `node desktop/companion/test_mood.js` Expected: `ok` - [ ] **Step 5: Commit** ```bash git add desktop/companion/Mood.js desktop/companion/test_mood.js git commit -m "companion: dj and groove states in Mood A DJ burst ranks just below the headphones sing, so both arriving together sings first. Music turns only the first idle stage into a groove: yawning and lying down still win, so music does not keep her awake and the screensaver handoff is unchanged. The groove pick is an input, keeping pick() pure. Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 2: Lines.js, artist lines and groove lines **Files:** - Modify: `desktop/companion/Lines.js` - Test: `desktop/companion/test_lines.js` - [ ] **Step 1: Write the failing tests** In `test_lines.js`, change the return list to: ```js "\nreturn { lines, apps, artists, monologue, appMap, appList, pickApp, pickArtist, pick, toMap };")(); ``` Before `console.log("ok");` add: ```js // Artists: a case-insensitive substring of what MPRIS reports, first // matching key wins, empty phrases skipped. assert.strictEqual(L.pickArtist("Linkin Park", null, 0), L.artists["linkin park"][0]); assert.strictEqual(L.pickArtist("LINKIN PARK, Jay-Z", null, 0), L.artists["linkin park"][0]); assert.strictEqual(L.pickArtist("Nobody", null, 0), ""); assert.strictEqual(L.pickArtist("", null, 0), ""); assert.strictEqual(L.pickArtist("Air, Beck", { air: ["a"], beck: ["b"] }, 0), "a"); assert.strictEqual(L.pickArtist("Airbourne", { air: ["", "a"] }, 0), "a"); assert.strictEqual(L.pick("artist:Eminem", 0, null, null), L.artists.eminem[0]); assert.strictEqual(L.pick("artist:Eminem", 0, null, { eminem: ["mine"] }), "mine"); assert.strictEqual(L.pick("groove", 0), L.lines.groove[0]); // The same list helpers serve the artist set. assert.strictEqual(L.appMap(null, L.artists), L.artists); assert.strictEqual(L.appList(null, L.artists).length, Object.keys(L.artists).length); assert.deepStrictEqual(L.toMap(L.appList(null, L.artists)), L.artists); ``` - [ ] **Step 2: Run, expect failure** Run: `node desktop/companion/test_lines.js` Expected: `ReferenceError: artists is not defined` - [ ] **Step 3: Implement** In `Lines.js`, add to `lines` after `headphones`: ```js groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."], ``` After the `apps` object: ```js // The lines for an artist she knows, keyed by a lowercased name matched as // a substring of the reported artist, so "daft punk" also catches // collaborations. The Companion artists page edits them into // Prefs.companionArtists, which then replaces this whole set. var artists = { "linkin park": ["In the end, it doesn't even matter.", "Crawling in my skin... I know this one."], "evanescence": ["Wake me up inside. ...Operator, don't laugh."], "gigi d'agostino": ["L'amour toujours. I'll fly with you.", "...My feet are moving on their own."], "eminem": ["Lose yourself. Understood.", "He talks faster than Pod."], }; ``` Replace `appMap` and `appList` with versions taking the defaults (apps when omitted, so existing callers keep working): ```js // A set in effect: saved when it is a plain object, the defaults (apps // unless given) otherwise. A malformed saved value must not leave her mute. function appMap(saved, defaults) { return saved && typeof saved === "object" && !Array.isArray(saved) ? saved : (defaults || apps); } // A set as an ordered list of {cls, lines}, for the settings pages; cls is // the key, a window class or an artist. function appList(saved, defaults) { const m = appMap(saved, defaults); const keys = Object.keys(m); const out = []; for (let i = 0; i < keys.length; i++) out.push({ cls: keys[i], lines: (m[keys[i]] || []).slice() }); return out; } ``` Replace `pickApp` and `pick` with a shared non-empty filter, plus `pickArtist`: ```js function nonEmpty(all) { const l = []; for (let i = 0; i < (all || []).length; i++) if (typeof all[i] === "string" && all[i].trim() !== "") l.push(all[i]); return l; } function at(l, roll) { return l.length ? l[Math.min(l.length - 1, Math.floor(roll * l.length))] : ""; } // A line for an app class, empty strings skipped; "" when the class has none. function pickApp(cls, saved, roll) { return at(nonEmpty(appMap(saved)[cls]), roll); } // A line for the playing artist: the first key that is a case-insensitive // substring of it and has a non-empty line. ponytail: substring, so a // short key like "air" also matches "Airbourne"; whole words if that bites. function pickArtist(artist, saved, roll) { const a = String(artist || "").toLowerCase(); if (a === "") return ""; const m = appMap(saved, artists); const keys = Object.keys(m); for (let i = 0; i < keys.length; i++) { const l = nonEmpty(m[keys[i]]); if (keys[i] !== "" && l.length && a.indexOf(keys[i].toLowerCase()) !== -1) return at(l, roll); } return ""; } // A line for a reaction kind, or, with an "app:" or "artist:" prefix, for // that class or artist. function pick(kind, roll, saved, savedArtists) { if (kind.slice(0, 4) === "app:") return pickApp(kind.slice(4), saved, roll); if (kind.slice(0, 7) === "artist:") return pickArtist(kind.slice(7), savedArtists, roll); return at(lines[kind] || [], roll); } ``` - [ ] **Step 4: Run, expect pass** Run: `node desktop/companion/test_lines.js && node desktop/companion/test_mood.js` Expected: `ok` twice. - [ ] **Step 5: Commit** ```bash git add desktop/companion/Lines.js desktop/companion/test_lines.js git commit -m "companion: artist and groove lines Artist keys match as a case-insensitive substring of the MPRIS artist, so collaborations and feat. credits still hit; the cost is a short key over-matching, marked as a ponytail ceiling. appMap/appList take their defaults so the settings page can serve artists with the same helpers. Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 3: Prefs.companionArtists **Files:** - Modify: `desktop/Prefs.qml` (after `resetCompanionApps`, parse block near line 155, `onLoadFailed` near line 172) - [ ] **Step 1: Add the property and setters** after `resetCompanionApps()`'s closing brace: ```qml // The companion's artist lines as saved, keyed by lowercased artist, or // null for the defaults in companion/Lines.js. The Companion artists // page edits it. property var companionArtists: null function setCompanionArtists(map) { root.companionArtists = map; root.write(Object.assign({}, root.doc, { companionArtists: map })); } function resetCompanionArtists() { root.companionArtists = null; const d = Object.assign({}, root.doc); delete d.companionArtists; root.write(d); } ``` - [ ] **Step 2: Parse it** after the `root.companionApps = ca && ...` line: ```qml const cr = parsed.companionArtists; root.companionArtists = cr && typeof cr === "object" && !Array.isArray(cr) ? cr : null; ``` - [ ] **Step 3: Reset on load failure**: in the `onLoadFailed` line, after `root.companionApps = null;` insert ` root.companionArtists = null;`. - [ ] **Step 4: Check the shell reloads cleanly** Run: `qs log -p desktop --log-times | tail -5` Expected: a `Reloading configuration` / `Configuration Loaded` after the edit's mtime, no errors. If no reload line, see AGENTS.md "Hot reload sometimes does not fire". - [ ] **Step 5: Commit** ```bash git add desktop/Prefs.qml git commit -m "prefs: companionArtists, saved like companionApps Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 4: PhrasesPage text properties and ArtistsModule **Files:** - Modify: `desktop/modules/companion/PhrasesPage.qml` (properties near line 27, intro at line 101, placeholder at line 132, button at line 180) - Create: `desktop/modules/companion/ArtistsModule.qml` - Modify: `desktop/shell.qml:91` - [ ] **Step 1: Parametrise the page.** After `required property QtObject owner`, add: ```qml // What the cards are keyed by; the defaults are the app set's, so // PhrasesModule passes nothing. property string intro: "When focus moves to a program she recognizes, she says one of its lines at random, a third " + "of the time and under her 30 s line cooldown. The class is Hyprland's window class, " + "lowercased (hyprctl activewindow shows it). Save applies at once." property string keyHint: "window class, e.g. firefox" property string addText: "Add app" // The saved set, for Reset's enabled state; null means the defaults. property var saved: Prefs.companionApps ``` Replace the intro `Small`'s `text:` (the three-line string) with `text: page.intro`, the card field's `placeholder: "window class, e.g. firefox"` with `placeholder: page.keyHint`, `Button { text: "Add app"; ...}` with `Button { text: page.addText; onClicked: page.addApp() }`, and in the Reset button's `enabled:` replace `Prefs.companionApps !== null` with `page.saved !== null`. - [ ] **Step 2: Create `ArtistsModule.qml`** (copy the GPLv2 header from `PhrasesModule.qml` lines 1-10 verbatim, then): ```qml import QtQuick import "../.." import "../../companion/Lines.js" as Lines // The lines the companion says when a track by an artist she knows starts, // matched as a case-insensitive substring of the player's artist. Same // draft, Save and Reset as PhrasesModule, on Prefs.companionArtists and // Lines.artists; the page's cls field holds the artist. Module { id: am name: "artists" label: "Companion artists" // nf-fa-music, present in Inconsolata Nerd Font. icon: "" settings: true group: "Companion" keywords: ["companion", "artist", "music", "song", "track", "line", "say", "2b"] searchItems: am.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i })) property var draft: null property bool resetting: false readonly property var shown: am.draft ?? Lines.appList(Prefs.companionArtists, Lines.artists) readonly property bool valid: am.shown.every(a => String(a.cls).trim() !== "" && (a.lines || []).some(l => String(l).trim() !== "")) dirty: am.draft !== null function edit(list) { am.draft = list; am.resetting = false; } function reset() { am.draft = Lines.appList(null, Lines.artists); am.resetting = true; } function save() { if (am.resetting) Prefs.resetCompanionArtists(); else Prefs.setCompanionArtists(Lines.toMap(am.draft)); am.discard(); } function discard() { am.draft = null; am.resetting = false; } page: Component { Page { title: "Companion artists" actions: Component { SaveBar { dirty: am.dirty; valid: am.valid; note: "every artist needs a name and a phrase" onSave: am.save(); onDiscard: am.discard() } } PhrasesPage { width: parent.width owner: am intro: "When a track by an artist she knows starts or changes, she says one of their lines at " + "random, under her 30 s line cooldown. The name matches any part of the player's artist, " + "ignoring case, so \"daft punk\" also catches \"Daft Punk, Pharrell Williams\". " + "Save applies at once." keyHint: "artist, e.g. linkin park" addText: "Add artist" saved: Prefs.companionArtists } } } } ``` - [ ] **Step 3: Register it** in `desktop/shell.qml`, after `PhrasesModule {},`: ```qml ArtistsModule {}, ``` - [ ] **Step 4: Verify.** `shell.qml` changed, so ask the user to run `./reload.sh desktop` from their terminal (never from a tool call). Then `qs log -p desktop | tail -20` must show no errors, and the user checks by eye: Settings > Companion artists lists the four seeds; Companion phrases still reads as before. - [ ] **Step 5: Commit** ```bash git add desktop/modules/companion/PhrasesPage.qml desktop/modules/companion/ArtistsModule.qml desktop/shell.qml git commit -m "companion: artist lines page PhrasesPage takes its intro, key hint and add-button text, so the artist set reuses it rather than a copy that would drift. ArtistsModule mirrors PhrasesModule on Prefs.companionArtists and Lines.artists. Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 5: Companion.qml, track changes, groove pick, frame cycles **Files:** - Modify: `desktop/companion/Companion.qml` - [ ] **Step 1: Import the player.** After `import ".."` add `import "../modules/sound"`. - [ ] **Step 2: Inputs.** After `property double singAt: 0`: ```qml property double djAt: 0 property string groove: "" ``` - [ ] **Step 3: Groove pick and bored line.** In `onIdleChanged`, replace the `else if (root.idle === 1 && root.wasIdle === 0) { ... }` branch with: ```qml } else if (root.idle === 1 && root.wasIdle === 0) { root.pickGroove(); if (Player.playing) root.say("groove"); else if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue(); else root.say("bored"); } ``` - [ ] **Step 4: Track changes.** After the headphones block (`onPhonesConnectedChanged`), add: ```qml // What is playing, from the sound module's player (it already drops the // playerctld and browser-integration duplicates). A new artist+title // while playing is a track start or change: she DJs for Mood.T.djHold // and says the artist's line if she knows them. Paused reads "", and // resuming the same track matches lastTrack, so it does not DJ again. readonly property string track: Player.playing ? Player.artist + "\n" + Player.title : "" property string lastTrack: "" onTrackChanged: { if (root.track === "" || root.track === root.lastTrack) return; root.lastTrack = root.track; root.djAt = Date.now(); root.now = root.djAt; root.say("artist:" + Player.artist); } // Music starting while she is already bored needs a groove too. readonly property bool music: Player.playing onMusicChanged: if (root.music && root.idle === 1) root.pickGroove() function pickGroove() { const g = ["whistle", "headbang", "airguitar"]; root.groove = g[Math.floor(Math.random() * g.length)]; } ``` - [ ] **Step 5: Feed Mood.** In the `Mood.pick({...})` snapshot, change `singAt: root.singAt, monologue: root.monologue, idle: root.idle,` to: ```qml singAt: root.singAt, djAt: root.djAt, music: root.music, groove: root.groove, monologue: root.monologue, idle: root.idle, ``` - [ ] **Step 6: Artist lines reach say.** In `say()`, change the pick to: ```qml const line = Lines.pick(kind, Math.random(), Prefs.companionApps, Prefs.companionArtists); ``` - [ ] **Step 7: Frame cycles.** After the `waveFlip` Timer, add: ```qml // Frames Mood names that are really a loop: the frame names its cycle // and how fast it turns. Everything else shows as named. readonly property var cycles: ({ dj: { frames: ["dj", "dj-scratch"], ms: 450 }, headbang: { frames: ["headbang-up", "headbang-down"], ms: 220 }, }) property int cycleIndex: 0 readonly property var cycle: root.cycles[root.mood.frame] ?? null onCycleChanged: root.cycleIndex = 0 Timer { interval: root.cycle?.ms ?? 1000 repeat: true running: root.cycle !== null && !root.away onTriggered: root.cycleIndex = (root.cycleIndex + 1) % root.cycle.frames.length } readonly property string frame: root.leaving ? root.waveCycle[root.waveIndex] : root.cycle ? root.cycle.frames[root.cycleIndex % root.cycle.frames.length] : root.mood.frame ``` Change the `Figure`'s `source:` to: ```qml source: `file://${root.art}/${root.outfit}/${root.frame}.png` ``` - [ ] **Step 8: Verify the load.** Run: `node desktop/companion/test_mood.js && node desktop/companion/test_lines.js && qs log -p desktop --log-times | tail -10` Expected: `ok`, `ok`, a reload after the edit with no `TypeError`/`ReferenceError`. Then ask the user to play a track: she should show `base` (frames not generated yet, falls back) for 6s, and say an artist line for one of the seeds; pause and resume must not repeat it. - [ ] **Step 9: Commit** ```bash git add desktop/companion/Companion.qml git commit -m "companion: DJ on track change, groove while bored with music Reads the sound module's Player, which already filters the MPRIS duplicates. The track key is artist+title while playing, so a pause and resume of the same track does not DJ again; a shell start with music on DJs once, which is harmless. Frame loops are a small table keyed by the frame Mood names, so dj and headbang need no special case in Mood. Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 6: Frames and docs **Files:** - Modify: `desktop/companion/frames.txt` - Modify: `desktop/companion/README.md` - [ ] **Step 1: Dress frames.** In `frames.txt`, after `dress/sing <- ...`, add: ``` # Music: behind the decks on a track start or change, and the grooves # that replace boredom while music plays. DECK = A small black DJ console with two turntables and a mixer stands in front of her at waist height, hiding her legs. dress/dj <- dress/base : ${DECK} Both gloved hands rest on the turntables, head nodding, a small smile; she wears black over-ear headphones. ${EYES} ${KEEP} dress/dj-scratch <- dress/dj : Change only her arms: the hand on the left side of the image scratches a record, the other holds one headphone cup to her ear. Everything else exactly the same. ${KEEP} dress/whistle <- dress/base : Change only her expression: lips puckered, whistling, a couple of small black musical notes floating beside her mouth. ${POSE} ${EYES} ${KEEP} dress/headbang-up <- dress/base : She headbangs: head tipped back, hair flying up, eyes shut, fists clenched at her sides. ${BARE} ${KEEP_BARE} dress/headbang-down <- dress/headbang-up : Change only her head: tipped sharply forward and down, hair thrown forward over her face. Everything else exactly the same. ${KEEP_BARE} dress/airguitar <- dress/base : She plays an invisible air guitar: one hand strumming at her hip, the other fretting in the air to the side, knees bent, eyes shut, mouth open in a grin. Nothing in her hands. ${BARE} ${KEEP_BARE} ``` `KEEP`, `EYES`, `BARE`, `KEEP_BARE` and `LEOTARD` are defined at the top of the file; `DECK` only has to precede its uses, as `SING` does. - [ ] **Step 2: Leotard frames.** After the last `leotard/` line add: ``` leotard/dj <- dress/dj : ${LEOTARD} leotard/dj-scratch <- dress/dj-scratch : ${LEOTARD} leotard/whistle <- dress/whistle : ${LEOTARD} leotard/headbang-up <- dress/headbang-up : ${LEOTARD} leotard/headbang-down <- dress/headbang-down : ${LEOTARD} leotard/airguitar <- dress/airguitar : ${LEOTARD} ``` - [ ] **Step 3: Check the parse.** Run: `desktop/companion/gen-frames --list` Expected: the 12 new outputs listed (and nothing else new), no parse error. - [ ] **Step 4: README.** In `desktop/companion/README.md`, add to the Files table row for `Lines.js` "and per-artist lines", and add an Inputs bullet after the Sony headphones one: ``` - What is playing is `modules/sound/Player.qml`, which already drops the playerctld and browser-integration duplicates. A new artist+title while playing makes her DJ for `Mood.T.djHold`, 6 s (`dj` and `dj-scratch` cycling), and say the artist's line if one matches: lowercased keys in `Lines.artists`, matched as a substring, edited on Settings > Companion artists into `Prefs.companionArtists`. Pause and resume of the same track is not a change. While music plays, the first idle stage is a groove instead of bored: `whistle`, `headbang` (`headbang-up`/`-down` cycling) or `airguitar`, picked at random, with a `groove` line; yawning and lying down are unchanged. ``` - [ ] **Step 5: Commit** ```bash git add desktop/companion/frames.txt desktop/companion/README.md git commit -m "companion: music frames to generate, and the README Twelve frames, dress and leotard. Prompts are a first draft for klein; the user generates them, and until then each falls back to base. Co-Authored-By: Claude Opus 5.5 " ``` - [ ] **Step 6: Hand off art.** Tell the user: start sd-webui with FLUX.2 klein, run `desktop/companion/gen-frames`, reroll with `--seed N ` as needed, record failures in `recipe.txt`.