@cullumco/cadence 0.1.2 → 0.1.4
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/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -9
- package/dist/cadence.js +11 -6
- package/dist/posttool.js +119 -0
- package/dist/providers/ambient.js +61 -8
- package/dist/vibe.js +64 -2
- package/hooks/hooks.json +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ deaf to the room.
|
|
|
12
12
|
**macOS-only (alpha).** Most signals read the Mac around you; other platforms
|
|
13
13
|
degrade to self-report + dials + time/git.
|
|
14
14
|
|
|
15
|
-
A [Cullum&Co](https://cullum.co) project.
|
|
15
|
+
A [Cullum&Co](https://cullum.co) project · [cullumco.github.io/cadence](https://cullumco.github.io/cadence/)
|
|
16
16
|
|
|
17
17
|
## What it does
|
|
18
18
|
|
|
@@ -21,8 +21,8 @@ Before Claude sees your prompt, Cadence injects a `<user_state>` block:
|
|
|
21
21
|
```
|
|
22
22
|
<user_state>
|
|
23
23
|
signals:
|
|
24
|
-
music: "
|
|
25
|
-
vibe:
|
|
24
|
+
music: "You Fail Me" — Converge (Spotify)
|
|
25
|
+
vibe: aggressive, energetic
|
|
26
26
|
self_report: "two beers, shipping"
|
|
27
27
|
cadence: # inferred from signals, advisory
|
|
28
28
|
{ pace=fast tone=warm posture=decisive proactivity=act-freely }
|
|
@@ -78,8 +78,10 @@ Same words. The room around them changed, and the agent finally saw it.
|
|
|
78
78
|
Spotify login, no API key, no Premium.
|
|
79
79
|
- **self-report** — what you tell it: `cadence state "two beers, shipping"`.
|
|
80
80
|
|
|
81
|
-
Time/day
|
|
82
|
-
|
|
81
|
+
Time/day, self-report, and git move the dials (git reads *what you're
|
|
82
|
+
doing*: 3+ commits/hr → fast pace, mid-conflict → verify-first); the rest
|
|
83
|
+
render as context the agent reads (flavor). Self-report always outranks
|
|
84
|
+
inference — "I'm shipping" beats a mid-conflict read.
|
|
83
85
|
2. **Dials** — four independent knobs, each `low | medium | high`, inferred from
|
|
84
86
|
the signals (or pinned by you):
|
|
85
87
|
- **pace** — deliberate ↔ fast
|
|
@@ -228,10 +230,12 @@ See [`BACKLOG.md`](BACKLOG.md). Highlights:
|
|
|
228
230
|
one long considered one.
|
|
229
231
|
- **focused app** — what's frontmost next to the terminal (docs? a profiler?
|
|
230
232
|
Slack?).
|
|
231
|
-
- **
|
|
232
|
-
Focus
|
|
233
|
-
- **After-the-fact injection** —
|
|
234
|
-
|
|
233
|
+
- **deeper Focus** — manual + scheduled Focus detection ship now; geofenced/
|
|
234
|
+
iPhone-synced Focus leaves no local trace and stays undetectable.
|
|
235
|
+
- **After-the-fact injection** — the first cut ships: a `PostToolUse` hook
|
|
236
|
+
watches git-ish commands and speaks exactly once when the repo enters or
|
|
237
|
+
leaves a merge/rebase conflict ("this is debug now" / "conflict resolved").
|
|
238
|
+
Next material events: failing-test transitions, reset/force-push thrash.
|
|
235
239
|
- **Opt-in flavor providers** — horoscope, moon phase, for those who want them.
|
|
236
240
|
|
|
237
241
|
## Caveats
|
package/dist/cadence.js
CHANGED
|
@@ -71,6 +71,16 @@ export function deriveCadence(state) {
|
|
|
71
71
|
if (music?.vibe && /\b(calm|chilled|ethereal|romantic|warm)\b/.test(music.vibe)) {
|
|
72
72
|
c.tone = "low";
|
|
73
73
|
}
|
|
74
|
+
// ── git → pace / proactivity (what you're DOING, not what you said) ───────
|
|
75
|
+
// Enabled 2026-06-05 after the flavor proved trustworthy in real use.
|
|
76
|
+
// Applied below self-report on purpose: "I'm shipping" beats a mid-conflict
|
|
77
|
+
// read — the user's explicit word stays the higher authority.
|
|
78
|
+
if (git) {
|
|
79
|
+
if (git.commitsLastHour >= 3)
|
|
80
|
+
c.pace = "high"; // flow state
|
|
81
|
+
if (git.conflicted)
|
|
82
|
+
c.proactivity = "low"; // verify, don't barrel
|
|
83
|
+
}
|
|
74
84
|
// ── self-report → posture / proactivity / tone (you know your state) ──────
|
|
75
85
|
if (report) {
|
|
76
86
|
const t = report.text.toLowerCase();
|
|
@@ -92,13 +102,8 @@ export function deriveCadence(state) {
|
|
|
92
102
|
if (/\b(focused|formal|work|serious|crunch)\b/.test(t))
|
|
93
103
|
c.tone = "high";
|
|
94
104
|
}
|
|
95
|
-
//
|
|
96
|
-
// Candidate nudges, deliberately dormant until we've watched real output:
|
|
97
|
-
// conflicted → proactivity low (verify, don't barrel)
|
|
98
|
-
// commitsLastHour >= 3 → pace high (flow state)
|
|
105
|
+
// Still-dormant candidate nudges (see BACKLOG):
|
|
99
106
|
// ambient focus on → proactivity high (heads-down = fewer check-ins)
|
|
100
|
-
// See BACKLOG: turn these on once the flavor proves trustworthy.
|
|
101
|
-
void git;
|
|
102
107
|
// ── activity → pace (returning from a break = slow back down) ─────────────
|
|
103
108
|
if (activity?.minSinceLastPrompt != null && activity.minSinceLastPrompt > 30) {
|
|
104
109
|
c.pace = "low";
|
package/dist/posttool.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { getGitSignal } from "./providers/git.js";
|
|
7
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
8
|
+
* PostToolUse adapter — V2 "after-the-fact" refinement, conservative cut.
|
|
9
|
+
*
|
|
10
|
+
* The UserPromptSubmit lens is predictive: it reads AMBIENT state before any
|
|
11
|
+
* work happens. This hook watches the work itself and speaks only when the
|
|
12
|
+
* observed work MATERIALLY changes the cadence read. V2's single material
|
|
13
|
+
* event: the repo entering or leaving a merge/rebase conflict — the
|
|
14
|
+
* strongest debug tell we have, far stronger than music ever was.
|
|
15
|
+
*
|
|
16
|
+
* Discipline (the BACKLOG's hard constraint):
|
|
17
|
+
* - fires only after Bash tool calls whose command mentions git
|
|
18
|
+
* - injects at most once per conflict-state TRANSITION, never per tool
|
|
19
|
+
* - silent in every other case, silent on every error
|
|
20
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
21
|
+
const TOTAL_BUDGET_MS = 1500;
|
|
22
|
+
const STATE_FILE = join(homedir(), ".cadence", "workstate.json");
|
|
23
|
+
const MAX_SESSIONS = 20;
|
|
24
|
+
// Gate 1: is this tool call even capable of changing git conflict state?
|
|
25
|
+
// Only Bash commands that mention git — Edit/Read/etc. can't start a merge,
|
|
26
|
+
// and checking the repo after every tool call would betray the silence rule.
|
|
27
|
+
export function shouldCheck(input) {
|
|
28
|
+
if (input.tool_name !== "Bash")
|
|
29
|
+
return false;
|
|
30
|
+
const cmd = input.tool_input?.command;
|
|
31
|
+
return typeof cmd === "string" && /\bgit\b/.test(cmd);
|
|
32
|
+
}
|
|
33
|
+
// Gate 2: did the observed state actually TRANSITION? Speak only on the edge.
|
|
34
|
+
// undefined → true first observation reveals a conflict → speak
|
|
35
|
+
// false → true work just entered a conflict → speak
|
|
36
|
+
// true → false conflict resolved → speak (release)
|
|
37
|
+
// anything else no change → silent
|
|
38
|
+
export function refineContext(prev, conflicted) {
|
|
39
|
+
if (prev === conflicted)
|
|
40
|
+
return null;
|
|
41
|
+
if (conflicted) {
|
|
42
|
+
return ("<user_state_update>observed work: the repo just entered a merge/rebase " +
|
|
43
|
+
"conflict. Read the cadence as debug now — verify the repo state, lead " +
|
|
44
|
+
"with hypotheses, and don't barrel toward shipping until it's resolved. " +
|
|
45
|
+
"If the user's words clearly mean otherwise, follow their words.</user_state_update>");
|
|
46
|
+
}
|
|
47
|
+
if (prev === true) {
|
|
48
|
+
return ("<user_state_update>observed work: the conflict is resolved — drop the " +
|
|
49
|
+
"debug framing and return to the user's prior cadence.</user_state_update>");
|
|
50
|
+
}
|
|
51
|
+
return null; // first observation of a clean repo: record silently
|
|
52
|
+
}
|
|
53
|
+
async function loadState() {
|
|
54
|
+
try {
|
|
55
|
+
const raw = JSON.parse(await readFile(STATE_FILE, "utf-8"));
|
|
56
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function saveState(state) {
|
|
63
|
+
try {
|
|
64
|
+
// prune to the newest MAX_SESSIONS so the file can't grow unbounded
|
|
65
|
+
const entries = Object.entries(state)
|
|
66
|
+
.sort((a, b) => b[1].at - a[1].at)
|
|
67
|
+
.slice(0, MAX_SESSIONS);
|
|
68
|
+
await mkdir(join(homedir(), ".cadence"), { recursive: true });
|
|
69
|
+
await writeFile(STATE_FILE, JSON.stringify(Object.fromEntries(entries)), "utf-8");
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// best-effort; a failed save just means we might speak twice
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function readStdin() {
|
|
76
|
+
if (process.stdin.isTTY)
|
|
77
|
+
return {};
|
|
78
|
+
let raw = "";
|
|
79
|
+
for await (const chunk of process.stdin)
|
|
80
|
+
raw += chunk;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(raw);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function main() {
|
|
89
|
+
const input = await readStdin();
|
|
90
|
+
if (!shouldCheck(input))
|
|
91
|
+
return;
|
|
92
|
+
const git = await Promise.race([
|
|
93
|
+
getGitSignal(input.cwd ?? process.cwd()).catch(() => null),
|
|
94
|
+
new Promise((resolve) => setTimeout(() => resolve(null), TOTAL_BUDGET_MS)),
|
|
95
|
+
]);
|
|
96
|
+
if (!git)
|
|
97
|
+
return; // not a repo / git unavailable → nothing to observe
|
|
98
|
+
const key = input.session_id ?? "default";
|
|
99
|
+
const state = await loadState();
|
|
100
|
+
const prev = state[key]?.conflicted;
|
|
101
|
+
const message = refineContext(prev, git.conflicted);
|
|
102
|
+
state[key] = { conflicted: git.conflicted, at: Date.now() };
|
|
103
|
+
await saveState(state);
|
|
104
|
+
if (message) {
|
|
105
|
+
process.stdout.write(JSON.stringify({
|
|
106
|
+
hookSpecificOutput: {
|
|
107
|
+
hookEventName: "PostToolUse",
|
|
108
|
+
additionalContext: message,
|
|
109
|
+
},
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
114
|
+
main().catch((err) => {
|
|
115
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
116
|
+
process.stderr.write(`cadence posttool: ${msg}\n`);
|
|
117
|
+
process.exit(0); // never block the tool loop
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -22,7 +22,9 @@ function sh(cmd, ms = 500) {
|
|
|
22
22
|
* "Put the vibes back into engineering": this is the atmosphere layer.
|
|
23
23
|
* ───────────────────────────────────────────────────────────────────────── */
|
|
24
24
|
const CONFIG_FILE = join(homedir(), ".cadence", "config.json");
|
|
25
|
-
const
|
|
25
|
+
const DND_DIR = join(homedir(), "Library", "DoNotDisturb", "DB");
|
|
26
|
+
const DND_ASSERTIONS = join(DND_DIR, "Assertions.json");
|
|
27
|
+
const DND_MODE_CONFIGS = join(DND_DIR, "ModeConfigurations.json");
|
|
26
28
|
const WEATHER_TIMEOUT_MS = 900;
|
|
27
29
|
const DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
|
28
30
|
function partOfDay(hour) {
|
|
@@ -63,27 +65,78 @@ function getVitals() {
|
|
|
63
65
|
const load1 = loadavg()[0] ?? 0;
|
|
64
66
|
return { uptimeHours, loadHigh: load1 / cores > 0.8 };
|
|
65
67
|
}
|
|
68
|
+
// Is any SCHEDULED Focus window active right now? Pure function over the
|
|
69
|
+
// parsed ModeConfigurations.json (exported for fixture tests). The shape is
|
|
70
|
+
// Apple-private but stable Monterey→Tahoe: each mode's triggers carry
|
|
71
|
+
// enabledSetting (2 = on) and a start/end time-of-day window that may wrap
|
|
72
|
+
// midnight (22:00 → 07:00). Anything unexpected reads as "not active".
|
|
73
|
+
export function scheduleActive(json, now) {
|
|
74
|
+
try {
|
|
75
|
+
const configs = json?.data?.[0]?.modeConfigurations;
|
|
76
|
+
if (!configs || typeof configs !== "object")
|
|
77
|
+
return false;
|
|
78
|
+
const minutes = now.getHours() * 60 + now.getMinutes();
|
|
79
|
+
for (const mode of Object.values(configs)) {
|
|
80
|
+
const triggers = mode?.triggers?.triggers;
|
|
81
|
+
if (!Array.isArray(triggers))
|
|
82
|
+
continue;
|
|
83
|
+
for (const t of triggers) {
|
|
84
|
+
const trig = t;
|
|
85
|
+
if (trig?.enabledSetting !== 2)
|
|
86
|
+
continue; // 2 = schedule enabled
|
|
87
|
+
const { timePeriodStartTimeHour: sh, timePeriodEndTimeHour: eh } = trig;
|
|
88
|
+
if (typeof sh !== "number" || typeof eh !== "number")
|
|
89
|
+
continue;
|
|
90
|
+
const start = sh * 60 + (trig.timePeriodStartTimeMinute ?? 0);
|
|
91
|
+
const end = eh * 60 + (trig.timePeriodEndTimeMinute ?? 0);
|
|
92
|
+
const active = start < end
|
|
93
|
+
? minutes >= start && minutes < end
|
|
94
|
+
: minutes >= start || minutes < end; // window wraps midnight
|
|
95
|
+
if (active)
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
66
105
|
// Focus / DND — tri-state, read straight from the private donotdisturbd DB
|
|
67
106
|
// (~1ms, no subprocess). Exported for the darwin smoke test.
|
|
68
|
-
// true → a Focus mode is asserted (
|
|
69
|
-
//
|
|
107
|
+
// true → a Focus mode is asserted (manual toggle) OR a scheduled
|
|
108
|
+
// Focus window is active right now
|
|
109
|
+
// false → assertions read OK, none set, no schedule active → off
|
|
70
110
|
// undefined → unreadable: terminal lacks Full Disk Access (TCC denies the
|
|
71
111
|
// read silently — hook subprocesses never get a prompt), file
|
|
72
112
|
// moved, or shape changed → "unavailable", never "off"
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
export async function getFocus() {
|
|
113
|
+
// Remaining gap: geofenced/iPhone-synced Focus writes neither an assertion
|
|
114
|
+
// nor a local schedule — undetectable from this Mac.
|
|
115
|
+
export async function getFocus(now = new Date()) {
|
|
76
116
|
if (process.platform !== "darwin")
|
|
77
117
|
return undefined;
|
|
118
|
+
let manual;
|
|
78
119
|
try {
|
|
79
120
|
const raw = await readFile(DND_ASSERTIONS, "utf-8");
|
|
80
121
|
const json = JSON.parse(raw);
|
|
81
122
|
const records = json.data?.[0]?.storeAssertionRecords;
|
|
82
|
-
|
|
123
|
+
manual = Array.isArray(records) && records.length > 0;
|
|
83
124
|
}
|
|
84
125
|
catch {
|
|
85
|
-
|
|
126
|
+
manual = undefined;
|
|
127
|
+
}
|
|
128
|
+
if (manual)
|
|
129
|
+
return true;
|
|
130
|
+
// Manual focus is off (or unknowable) — a scheduled window may still be on.
|
|
131
|
+
try {
|
|
132
|
+
const raw = await readFile(DND_MODE_CONFIGS, "utf-8");
|
|
133
|
+
if (scheduleActive(JSON.parse(raw), now))
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// both files unreadable → truly unknown
|
|
86
138
|
}
|
|
139
|
+
return manual;
|
|
87
140
|
}
|
|
88
141
|
// ── mac context: best-effort shell-outs, all flavor (no dial nudges) ─────────
|
|
89
142
|
async function getMacContext() {
|
package/dist/vibe.js
CHANGED
|
@@ -37,11 +37,55 @@ const GENRE_AFFECT = {
|
|
|
37
37
|
pop: { energy: 0.72, valence: 0.78, acoustic: 0.15, moods: ["happy", "uplifting"] },
|
|
38
38
|
funk: { energy: 0.75, valence: 0.8, acoustic: 0.1, moods: ["happy", "energetic"] },
|
|
39
39
|
disco: { energy: 0.8, valence: 0.85, acoustic: 0.1, moods: ["happy", "uplifting"] },
|
|
40
|
+
"post-hardcore": { energy: 0.85, valence: 0.45, acoustic: 0.05, moods: ["aggressive", "energetic"] },
|
|
41
|
+
"post-punk": { energy: 0.7, valence: 0.4, acoustic: 0.1, moods: ["dark", "energetic"] },
|
|
42
|
+
screamo: { energy: 0.9, valence: 0.3, acoustic: 0.03, moods: ["aggressive", "dark"] },
|
|
43
|
+
grindcore: { energy: 0.98, valence: 0.3, acoustic: 0.02, moods: ["aggressive", "scary"] },
|
|
44
|
+
grunge: { energy: 0.75, valence: 0.4, acoustic: 0.15, moods: ["aggressive", "dark"] },
|
|
45
|
+
emo: { energy: 0.7, valence: 0.35, acoustic: 0.2, moods: ["sad", "energetic"] },
|
|
46
|
+
stoner: { energy: 0.7, valence: 0.5, acoustic: 0.15, moods: ["dark", "energetic"] },
|
|
47
|
+
sludge: { energy: 0.65, valence: 0.25, acoustic: 0.1, moods: ["dark", "aggressive"] },
|
|
48
|
+
industrial: { energy: 0.8, valence: 0.35, acoustic: 0.02, moods: ["dark", "aggressive"] },
|
|
49
|
+
noise: { energy: 0.7, valence: 0.25, acoustic: 0.05, moods: ["scary", "aggressive"] },
|
|
50
|
+
trance: { energy: 0.85, valence: 0.65, acoustic: 0.02, moods: ["energetic", "uplifting"] },
|
|
51
|
+
dubstep: { energy: 0.85, valence: 0.5, acoustic: 0.02, moods: ["energetic", "dark"] },
|
|
52
|
+
edm: { energy: 0.85, valence: 0.7, acoustic: 0.02, moods: ["energetic", "uplifting"] },
|
|
53
|
+
trap: { energy: 0.75, valence: 0.5, acoustic: 0.05, moods: ["energetic", "dark"] },
|
|
54
|
+
drill: { energy: 0.75, valence: 0.4, acoustic: 0.05, moods: ["dark", "aggressive"] },
|
|
55
|
+
grime: { energy: 0.8, valence: 0.5, acoustic: 0.05, moods: ["aggressive", "energetic"] },
|
|
56
|
+
"k-pop": { energy: 0.8, valence: 0.8, acoustic: 0.08, moods: ["happy", "energetic"] },
|
|
57
|
+
"j-pop": { energy: 0.78, valence: 0.8, acoustic: 0.1, moods: ["happy", "uplifting"] },
|
|
58
|
+
"new wave": { energy: 0.7, valence: 0.65, acoustic: 0.08, moods: ["energetic", "happy"] },
|
|
59
|
+
synthpop: { energy: 0.7, valence: 0.7, acoustic: 0.05, moods: ["happy", "energetic"] },
|
|
60
|
+
"synth-pop": { energy: 0.7, valence: 0.7, acoustic: 0.05, moods: ["happy", "energetic"] },
|
|
61
|
+
reggaeton: { energy: 0.8, valence: 0.75, acoustic: 0.05, moods: ["sexy", "energetic"] },
|
|
62
|
+
latin: { energy: 0.75, valence: 0.75, acoustic: 0.3, moods: ["happy", "energetic"] },
|
|
63
|
+
salsa: { energy: 0.8, valence: 0.8, acoustic: 0.4, moods: ["happy", "energetic"] },
|
|
64
|
+
cumbia: { energy: 0.65, valence: 0.75, acoustic: 0.4, moods: ["happy"] },
|
|
65
|
+
afrobeat: { energy: 0.75, valence: 0.75, acoustic: 0.3, moods: ["happy", "energetic"] },
|
|
66
|
+
ska: { energy: 0.8, valence: 0.75, acoustic: 0.15, moods: ["happy", "energetic"] },
|
|
67
|
+
surf: { energy: 0.7, valence: 0.7, acoustic: 0.2, moods: ["happy", "energetic"] },
|
|
68
|
+
"math rock": { energy: 0.75, valence: 0.55, acoustic: 0.2, moods: ["energetic"] },
|
|
40
69
|
// ── mid / groovy → ship-or-think depending on energy ─────────────────
|
|
41
70
|
"r&b": { energy: 0.55, valence: 0.6, acoustic: 0.2, moods: ["sexy", "chilled"] },
|
|
42
71
|
soul: { energy: 0.55, valence: 0.65, acoustic: 0.25, moods: ["romantic", "uplifting"] },
|
|
43
72
|
reggae: { energy: 0.6, valence: 0.75, acoustic: 0.2, moods: ["chilled", "happy"] },
|
|
44
73
|
indie: { energy: 0.6, valence: 0.55, acoustic: 0.3, moods: ["chilled"] },
|
|
74
|
+
alternative: { energy: 0.65, valence: 0.5, acoustic: 0.2, moods: ["energetic"] },
|
|
75
|
+
progressive: { energy: 0.65, valence: 0.5, acoustic: 0.15, moods: ["epic"] },
|
|
76
|
+
psychedelic: { energy: 0.6, valence: 0.55, acoustic: 0.3, moods: ["ethereal"] },
|
|
77
|
+
krautrock: { energy: 0.6, valence: 0.5, acoustic: 0.2, moods: ["ethereal", "energetic"] },
|
|
78
|
+
synthwave: { energy: 0.65, valence: 0.55, acoustic: 0.02, moods: ["energetic", "ethereal"] },
|
|
79
|
+
"city pop": { energy: 0.6, valence: 0.7, acoustic: 0.3, moods: ["happy", "chilled"] },
|
|
80
|
+
garage: { energy: 0.75, valence: 0.6, acoustic: 0.1, moods: ["energetic"] },
|
|
81
|
+
country: { energy: 0.55, valence: 0.6, acoustic: 0.7, moods: ["happy", "romantic"] },
|
|
82
|
+
bluegrass: { energy: 0.65, valence: 0.7, acoustic: 0.9, moods: ["happy", "uplifting"] },
|
|
83
|
+
americana: { energy: 0.45, valence: 0.5, acoustic: 0.8, moods: ["calm", "romantic"] },
|
|
84
|
+
gospel: { energy: 0.6, valence: 0.8, acoustic: 0.6, moods: ["uplifting", "epic"] },
|
|
85
|
+
swing: { energy: 0.65, valence: 0.75, acoustic: 0.6, moods: ["happy"] },
|
|
86
|
+
"big band": { energy: 0.65, valence: 0.7, acoustic: 0.6, moods: ["happy", "epic"] },
|
|
87
|
+
samba: { energy: 0.7, valence: 0.8, acoustic: 0.5, moods: ["happy"] },
|
|
88
|
+
flamenco: { energy: 0.6, valence: 0.5, acoustic: 0.85, moods: ["romantic", "epic"] },
|
|
45
89
|
// ── low energy, organic → think ──────────────────────────────────────
|
|
46
90
|
ambient: { energy: 0.2, valence: 0.5, acoustic: 0.6, moods: ["ethereal", "calm"] },
|
|
47
91
|
"lo-fi": { energy: 0.3, valence: 0.5, acoustic: 0.4, moods: ["chilled", "calm"] },
|
|
@@ -59,23 +103,41 @@ const GENRE_AFFECT = {
|
|
|
59
103
|
"singer-songwriter": { energy: 0.4, valence: 0.5, acoustic: 0.7, moods: ["calm", "sad"] },
|
|
60
104
|
"post-rock": { energy: 0.5, valence: 0.4, acoustic: 0.4, moods: ["epic", "ethereal"] },
|
|
61
105
|
shoegaze: { energy: 0.55, valence: 0.4, acoustic: 0.3, moods: ["ethereal", "dark"] },
|
|
106
|
+
"dream pop": { energy: 0.45, valence: 0.5, acoustic: 0.3, moods: ["ethereal", "chilled"] },
|
|
107
|
+
"bedroom pop": { energy: 0.45, valence: 0.55, acoustic: 0.3, moods: ["chilled"] },
|
|
108
|
+
vaporwave: { energy: 0.35, valence: 0.45, acoustic: 0.1, moods: ["ethereal", "chilled"] },
|
|
109
|
+
idm: { energy: 0.55, valence: 0.45, acoustic: 0.05, moods: ["ethereal"] },
|
|
110
|
+
"bossa nova": { energy: 0.35, valence: 0.6, acoustic: 0.7, moods: ["chilled", "romantic"] },
|
|
111
|
+
"new age": { energy: 0.2, valence: 0.6, acoustic: 0.7, moods: ["calm", "ethereal"] },
|
|
112
|
+
soundtrack: { energy: 0.45, valence: 0.45, acoustic: 0.6, moods: ["epic"] },
|
|
113
|
+
opera: { energy: 0.5, valence: 0.45, acoustic: 0.9, moods: ["epic"] },
|
|
114
|
+
choral: { energy: 0.3, valence: 0.5, acoustic: 0.95, moods: ["ethereal", "epic"] },
|
|
62
115
|
// ── low energy, low valence → debug-leaning ──────────────────────────
|
|
63
116
|
blues: { energy: 0.45, valence: 0.35, acoustic: 0.5, moods: ["sad", "dark"] },
|
|
117
|
+
goth: { energy: 0.55, valence: 0.3, acoustic: 0.2, moods: ["dark"] },
|
|
118
|
+
gothic: { energy: 0.55, valence: 0.3, acoustic: 0.2, moods: ["dark"] },
|
|
119
|
+
darkwave: { energy: 0.6, valence: 0.35, acoustic: 0.1, moods: ["dark", "ethereal"] },
|
|
120
|
+
drone: { energy: 0.15, valence: 0.3, acoustic: 0.4, moods: ["dark", "ethereal"] },
|
|
64
121
|
slowcore: { energy: 0.25, valence: 0.3, acoustic: 0.5, moods: ["sad", "dark"] },
|
|
65
122
|
sad: { energy: 0.3, valence: 0.2, acoustic: 0.5, moods: ["sad"] },
|
|
66
123
|
melancholy: { energy: 0.3, valence: 0.25, acoustic: 0.5, moods: ["sad", "dark"] },
|
|
67
124
|
melancholic: { energy: 0.3, valence: 0.25, acoustic: 0.5, moods: ["sad", "dark"] },
|
|
68
125
|
doom: { energy: 0.5, valence: 0.2, acoustic: 0.2, moods: ["dark", "scary"] },
|
|
69
126
|
};
|
|
127
|
+
// Keys sorted longest-first so the most SPECIFIC row wins per tag: a
|
|
128
|
+
// "post-rock" tag must hit the post-rock row, never fall through to "rock".
|
|
129
|
+
// (Insertion-order matching silently mis-filed every sub-genre whose parent
|
|
130
|
+
// appeared earlier in the table.)
|
|
131
|
+
const GENRE_KEYS = Object.keys(GENRE_AFFECT).sort((a, b) => b.length - a.length);
|
|
70
132
|
/** Match a list of raw tags against the affect table and aggregate. */
|
|
71
133
|
export function tagsToVibe(tags) {
|
|
72
134
|
const hits = [];
|
|
73
135
|
for (const raw of tags) {
|
|
74
136
|
const t = raw.toLowerCase();
|
|
75
|
-
for (const key
|
|
137
|
+
for (const key of GENRE_KEYS) {
|
|
76
138
|
if (t.includes(key)) {
|
|
77
139
|
hits.push(GENRE_AFFECT[key]);
|
|
78
|
-
break; // one row per tag —
|
|
140
|
+
break; // one row per tag — the longest (most specific) match wins
|
|
79
141
|
}
|
|
80
142
|
}
|
|
81
143
|
}
|
package/hooks/hooks.json
CHANGED
package/package.json
CHANGED