@cullumco/cadence 0.1.4 → 0.1.6
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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +114 -66
- package/dist/cadence.js +58 -17
- package/dist/cli.js +238 -29
- package/dist/config.js +77 -0
- package/dist/hook.js +40 -18
- package/dist/inject.js +21 -3
- package/dist/posttool.js +126 -9
- package/dist/providers/activity.js +45 -4
- package/dist/providers/{ambient.js → environment.js} +67 -8
- package/dist/providers/esoteric.js +77 -0
- package/dist/providers/git.js +4 -3
- package/dist/providers/intent.js +37 -0
- package/dist/providers/moon.js +51 -0
- package/dist/providers/music.js +15 -3
- package/dist/providers/selfreport.js +6 -1
- package/dist/providers/spotify.js +142 -0
- package/dist/session-start.js +38 -8
- package/dist/signals-view.js +45 -11
- package/dist/spotify-auth.js +136 -0
- package/dist/stop.js +9 -6
- package/dist/types.js +1 -0
- package/dist/vibe.js +10 -6
- package/package.json +2 -2
- package/skills/pause/SKILL.md +16 -0
- package/skills/resume/SKILL.md +12 -0
- package/skills/setup/SKILL.md +43 -0
- package/skills/state/SKILL.md +3 -3
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { providerEnabled } from "../config.js";
|
|
5
|
+
import { debug } from "../debug.js";
|
|
6
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
7
|
+
* Spotify now-playing — the CROSS-PLATFORM music source (opt-in).
|
|
8
|
+
*
|
|
9
|
+
* macOS already reads Spotify.app / Music.app via AppleScript; this is for the
|
|
10
|
+
* Linux/Windows user (or anyone who'd rather not script the desktop app). It
|
|
11
|
+
* is NOT the deprecated audio-features API — only `currently-playing`, which
|
|
12
|
+
* is still live. Vibe still comes from MusicBrainz downstream, so this returns
|
|
13
|
+
* identity only (track + artist), exactly like the AppleScript path.
|
|
14
|
+
*
|
|
15
|
+
* Opt-in and BYO-credentials — no shared client, no callback server in a
|
|
16
|
+
* background hook. The user registers their own Spotify app and supplies a
|
|
17
|
+
* refresh token + client id (see `cadence spotify`); we refresh the short-lived
|
|
18
|
+
* access token ourselves and cache it so a normal prompt makes ONE request.
|
|
19
|
+
* Fail-silent throughout: any hiccup degrades to "no music," never throws.
|
|
20
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
21
|
+
const TOKEN_CACHE = join(homedir(), ".cadence", "spotify-token.json");
|
|
22
|
+
const REFRESH_TIMEOUT_MS = 800;
|
|
23
|
+
const NOWPLAYING_TIMEOUT_MS = 800;
|
|
24
|
+
const TOKEN_SKEW_MS = 60_000; // refresh a minute early so a live token can't expire mid-flight
|
|
25
|
+
/** Pull and validate the Spotify creds out of the provider registry, or null
|
|
26
|
+
* when the user hasn't opted in / the shape is incomplete. Pure + exported. */
|
|
27
|
+
export function readCreds(providers) {
|
|
28
|
+
if (!providerEnabled(providers, "spotify"))
|
|
29
|
+
return null;
|
|
30
|
+
const v = providers["spotify"];
|
|
31
|
+
if (!v || typeof v !== "object")
|
|
32
|
+
return null;
|
|
33
|
+
const o = v;
|
|
34
|
+
const refreshToken = o["refreshToken"];
|
|
35
|
+
const clientId = o["clientId"];
|
|
36
|
+
if (typeof refreshToken !== "string" || typeof clientId !== "string")
|
|
37
|
+
return null;
|
|
38
|
+
if (!refreshToken || !clientId)
|
|
39
|
+
return null;
|
|
40
|
+
const clientSecret = typeof o["clientSecret"] === "string" ? o["clientSecret"] : undefined;
|
|
41
|
+
return { refreshToken, clientId, clientSecret };
|
|
42
|
+
}
|
|
43
|
+
async function loadToken() {
|
|
44
|
+
try {
|
|
45
|
+
const t = JSON.parse(await readFile(TOKEN_CACHE, "utf-8"));
|
|
46
|
+
return typeof t?.accessToken === "string" && typeof t?.expiresAt === "number" ? t : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function saveToken(t) {
|
|
53
|
+
try {
|
|
54
|
+
await mkdir(join(homedir(), ".cadence"), { recursive: true });
|
|
55
|
+
await writeFile(TOKEN_CACHE, JSON.stringify(t), "utf-8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// best-effort; a failed cache just means we refresh again next prompt
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function refreshAccessToken(creds) {
|
|
62
|
+
const ctrl = new AbortController();
|
|
63
|
+
const timer = setTimeout(() => ctrl.abort(), REFRESH_TIMEOUT_MS);
|
|
64
|
+
try {
|
|
65
|
+
const body = new URLSearchParams({
|
|
66
|
+
grant_type: "refresh_token",
|
|
67
|
+
refresh_token: creds.refreshToken,
|
|
68
|
+
client_id: creds.clientId, // in-body client_id covers PKCE/public apps
|
|
69
|
+
});
|
|
70
|
+
const headers = {
|
|
71
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
72
|
+
};
|
|
73
|
+
if (creds.clientSecret) {
|
|
74
|
+
const basic = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString("base64");
|
|
75
|
+
headers["Authorization"] = `Basic ${basic}`;
|
|
76
|
+
}
|
|
77
|
+
const res = await fetch("https://accounts.spotify.com/api/token", {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers,
|
|
80
|
+
body,
|
|
81
|
+
signal: ctrl.signal,
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
debug("spotify", `token refresh failed: ${res.status}`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const data = (await res.json());
|
|
88
|
+
if (!data.access_token)
|
|
89
|
+
return null;
|
|
90
|
+
await saveToken({
|
|
91
|
+
accessToken: data.access_token,
|
|
92
|
+
expiresAt: Date.now() + (data.expires_in ?? 3600) * 1000,
|
|
93
|
+
});
|
|
94
|
+
return data.access_token;
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
debug("spotify", `token refresh error: ${e instanceof Error ? e.message : String(e)}`);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function getAccessToken(creds) {
|
|
105
|
+
const cached = await loadToken();
|
|
106
|
+
if (cached && cached.expiresAt - TOKEN_SKEW_MS > Date.now())
|
|
107
|
+
return cached.accessToken;
|
|
108
|
+
return refreshAccessToken(creds);
|
|
109
|
+
}
|
|
110
|
+
export async function getSpotifyNowPlaying(providers) {
|
|
111
|
+
const creds = readCreds(providers);
|
|
112
|
+
if (!creds)
|
|
113
|
+
return null;
|
|
114
|
+
const token = await getAccessToken(creds);
|
|
115
|
+
if (!token)
|
|
116
|
+
return null;
|
|
117
|
+
const ctrl = new AbortController();
|
|
118
|
+
const timer = setTimeout(() => ctrl.abort(), NOWPLAYING_TIMEOUT_MS);
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch("https://api.spotify.com/v1/me/player/currently-playing", {
|
|
121
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
122
|
+
signal: ctrl.signal,
|
|
123
|
+
});
|
|
124
|
+
if (res.status === 204 || !res.ok)
|
|
125
|
+
return null; // 204 = nothing playing
|
|
126
|
+
const data = (await res.json());
|
|
127
|
+
if (data.is_playing === false)
|
|
128
|
+
return null;
|
|
129
|
+
const track = data.item?.name;
|
|
130
|
+
const artist = data.item?.artists?.map((a) => a.name).filter(Boolean).join(", ");
|
|
131
|
+
if (!track || !artist)
|
|
132
|
+
return null;
|
|
133
|
+
return { track, artist, player: "Spotify" };
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
debug("spotify", `now-playing error: ${e instanceof Error ? e.message : String(e)}`);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/session-start.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { getMusicSignal } from "./providers/music.js";
|
|
3
|
-
import { getSelfReportSignal } from "./providers/selfreport.js";
|
|
3
|
+
import { getSelfReportSignal, STALE_AFTER_MS, REFRESH_SOON_MS } from "./providers/selfreport.js";
|
|
4
4
|
import { loadOverrides } from "./cadence.js";
|
|
5
|
+
import { isPaused } from "./config.js";
|
|
5
6
|
import { debug } from "./debug.js";
|
|
6
7
|
/* ─────────────────────────────────────────────────────────────────────────
|
|
7
8
|
* Claude Code SessionStart adapter: one short, human-facing line when a
|
|
@@ -16,24 +17,50 @@ import { debug } from "./debug.js";
|
|
|
16
17
|
const BUDGET_MS = 700;
|
|
17
18
|
// The voice of the product's first impression. Return null to stay silent.
|
|
18
19
|
// Default policy: always one line on startup — say what's seen when there
|
|
19
|
-
// are signals, point at the inputs when there's nothing yet
|
|
20
|
+
// are signals, point at the inputs when there's nothing yet, and invite a
|
|
21
|
+
// refresh when the self-report is about to go stale ("inquire about updating").
|
|
20
22
|
export function composeHint(info) {
|
|
23
|
+
// Paused: the model-facing hooks are silent, but this line is for the USER —
|
|
24
|
+
// say so once per session so "off" never reads as "broken".
|
|
25
|
+
if (info.paused) {
|
|
26
|
+
return "cadence: paused — prompts go through untouched (`cadence resume` or /cadence:resume to turn it back on)";
|
|
27
|
+
}
|
|
21
28
|
if (info.firstRun) {
|
|
22
|
-
return 'cadence: on, but it hasn\'t heard from you — try `cadence start` (or just `cadence
|
|
29
|
+
return 'cadence: on, but it hasn\'t heard from you — try `cadence start` (or just `cadence report "deep work"`)';
|
|
23
30
|
}
|
|
31
|
+
const expiringSoon = info.selfReport != null &&
|
|
32
|
+
info.selfReportRemainingMs != null &&
|
|
33
|
+
info.selfReportRemainingMs <= REFRESH_SOON_MS;
|
|
24
34
|
const seen = [];
|
|
25
|
-
if (info.selfReport)
|
|
26
|
-
seen.push(`state "${info.selfReport}"`);
|
|
35
|
+
if (info.selfReport) {
|
|
36
|
+
seen.push(`state "${info.selfReport}"${expiringSoon ? " (expiring)" : ""}`);
|
|
37
|
+
}
|
|
27
38
|
if (info.nowPlaying)
|
|
28
39
|
seen.push(`${info.nowPlaying.player}: ${info.nowPlaying.artist}`);
|
|
29
40
|
if (info.pinned.length)
|
|
30
41
|
seen.push(`pinned ${info.pinned.join(", ")}`);
|
|
31
42
|
if (seen.length === 0) {
|
|
32
|
-
return 'cadence: live, no signals right now — `cadence
|
|
43
|
+
return 'cadence: live, no signals right now — `cadence report "..."` to give it one';
|
|
33
44
|
}
|
|
34
|
-
|
|
45
|
+
// When state is about to expire, the inputs hint becomes an explicit nudge to
|
|
46
|
+
// re-declare — so a long session keeps the cadence honest as the room shifts.
|
|
47
|
+
const tail = expiringSoon
|
|
48
|
+
? "still in this cadence? `cadence report \"...\"` to refresh"
|
|
49
|
+
: "inputs: cadence report | dials";
|
|
50
|
+
return `cadence: live — ${seen.join(" · ")} (${tail})`;
|
|
35
51
|
}
|
|
36
52
|
async function collectInfo() {
|
|
53
|
+
// Paused short-circuits everything — don't even probe for music.
|
|
54
|
+
if (await isPaused()) {
|
|
55
|
+
return {
|
|
56
|
+
selfReport: null,
|
|
57
|
+
selfReportRemainingMs: null,
|
|
58
|
+
pinned: [],
|
|
59
|
+
nowPlaying: null,
|
|
60
|
+
firstRun: false,
|
|
61
|
+
paused: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
37
64
|
// Race music against the budget — MusicBrainz on a brand-new artist can
|
|
38
65
|
// be slow, and a session greeting must never delay the session.
|
|
39
66
|
const [report, overrides, music] = await Promise.all([
|
|
@@ -41,15 +68,18 @@ async function collectInfo() {
|
|
|
41
68
|
loadOverrides(),
|
|
42
69
|
Promise.race([
|
|
43
70
|
getMusicSignal().catch(() => null),
|
|
44
|
-
new Promise((resolve) => setTimeout(() => resolve(null), BUDGET_MS)),
|
|
71
|
+
new Promise((resolve) => setTimeout(() => resolve(null), BUDGET_MS).unref()),
|
|
45
72
|
]),
|
|
46
73
|
]);
|
|
47
74
|
const pinned = Object.keys(overrides);
|
|
75
|
+
const remaining = report ? Math.max(0, STALE_AFTER_MS - (Date.now() - report.setAt)) : null;
|
|
48
76
|
return {
|
|
49
77
|
selfReport: report?.text ?? null,
|
|
78
|
+
selfReportRemainingMs: remaining,
|
|
50
79
|
pinned,
|
|
51
80
|
nowPlaying: music?.artist ? { artist: music.artist, player: music.player ?? "music" } : null,
|
|
52
81
|
firstRun: !report && pinned.length === 0,
|
|
82
|
+
paused: false,
|
|
53
83
|
};
|
|
54
84
|
}
|
|
55
85
|
async function main() {
|
package/dist/signals-view.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { STALE_AFTER_MS } from "./providers/selfreport.js";
|
|
2
|
+
import { providerEnabled, providerSetting } from "./config.js";
|
|
2
3
|
const LABEL_W = 12; // sub-row label column
|
|
3
4
|
const VALUE_W = 18; // value column, before a "(hidden: …)" note
|
|
4
5
|
function row(label, value, note) {
|
|
@@ -14,12 +15,12 @@ function ttlLeft(setAt, now) {
|
|
|
14
15
|
const m = Math.floor((rem % 3_600_000) / 60_000);
|
|
15
16
|
return h > 0 ? `${h}h${String(m).padStart(2, "0")}m left` : `${m}m left`;
|
|
16
17
|
}
|
|
17
|
-
function
|
|
18
|
+
function environmentRows(a, platform, providers) {
|
|
18
19
|
if (!a)
|
|
19
|
-
return [top("
|
|
20
|
+
return [top("environment", "— unavailable")];
|
|
20
21
|
const mac = platform === "darwin";
|
|
21
22
|
const macNote = "— macOS only";
|
|
22
|
-
const lines = ["
|
|
23
|
+
const lines = [" environment"];
|
|
23
24
|
lines.push(row("time", `${a.partOfDay} (${a.dayOfWeek})`));
|
|
24
25
|
lines.push(row("weather", a.weather ?? "— off (run: cadence set-location <lat> <lon>)"));
|
|
25
26
|
lines.push(!mac
|
|
@@ -45,9 +46,11 @@ function ambientRows(a, platform) {
|
|
|
45
46
|
: row("displays", String(a.displays), "(hidden: only shows >1)"));
|
|
46
47
|
lines.push(!mac
|
|
47
48
|
? row("wifi", macNote)
|
|
48
|
-
:
|
|
49
|
-
? row("wifi",
|
|
50
|
-
:
|
|
49
|
+
: !providerEnabled(providers, "wifi")
|
|
50
|
+
? row("wifi", "— off (run: cadence enable wifi)")
|
|
51
|
+
: a.network
|
|
52
|
+
? row("wifi", JSON.stringify(a.network))
|
|
53
|
+
: row("wifi", "— unavailable (macOS may require Location Services)"));
|
|
51
54
|
lines.push(a.uptimeHours == null
|
|
52
55
|
? row("uptime", "— unavailable")
|
|
53
56
|
: a.uptimeHours >= 12
|
|
@@ -65,17 +68,21 @@ function ambientRows(a, platform) {
|
|
|
65
68
|
: row("focus", "off", "(hidden: only shows on)"));
|
|
66
69
|
return lines;
|
|
67
70
|
}
|
|
68
|
-
function musicRows(m) {
|
|
71
|
+
function musicRows(m, providers) {
|
|
72
|
+
const spotify = providerEnabled(providers, "spotify")
|
|
73
|
+
? row("source", "macOS apps + Spotify (cross-platform, linked)")
|
|
74
|
+
: row("source", "macOS apps only", "(cross-platform: cadence spotify)");
|
|
69
75
|
if (!m?.track)
|
|
70
|
-
return [top("music", "— nothing playing")];
|
|
76
|
+
return [top("music", "— nothing playing"), spotify];
|
|
71
77
|
const lines = [" music"];
|
|
72
78
|
lines.push(row("track", `${JSON.stringify(m.track)}${m.artist ? ` — ${m.artist}` : ""}${m.player ? ` (${m.player})` : ""}`));
|
|
73
79
|
lines.push(m.vibe ? row("vibe", m.vibe) : row("vibe", "— no tags yet (looked up once per artist)"));
|
|
80
|
+
lines.push(spotify);
|
|
74
81
|
return lines;
|
|
75
82
|
}
|
|
76
83
|
function reportRow(r, now) {
|
|
77
84
|
if (!r)
|
|
78
|
-
return top("self_report", '— none set (run: cadence
|
|
85
|
+
return top("self_report", '— none set (run: cadence report "...")');
|
|
79
86
|
const text = r.text.length > 44 ? `${r.text.slice(0, 43)}…` : r.text;
|
|
80
87
|
return top("self_report", `${JSON.stringify(text)} (${ttlLeft(r.setAt, now)})`);
|
|
81
88
|
}
|
|
@@ -92,12 +99,39 @@ function gitRow(g) {
|
|
|
92
99
|
].filter(Boolean);
|
|
93
100
|
return top("git", parts.join(", "));
|
|
94
101
|
}
|
|
102
|
+
function intentRow() {
|
|
103
|
+
return top("intent", "— reads your prompt (the hook infers ship/think/debug per-prompt)");
|
|
104
|
+
}
|
|
105
|
+
function tempoRow(providers) {
|
|
106
|
+
return providerEnabled(providers, "typingTempo")
|
|
107
|
+
? top("typing tempo", "on (opt-in) — rapid vs. considered prompt rhythm → pace")
|
|
108
|
+
: top("typing tempo", "— off (opt-in: cadence enable typingTempo)");
|
|
109
|
+
}
|
|
110
|
+
function optInFlavorRows(providers, environment) {
|
|
111
|
+
const sign = providerSetting(providers, "horoscope");
|
|
112
|
+
return [
|
|
113
|
+
" opt-in flavor",
|
|
114
|
+
row("focused app", providerEnabled(providers, "focusedApp")
|
|
115
|
+
? environment?.focusedApp ?? "on — nothing non-terminal in front"
|
|
116
|
+
: "— off (cadence enable focusedApp)"),
|
|
117
|
+
row("moon", providerEnabled(providers, "moon")
|
|
118
|
+
? "on — phase shows in the block"
|
|
119
|
+
: "— off (cadence enable moon)"),
|
|
120
|
+
row("horoscope", typeof sign === "string"
|
|
121
|
+
? `on (${sign}) — daily text shows in the block`
|
|
122
|
+
: "— off (cadence enable horoscope <sign>)"),
|
|
123
|
+
];
|
|
124
|
+
}
|
|
95
125
|
export function renderSignalsTable(raw) {
|
|
126
|
+
const providers = raw.providers ?? {};
|
|
96
127
|
return [
|
|
97
|
-
...
|
|
98
|
-
...musicRows(raw.music),
|
|
128
|
+
...environmentRows(raw.environment, raw.platform, raw.providers ?? {}),
|
|
129
|
+
...musicRows(raw.music, providers),
|
|
99
130
|
reportRow(raw.report, raw.now),
|
|
131
|
+
intentRow(),
|
|
100
132
|
gitRow(raw.git),
|
|
101
133
|
top("activity", "— session-only (the hook injects it per-prompt)"),
|
|
134
|
+
tempoRow(providers),
|
|
135
|
+
...optInFlavorRows(providers, raw.environment),
|
|
102
136
|
].join("\n");
|
|
103
137
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* Spotify connect — Authorization Code + PKCE, run from the INTERACTIVE CLI.
|
|
6
|
+
*
|
|
7
|
+
* A distributed CLI can't keep a client secret, so we use PKCE (public client,
|
|
8
|
+
* no secret). The flow: spin up a one-shot loopback server, open the browser to
|
|
9
|
+
* Spotify's consent page, catch the redirect with the auth code, exchange it for
|
|
10
|
+
* a refresh token, and hand that back to the CLI to store in `providers.spotify`
|
|
11
|
+
* — the exact shape the fail-silent hook-side provider already reads.
|
|
12
|
+
*
|
|
13
|
+
* This NEVER runs in the hook (no browser, no server in a 1.5s budget). The hook
|
|
14
|
+
* only ever reads the cached refresh token. Keep it that way.
|
|
15
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
16
|
+
// Must be registered verbatim in the Spotify app's redirect URIs. Spotify
|
|
17
|
+
// requires the explicit loopback IP (not "localhost") for native/CLI apps.
|
|
18
|
+
export const REDIRECT_PORT = 8888;
|
|
19
|
+
export const REDIRECT_URI = `http://127.0.0.1:${REDIRECT_PORT}/callback`;
|
|
20
|
+
export const SCOPE = "user-read-currently-playing";
|
|
21
|
+
const AUTH_TIMEOUT_MS = 120_000;
|
|
22
|
+
/** PKCE pair: a high-entropy verifier and its S256 challenge. Pure. */
|
|
23
|
+
export function generatePkce() {
|
|
24
|
+
const verifier = randomBytes(32).toString("base64url"); // 43 chars, within 43–128
|
|
25
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
26
|
+
return { verifier, challenge };
|
|
27
|
+
}
|
|
28
|
+
/** Build the Spotify consent URL. Pure + exported so the params are testable. */
|
|
29
|
+
export function buildAuthorizeUrl(opts) {
|
|
30
|
+
const q = new URLSearchParams({
|
|
31
|
+
response_type: "code",
|
|
32
|
+
client_id: opts.clientId,
|
|
33
|
+
redirect_uri: REDIRECT_URI,
|
|
34
|
+
scope: SCOPE,
|
|
35
|
+
code_challenge_method: "S256",
|
|
36
|
+
code_challenge: opts.challenge,
|
|
37
|
+
state: opts.state,
|
|
38
|
+
});
|
|
39
|
+
return `https://accounts.spotify.com/authorize?${q.toString()}`;
|
|
40
|
+
}
|
|
41
|
+
/** Pull the refresh token out of Spotify's token response, or null. Pure. */
|
|
42
|
+
export function parseTokenResponse(json) {
|
|
43
|
+
const data = json;
|
|
44
|
+
return typeof data?.refresh_token === "string" && data.refresh_token ? data.refresh_token : null;
|
|
45
|
+
}
|
|
46
|
+
// Best-effort browser open; if it fails we've already printed the URL to paste.
|
|
47
|
+
function openBrowser(url) {
|
|
48
|
+
const cmd = process.platform === "darwin"
|
|
49
|
+
? "open"
|
|
50
|
+
: process.platform === "win32"
|
|
51
|
+
? "start \"\""
|
|
52
|
+
: "xdg-open";
|
|
53
|
+
const child = exec(`${cmd} "${url}"`, () => { });
|
|
54
|
+
child.on("error", () => { });
|
|
55
|
+
}
|
|
56
|
+
// Wait for Spotify to redirect back with ?code=&state=, validating state.
|
|
57
|
+
function waitForCode(expectedState) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const server = createServer((req, res) => {
|
|
60
|
+
const url = new URL(req.url ?? "/", REDIRECT_URI);
|
|
61
|
+
if (url.pathname !== "/callback") {
|
|
62
|
+
res.writeHead(404).end();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const code = url.searchParams.get("code");
|
|
66
|
+
const state = url.searchParams.get("state");
|
|
67
|
+
const err = url.searchParams.get("error");
|
|
68
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
69
|
+
const done = (msg) => res.end(`<!doctype html><meta charset=utf-8><body style="font:16px system-ui;padding:3rem">${msg} — you can close this tab.</body>`);
|
|
70
|
+
if (err) {
|
|
71
|
+
done(`Spotify returned "${err}"`);
|
|
72
|
+
cleanup();
|
|
73
|
+
reject(new Error(`authorization denied: ${err}`));
|
|
74
|
+
}
|
|
75
|
+
else if (!code || state !== expectedState) {
|
|
76
|
+
done("Something didn't line up");
|
|
77
|
+
cleanup();
|
|
78
|
+
reject(new Error("missing code or state mismatch"));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
done("Spotify linked ✓");
|
|
82
|
+
cleanup();
|
|
83
|
+
resolve(code);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
cleanup();
|
|
88
|
+
reject(new Error("timed out waiting for Spotify authorization"));
|
|
89
|
+
}, AUTH_TIMEOUT_MS);
|
|
90
|
+
function cleanup() {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
server.close();
|
|
93
|
+
}
|
|
94
|
+
server.on("error", (e) => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
reject(e.code === "EADDRINUSE"
|
|
97
|
+
? new Error(`port ${REDIRECT_PORT} is in use — close whatever's on it and retry`)
|
|
98
|
+
: e);
|
|
99
|
+
});
|
|
100
|
+
server.listen(REDIRECT_PORT, "127.0.0.1");
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function exchangeCode(clientId, code, verifier) {
|
|
104
|
+
const res = await fetch("https://accounts.spotify.com/api/token", {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
107
|
+
body: new URLSearchParams({
|
|
108
|
+
grant_type: "authorization_code",
|
|
109
|
+
code,
|
|
110
|
+
redirect_uri: REDIRECT_URI,
|
|
111
|
+
client_id: clientId,
|
|
112
|
+
code_verifier: verifier,
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
throw new Error(`token exchange failed: ${res.status} ${await res.text()}`);
|
|
117
|
+
const refreshToken = parseTokenResponse(await res.json());
|
|
118
|
+
if (!refreshToken)
|
|
119
|
+
throw new Error("Spotify did not return a refresh token");
|
|
120
|
+
return refreshToken;
|
|
121
|
+
}
|
|
122
|
+
/** Run the full browser flow. Resolves with a refresh token to store, or
|
|
123
|
+
* throws with a human-readable reason. Logs progress via the passed printer so
|
|
124
|
+
* the CLI owns all stdout. */
|
|
125
|
+
export async function connectSpotify(clientId, log) {
|
|
126
|
+
const { verifier, challenge } = generatePkce();
|
|
127
|
+
const state = randomBytes(16).toString("hex");
|
|
128
|
+
const url = buildAuthorizeUrl({ clientId, challenge, state });
|
|
129
|
+
log(" Opening Spotify in your browser to authorize…");
|
|
130
|
+
log(" If it doesn't open, paste this:\n");
|
|
131
|
+
log(` ${url}\n`);
|
|
132
|
+
openBrowser(url);
|
|
133
|
+
const code = await waitForCode(state);
|
|
134
|
+
log(" Got it — exchanging for a refresh token…");
|
|
135
|
+
return exchangeCode(clientId, code, verifier);
|
|
136
|
+
}
|
package/dist/stop.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
3
|
import { getMusicSignal } from "./providers/music.js";
|
|
4
4
|
import { getSelfReportSignal } from "./providers/selfreport.js";
|
|
5
|
-
import {
|
|
5
|
+
import { getEnvironmentSignal } from "./providers/environment.js";
|
|
6
6
|
import { getGitSignal } from "./providers/git.js";
|
|
7
7
|
import { deriveCadence, loadOverrides, applyOverrides } from "./cadence.js";
|
|
8
|
+
import { isPaused } from "./config.js";
|
|
8
9
|
const TOTAL_BUDGET_MS = 1500;
|
|
9
10
|
async function readStdin() {
|
|
10
11
|
if (process.stdin.isTTY)
|
|
@@ -20,10 +21,10 @@ async function readStdin() {
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
async function collectSignals(cwd) {
|
|
23
|
-
const [music, report,
|
|
24
|
+
const [music, report, environment, git] = await Promise.allSettled([
|
|
24
25
|
getMusicSignal(),
|
|
25
26
|
getSelfReportSignal(),
|
|
26
|
-
|
|
27
|
+
getEnvironmentSignal(new Date()),
|
|
27
28
|
getGitSignal(cwd),
|
|
28
29
|
]);
|
|
29
30
|
const signals = [];
|
|
@@ -31,8 +32,8 @@ async function collectSignals(cwd) {
|
|
|
31
32
|
signals.push(music.value);
|
|
32
33
|
if (report.status === "fulfilled" && report.value)
|
|
33
34
|
signals.push(report.value);
|
|
34
|
-
if (
|
|
35
|
-
signals.push(
|
|
35
|
+
if (environment.status === "fulfilled" && environment.value)
|
|
36
|
+
signals.push(environment.value);
|
|
36
37
|
if (git.status === "fulfilled" && git.value)
|
|
37
38
|
signals.push(git.value);
|
|
38
39
|
return signals;
|
|
@@ -75,12 +76,14 @@ export function decideStop(input, signals, cadence, pinned) {
|
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
78
|
async function main() {
|
|
79
|
+
if (await isPaused())
|
|
80
|
+
return; // user asked for silence — never block while paused
|
|
78
81
|
const input = await readStdin();
|
|
79
82
|
const projectDir = input.cwd ?? process.cwd();
|
|
80
83
|
const [signals, overrides] = await Promise.all([
|
|
81
84
|
Promise.race([
|
|
82
85
|
collectSignals(projectDir),
|
|
83
|
-
new Promise((resolve) => setTimeout(() => resolve([]), TOTAL_BUDGET_MS)),
|
|
86
|
+
new Promise((resolve) => setTimeout(() => resolve([]), TOTAL_BUDGET_MS).unref()),
|
|
84
87
|
]),
|
|
85
88
|
loadOverrides(),
|
|
86
89
|
]);
|
package/dist/types.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* The four embodied dimensions, each a provider emitting one Signal:
|
|
10
10
|
* - MusicSignal → what's playing (music)
|
|
11
11
|
* - SelfReportSignal → what you told us (mood, ground truth)
|
|
12
|
+
* - IntentSignal → what your prompt implies (mood, inferred from words)
|
|
12
13
|
* - ActivitySignal → your motor/typing tempo (mood, inferred)
|
|
13
14
|
* - GitSignal → your work state (context)
|
|
14
15
|
* - PlaceSignal → where & in what setting (place)
|
package/dist/vibe.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* (energy: death-metal high → Bach low; valence: euphoric high → depressed low).
|
|
21
21
|
* Order doesn't matter — every matching row is averaged. */
|
|
22
22
|
const GENRE_AFFECT = {
|
|
23
|
-
// ── high energy →
|
|
23
|
+
// ── high energy → pace high ──────────────────────────────────────────
|
|
24
24
|
punk: { energy: 0.9, valence: 0.6, acoustic: 0.05, moods: ["aggressive", "energetic"] },
|
|
25
25
|
metal: { energy: 0.95, valence: 0.4, acoustic: 0.02, moods: ["aggressive", "dark"] },
|
|
26
26
|
hardcore: { energy: 0.95, valence: 0.45, acoustic: 0.02, moods: ["aggressive", "energetic"] },
|
|
@@ -66,7 +66,7 @@ const GENRE_AFFECT = {
|
|
|
66
66
|
ska: { energy: 0.8, valence: 0.75, acoustic: 0.15, moods: ["happy", "energetic"] },
|
|
67
67
|
surf: { energy: 0.7, valence: 0.7, acoustic: 0.2, moods: ["happy", "energetic"] },
|
|
68
68
|
"math rock": { energy: 0.75, valence: 0.55, acoustic: 0.2, moods: ["energetic"] },
|
|
69
|
-
// ── mid / groovy →
|
|
69
|
+
// ── mid / groovy → pace dead zone (0.4 < energy < 0.7 = no nudge) ────
|
|
70
70
|
"r&b": { energy: 0.55, valence: 0.6, acoustic: 0.2, moods: ["sexy", "chilled"] },
|
|
71
71
|
soul: { energy: 0.55, valence: 0.65, acoustic: 0.25, moods: ["romantic", "uplifting"] },
|
|
72
72
|
reggae: { energy: 0.6, valence: 0.75, acoustic: 0.2, moods: ["chilled", "happy"] },
|
|
@@ -86,7 +86,7 @@ const GENRE_AFFECT = {
|
|
|
86
86
|
"big band": { energy: 0.65, valence: 0.7, acoustic: 0.6, moods: ["happy", "epic"] },
|
|
87
87
|
samba: { energy: 0.7, valence: 0.8, acoustic: 0.5, moods: ["happy"] },
|
|
88
88
|
flamenco: { energy: 0.6, valence: 0.5, acoustic: 0.85, moods: ["romantic", "epic"] },
|
|
89
|
-
// ── low energy, organic →
|
|
89
|
+
// ── low energy, organic → pace low, tone warm ────────────────────────
|
|
90
90
|
ambient: { energy: 0.2, valence: 0.5, acoustic: 0.6, moods: ["ethereal", "calm"] },
|
|
91
91
|
"lo-fi": { energy: 0.3, valence: 0.5, acoustic: 0.4, moods: ["chilled", "calm"] },
|
|
92
92
|
lofi: { energy: 0.3, valence: 0.5, acoustic: 0.4, moods: ["chilled", "calm"] },
|
|
@@ -112,7 +112,8 @@ const GENRE_AFFECT = {
|
|
|
112
112
|
soundtrack: { energy: 0.45, valence: 0.45, acoustic: 0.6, moods: ["epic"] },
|
|
113
113
|
opera: { energy: 0.5, valence: 0.45, acoustic: 0.9, moods: ["epic"] },
|
|
114
114
|
choral: { energy: 0.3, valence: 0.5, acoustic: 0.95, moods: ["ethereal", "epic"] },
|
|
115
|
-
// ── low energy, low valence →
|
|
115
|
+
// ── low energy, low valence → pace low; dark moods are render-only ───
|
|
116
|
+
// (valence currently moves NO dial — see BACKLOG "Valence boundary")
|
|
116
117
|
blues: { energy: 0.45, valence: 0.35, acoustic: 0.5, moods: ["sad", "dark"] },
|
|
117
118
|
goth: { energy: 0.55, valence: 0.3, acoustic: 0.2, moods: ["dark"] },
|
|
118
119
|
gothic: { energy: 0.55, valence: 0.3, acoustic: 0.2, moods: ["dark"] },
|
|
@@ -143,7 +144,10 @@ export function tagsToVibe(tags) {
|
|
|
143
144
|
}
|
|
144
145
|
if (hits.length === 0)
|
|
145
146
|
return null;
|
|
146
|
-
const
|
|
147
|
+
const avg = (pick) => hits.reduce((s, a) => s + pick(a), 0) / hits.length;
|
|
148
|
+
const energy = avg((a) => a.energy);
|
|
149
|
+
const valence = avg((a) => a.valence);
|
|
150
|
+
const acoustic = avg((a) => a.acoustic);
|
|
147
151
|
// Mood words: collect from all hits, dedupe, keep most frequent first.
|
|
148
152
|
const moodCounts = new Map();
|
|
149
153
|
for (const h of hits)
|
|
@@ -154,5 +158,5 @@ export function tagsToVibe(tags) {
|
|
|
154
158
|
.sort((a, b) => b[1] - a[1])
|
|
155
159
|
.slice(0, 4)
|
|
156
160
|
.map(([m]) => m);
|
|
157
|
-
return { moods, energy };
|
|
161
|
+
return { moods, energy, valence, acoustic };
|
|
158
162
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cullumco/cadence",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Agents that read the room. Ambient context, cadence dials, and finish-line guardrails for Claude Code. macOS-only alpha.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cadence": "bin/cadence"
|
|
8
8
|
},
|
|
9
|
-
"homepage": "https://
|
|
9
|
+
"homepage": "https://cadence.cullum.co/",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
12
|
"url": "git+https://github.com/cullumco/cadence.git"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Pause Cadence — silence all hooks instantly; prompts go through untouched until resumed.
|
|
3
|
+
disable-model-invocation: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pause Cadence
|
|
7
|
+
|
|
8
|
+
Run `cadence pause` with Bash.
|
|
9
|
+
|
|
10
|
+
Then confirm in one sentence: Cadence is paused, prompts go through untouched,
|
|
11
|
+
and `/cadence:resume` turns it back on. Their state (self-report, pins,
|
|
12
|
+
opt-ins) is preserved exactly as-is.
|
|
13
|
+
|
|
14
|
+
Note: the current session may already have `<user_state>` blocks in context
|
|
15
|
+
from earlier prompts — ignore them from here on; the user has asked for
|
|
16
|
+
silence.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Resume Cadence — start reading the room again after a pause.
|
|
3
|
+
disable-model-invocation: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Resume Cadence
|
|
7
|
+
|
|
8
|
+
Run `cadence resume` with Bash, then `cadence test`.
|
|
9
|
+
|
|
10
|
+
Confirm in one sentence that Cadence is live again, and summarize what it
|
|
11
|
+
currently sees from the `cadence test` output (or note that no signals are
|
|
12
|
+
present yet). Keep it to two sentences total.
|