@cullumco/cadence 0.1.4 → 0.1.5
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 +112 -64
- package/dist/cadence.js +58 -17
- package/dist/cli.js +216 -23
- package/dist/config.js +76 -0
- package/dist/hook.js +37 -18
- package/dist/inject.js +21 -3
- package/dist/posttool.js +57 -4
- package/dist/providers/activity.js +45 -4
- package/dist/providers/{ambient.js → environment.js} +25 -5
- 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 +40 -8
- 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
package/dist/posttool.js
CHANGED
|
@@ -4,6 +4,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { getGitSignal } from "./providers/git.js";
|
|
7
|
+
import { isPaused } from "./config.js";
|
|
7
8
|
/* ─────────────────────────────────────────────────────────────────────────
|
|
8
9
|
* PostToolUse adapter — V2 "after-the-fact" refinement, conservative cut.
|
|
9
10
|
*
|
|
@@ -13,14 +14,22 @@ import { getGitSignal } from "./providers/git.js";
|
|
|
13
14
|
* event: the repo entering or leaving a merge/rebase conflict — the
|
|
14
15
|
* strongest debug tell we have, far stronger than music ever was.
|
|
15
16
|
*
|
|
17
|
+
* Two material events now, same discipline:
|
|
18
|
+
* 1. the repo entering/leaving a merge/rebase conflict (re-observed via git)
|
|
19
|
+
* 2. a streak of destructive git ops — reset --hard / force-push — read off
|
|
20
|
+
* the command string (no tool_response parsing), i.e. thrash.
|
|
21
|
+
*
|
|
16
22
|
* Discipline (the BACKLOG's hard constraint):
|
|
17
23
|
* - fires only after Bash tool calls whose command mentions git
|
|
18
|
-
* - injects at most once per conflict
|
|
24
|
+
* - injects at most once per TRANSITION (conflict edge, or thrash threshold),
|
|
25
|
+
* never per tool
|
|
19
26
|
* - silent in every other case, silent on every error
|
|
20
27
|
* ───────────────────────────────────────────────────────────────────────── */
|
|
21
28
|
const TOTAL_BUDGET_MS = 1500;
|
|
22
29
|
const STATE_FILE = join(homedir(), ".cadence", "workstate.json");
|
|
23
30
|
const MAX_SESSIONS = 20;
|
|
31
|
+
const THRASH_WINDOW_MS = 10 * 60_000; // destructive ops within 10 min count as a streak
|
|
32
|
+
const THRASH_MIN = 2; // 2nd destructive op in the window = thrash
|
|
24
33
|
// Gate 1: is this tool call even capable of changing git conflict state?
|
|
25
34
|
// Only Bash commands that mention git — Edit/Read/etc. can't start a merge,
|
|
26
35
|
// and checking the repo after every tool call would betray the silence rule.
|
|
@@ -50,6 +59,34 @@ export function refineContext(prev, conflicted) {
|
|
|
50
59
|
}
|
|
51
60
|
return null; // first observation of a clean repo: record silently
|
|
52
61
|
}
|
|
62
|
+
// Is this command a destructive/undo git op? Just `reset --hard` and a true
|
|
63
|
+
// force-push — NOT --force-with-lease (the safe one), checkout, or restore
|
|
64
|
+
// (too ordinary to read as thrash). Pure + exported for tests.
|
|
65
|
+
export function isThrashCommand(cmd) {
|
|
66
|
+
return (/git\s+reset\s+--hard\b/.test(cmd) ||
|
|
67
|
+
(/git\s+push\b/.test(cmd) && /(--force\b|\s-f\b)/.test(cmd) && !/--force-with-lease\b/.test(cmd)));
|
|
68
|
+
}
|
|
69
|
+
/* Edge-trigger thrash off the rolling window of destructive-op timestamps.
|
|
70
|
+
* Speaks once when the streak first crosses THRASH_MIN inside the window, then
|
|
71
|
+
* stays quiet until the window empties (so it can fire again on a later run).
|
|
72
|
+
* Pure + exported; `times` already includes the current op if it was one. */
|
|
73
|
+
export function refineThrash(times, now, announced) {
|
|
74
|
+
const recent = times.filter((t) => now - t <= THRASH_WINDOW_MS);
|
|
75
|
+
if (recent.length === 0)
|
|
76
|
+
return { message: null, times: recent, announced: false };
|
|
77
|
+
if (recent.length >= THRASH_MIN && !announced) {
|
|
78
|
+
return {
|
|
79
|
+
message: "<user_state_update>observed work: a streak of destructive git ops " +
|
|
80
|
+
"(reset --hard / force-push). Read this as thrash — pause, verify the " +
|
|
81
|
+
"repo state and what's being undone before the next destructive step. " +
|
|
82
|
+
"If the user's words clearly mean otherwise, follow their words." +
|
|
83
|
+
"</user_state_update>",
|
|
84
|
+
times: recent,
|
|
85
|
+
announced: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return { message: null, times: recent, announced };
|
|
89
|
+
}
|
|
53
90
|
async function loadState() {
|
|
54
91
|
try {
|
|
55
92
|
const raw = JSON.parse(await readFile(STATE_FILE, "utf-8"));
|
|
@@ -86,6 +123,8 @@ async function readStdin() {
|
|
|
86
123
|
}
|
|
87
124
|
}
|
|
88
125
|
async function main() {
|
|
126
|
+
if (await isPaused())
|
|
127
|
+
return; // user asked for silence — observe nothing
|
|
89
128
|
const input = await readStdin();
|
|
90
129
|
if (!shouldCheck(input))
|
|
91
130
|
return;
|
|
@@ -97,10 +136,24 @@ async function main() {
|
|
|
97
136
|
return; // not a repo / git unavailable → nothing to observe
|
|
98
137
|
const key = input.session_id ?? "default";
|
|
99
138
|
const state = await loadState();
|
|
100
|
-
const prev = state[key]
|
|
101
|
-
const
|
|
102
|
-
|
|
139
|
+
const prev = state[key];
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
// 1. conflict edge (re-observed via git)
|
|
142
|
+
const conflictMsg = refineContext(prev?.conflicted, git.conflicted);
|
|
143
|
+
// 2. thrash threshold (read off the command string)
|
|
144
|
+
const cmd = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
|
|
145
|
+
const times = prev?.thrashTimes ?? [];
|
|
146
|
+
const nextTimes = isThrashCommand(cmd) ? [...times, now] : times;
|
|
147
|
+
const thrash = refineThrash(nextTimes, now, prev?.thrashAnnounced ?? false);
|
|
148
|
+
state[key] = {
|
|
149
|
+
conflicted: git.conflicted,
|
|
150
|
+
at: now,
|
|
151
|
+
thrashTimes: thrash.times,
|
|
152
|
+
thrashAnnounced: thrash.announced,
|
|
153
|
+
};
|
|
103
154
|
await saveState(state);
|
|
155
|
+
// A conflict edge is the stronger tell; fall back to thrash. At most one.
|
|
156
|
+
const message = conflictMsg ?? thrash.message;
|
|
104
157
|
if (message) {
|
|
105
158
|
process.stdout.write(JSON.stringify({
|
|
106
159
|
hookSpecificOutput: {
|
|
@@ -3,7 +3,36 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
const CADENCE_DIR = join(homedir(), ".cadence");
|
|
5
5
|
const ACTIVITY_FILE = join(CADENCE_DIR, "activity.json");
|
|
6
|
-
|
|
6
|
+
// Rolling window for typing tempo: how many recent prompts we keep, and how
|
|
7
|
+
// far back they still count toward a "rapid-fire" read.
|
|
8
|
+
const WINDOW_MAX = 10;
|
|
9
|
+
const WINDOW_AGE_MS = 10 * 60_000; // 10 min — older prompts are a different sitting
|
|
10
|
+
const BURST_WINDOW_MS = 5 * 60_000; // ≥3 prompts inside 5 min = a burst
|
|
11
|
+
const BURST_MIN_PROMPTS = 3;
|
|
12
|
+
const SHORT_PROMPT = 80; // median chars under this, in a burst → "rapid"
|
|
13
|
+
const LONG_PROMPT = 280; // one prompt over this → "considered"
|
|
14
|
+
/* Tempo from the prompt-rhythm window. Pure + exported for fixture tests.
|
|
15
|
+
* "considered" wins outright (a long prompt is deliberate even mid-burst);
|
|
16
|
+
* otherwise a tight cluster of short prompts reads as "rapid". */
|
|
17
|
+
export function computeTempo(window) {
|
|
18
|
+
if (window.length === 0)
|
|
19
|
+
return undefined;
|
|
20
|
+
const current = window[window.length - 1];
|
|
21
|
+
if (current.len > LONG_PROMPT)
|
|
22
|
+
return "considered";
|
|
23
|
+
const newest = current.at;
|
|
24
|
+
const inBurst = window.filter((m) => newest - m.at <= BURST_WINDOW_MS);
|
|
25
|
+
if (inBurst.length >= BURST_MIN_PROMPTS) {
|
|
26
|
+
const lens = inBurst.map((m) => m.len).sort((a, b) => a - b);
|
|
27
|
+
const median = lens[Math.floor(lens.length / 2)] ?? 0;
|
|
28
|
+
if (median < SHORT_PROMPT)
|
|
29
|
+
return "rapid";
|
|
30
|
+
}
|
|
31
|
+
if (window.length >= 2)
|
|
32
|
+
return "measured";
|
|
33
|
+
return undefined; // not enough rhythm yet to call it
|
|
34
|
+
}
|
|
35
|
+
export function activityFrom(prompt, lastPromptAt, now, recent = [], tempoEnabled = false) {
|
|
7
36
|
if (prompt == null)
|
|
8
37
|
return null;
|
|
9
38
|
const signal = {
|
|
@@ -13,9 +42,15 @@ export function activityFrom(prompt, lastPromptAt, now) {
|
|
|
13
42
|
if (lastPromptAt != null && Number.isFinite(lastPromptAt)) {
|
|
14
43
|
signal.minSinceLastPrompt = Math.max(0, Math.round((now - lastPromptAt) / 60000));
|
|
15
44
|
}
|
|
45
|
+
if (tempoEnabled) {
|
|
46
|
+
const window = [...recent, { at: now, len: prompt.length }].filter((m) => now - m.at <= WINDOW_AGE_MS);
|
|
47
|
+
const tempo = computeTempo(window);
|
|
48
|
+
if (tempo)
|
|
49
|
+
signal.tempo = tempo;
|
|
50
|
+
}
|
|
16
51
|
return signal;
|
|
17
52
|
}
|
|
18
|
-
export async function getActivitySignal(prompt, now = Date.now()) {
|
|
53
|
+
export async function getActivitySignal(prompt, now = Date.now(), opts = {}) {
|
|
19
54
|
let state = {};
|
|
20
55
|
try {
|
|
21
56
|
state = JSON.parse(await readFile(ACTIVITY_FILE, "utf-8"));
|
|
@@ -23,10 +58,16 @@ export async function getActivitySignal(prompt, now = Date.now()) {
|
|
|
23
58
|
catch {
|
|
24
59
|
// no activity file yet
|
|
25
60
|
}
|
|
26
|
-
const
|
|
61
|
+
const recent = Array.isArray(state.recent) ? state.recent : [];
|
|
62
|
+
const signal = activityFrom(prompt, state.lastPromptAt, now, recent, opts.tempoEnabled);
|
|
27
63
|
try {
|
|
28
64
|
await mkdir(CADENCE_DIR, { recursive: true });
|
|
29
|
-
|
|
65
|
+
const nextRecent = prompt == null
|
|
66
|
+
? recent
|
|
67
|
+
: [...recent, { at: now, len: prompt.length }]
|
|
68
|
+
.filter((m) => now - m.at <= WINDOW_AGE_MS)
|
|
69
|
+
.slice(-WINDOW_MAX);
|
|
70
|
+
await writeFile(ACTIVITY_FILE, JSON.stringify({ lastPromptAt: now, recent: nextRecent }), "utf-8");
|
|
30
71
|
}
|
|
31
72
|
catch {
|
|
32
73
|
// activity is best-effort; never let it break the hook
|
|
@@ -138,18 +138,37 @@ export async function getFocus(now = new Date()) {
|
|
|
138
138
|
}
|
|
139
139
|
return manual;
|
|
140
140
|
}
|
|
141
|
+
// Frontmost app — opt-in, macOS, flavor only. Read at UserPromptSubmit, so the
|
|
142
|
+
// terminal/IDE you typed into is usually frontmost; we filter known shells and
|
|
143
|
+
// editors out, so this speaks only when a genuinely different app (a browser,
|
|
144
|
+
// Slack, a PDF) is in front. Flavor for now; a dial nudge stays a candidate.
|
|
145
|
+
const TERMINAL_APPS = /^(Terminal|iTerm2?|Alacritty|kitty|WezTerm|Warp|Hyper|Code|Code - Insiders|Cursor|Windsurf|Electron|Ghostty|Tabby|rio)$/i;
|
|
146
|
+
export async function getFocusedApp(now) {
|
|
147
|
+
if (!now || process.platform !== "darwin")
|
|
148
|
+
return undefined;
|
|
149
|
+
const app = await sh(`osascript -e 'tell application "System Events" to name of first application process whose frontmost is true'`, 700);
|
|
150
|
+
if (!app)
|
|
151
|
+
return undefined;
|
|
152
|
+
const name = app.split("\n")[0]?.trim();
|
|
153
|
+
if (!name || TERMINAL_APPS.test(name))
|
|
154
|
+
return undefined; // you're in your terminal — no news
|
|
155
|
+
return name;
|
|
156
|
+
}
|
|
141
157
|
// ── mac context: best-effort shell-outs, all flavor (no dial nudges) ─────────
|
|
142
|
-
async function getMacContext() {
|
|
158
|
+
async function getMacContext(focusedAppEnabled) {
|
|
143
159
|
if (process.platform !== "darwin")
|
|
144
160
|
return {};
|
|
145
|
-
const [dark, ssid, displays, focus] = await Promise.all([
|
|
161
|
+
const [dark, ssid, displays, focus, focusedApp] = await Promise.all([
|
|
146
162
|
sh("defaults read -g AppleInterfaceStyle"), // "Dark", or error (=light)
|
|
147
163
|
sh("ipconfig getsummary en0 | awk -F ' SSID : ' '/ SSID : / {print $2}'", 700),
|
|
148
164
|
// fast display count via AppleScript (~100ms) — NOT system_profiler (1-3s)
|
|
149
165
|
sh(`osascript -e 'tell application "System Events" to count of desktops'`, 700),
|
|
150
166
|
getFocus(),
|
|
167
|
+
getFocusedApp(focusedAppEnabled),
|
|
151
168
|
]);
|
|
152
169
|
const ctx = {};
|
|
170
|
+
if (focusedApp)
|
|
171
|
+
ctx.focusedApp = focusedApp;
|
|
153
172
|
// `defaults read` exits non-zero when the key is unset — which is exactly
|
|
154
173
|
// what light mode looks like. So error/null ⇒ light, not unknown.
|
|
155
174
|
ctx.darkMode = dark != null && /dark/i.test(dark);
|
|
@@ -209,17 +228,17 @@ async function getWeather() {
|
|
|
209
228
|
clearTimeout(timer);
|
|
210
229
|
}
|
|
211
230
|
}
|
|
212
|
-
export async function
|
|
231
|
+
export async function getEnvironmentSignal(now, opts = {}) {
|
|
213
232
|
const hour = now.getHours();
|
|
214
233
|
const vitals = getVitals(); // sync, free
|
|
215
234
|
// all probes run in parallel; each resolves to a safe default on failure.
|
|
216
235
|
const [weather, battery, mac] = await Promise.all([
|
|
217
236
|
getWeather(),
|
|
218
237
|
getBattery(),
|
|
219
|
-
getMacContext(),
|
|
238
|
+
getMacContext(opts.focusedAppEnabled ?? false),
|
|
220
239
|
]);
|
|
221
240
|
return {
|
|
222
|
-
source: "
|
|
241
|
+
source: "environment",
|
|
223
242
|
partOfDay: partOfDay(hour),
|
|
224
243
|
dayOfWeek: DAYS[now.getDay()] ?? "",
|
|
225
244
|
isWeekend: now.getDay() === 0 || now.getDay() === 6,
|
|
@@ -233,5 +252,6 @@ export async function getAmbientSignal(now) {
|
|
|
233
252
|
displays: mac.displays,
|
|
234
253
|
network: mac.network,
|
|
235
254
|
darkMode: mac.darkMode,
|
|
255
|
+
focusedApp: mac.focusedApp,
|
|
236
256
|
};
|
|
237
257
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { providerEnabled, providerSetting } from "../config.js";
|
|
2
|
+
import { debug } from "../debug.js";
|
|
3
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* Esoteric flavor — opt-in, render-only. For people who want a little ambient
|
|
5
|
+
* woo in the room. Never moves a dial (the BACKLOG lean): it colors the block,
|
|
6
|
+
* it doesn't steer real work.
|
|
7
|
+
*
|
|
8
|
+
* moon → computed OFFLINE from the date, no API, no dep.
|
|
9
|
+
* horoscope → user sets their sign; daily text via a keyless API, opt-in and
|
|
10
|
+
* fail-silent, exactly like the weather probe. Absent on any
|
|
11
|
+
* hiccup, never throws.
|
|
12
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
13
|
+
const HOROSCOPE_TIMEOUT_MS = 900;
|
|
14
|
+
const SYNODIC_MONTH = 29.53058867; // days, new moon → new moon
|
|
15
|
+
const KNOWN_NEW_MOON = Date.UTC(2000, 0, 6, 18, 14) / 1000; // 2000-01-06 reference
|
|
16
|
+
const PHASES = [
|
|
17
|
+
"new moon",
|
|
18
|
+
"waxing crescent",
|
|
19
|
+
"first quarter",
|
|
20
|
+
"waxing gibbous",
|
|
21
|
+
"full moon",
|
|
22
|
+
"waning gibbous",
|
|
23
|
+
"last quarter",
|
|
24
|
+
"waning crescent",
|
|
25
|
+
];
|
|
26
|
+
const ZODIAC = new Set([
|
|
27
|
+
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
|
|
28
|
+
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
|
|
29
|
+
]);
|
|
30
|
+
/** Current moon phase as one of the 8 names. Pure + exported for tests. */
|
|
31
|
+
export function moonPhase(now) {
|
|
32
|
+
const days = (now.getTime() / 1000 - KNOWN_NEW_MOON) / 86_400;
|
|
33
|
+
const frac = (((days % SYNODIC_MONTH) + SYNODIC_MONTH) % SYNODIC_MONTH) / SYNODIC_MONTH;
|
|
34
|
+
const idx = Math.floor(frac * 8 + 0.5) % 8;
|
|
35
|
+
return PHASES[idx];
|
|
36
|
+
}
|
|
37
|
+
async function fetchHoroscope(sign) {
|
|
38
|
+
const s = sign.toLowerCase().trim();
|
|
39
|
+
if (!ZODIAC.has(s))
|
|
40
|
+
return undefined; // garbage in → silent, never a guess
|
|
41
|
+
const ctrl = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => ctrl.abort(), HOROSCOPE_TIMEOUT_MS);
|
|
43
|
+
try {
|
|
44
|
+
const url = `https://horoscope-app-api.vercel.app/api/v1/get-horoscope/daily?sign=${s}&day=TODAY`;
|
|
45
|
+
const res = await fetch(url, { signal: ctrl.signal });
|
|
46
|
+
if (!res.ok)
|
|
47
|
+
return undefined;
|
|
48
|
+
const data = (await res.json());
|
|
49
|
+
const text = data.data?.horoscope_data;
|
|
50
|
+
if (!text)
|
|
51
|
+
return undefined;
|
|
52
|
+
return text.length > 160 ? text.slice(0, 159).trimEnd() + "…" : text;
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
debug("esoteric", `horoscope lookup failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function getEsotericSignal(providers, now = new Date()) {
|
|
63
|
+
const moonOn = providerEnabled(providers, "moon");
|
|
64
|
+
const sign = providerSetting(providers, "horoscope");
|
|
65
|
+
if (!moonOn && typeof sign !== "string")
|
|
66
|
+
return null; // nothing opted in
|
|
67
|
+
const phase = moonOn ? moonPhase(now) : undefined;
|
|
68
|
+
const horoscope = typeof sign === "string" ? await fetchHoroscope(sign) : undefined;
|
|
69
|
+
if (!phase && !horoscope)
|
|
70
|
+
return null;
|
|
71
|
+
return {
|
|
72
|
+
source: "esoteric",
|
|
73
|
+
moonPhase: phase,
|
|
74
|
+
horoscope,
|
|
75
|
+
sign: typeof sign === "string" ? sign.toLowerCase().trim() : undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
package/dist/providers/git.js
CHANGED
|
@@ -5,9 +5,10 @@ import { exec } from "node:child_process";
|
|
|
5
5
|
* Cross-platform (git is git everywhere). Reads the repo at `cwd` — which,
|
|
6
6
|
* when run from the hook, is the project Claude Code is working in.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Collects commits this hour, dirty files, and whether you're mid-merge/rebase
|
|
9
|
+
* (the real debug tell). LIVE in dial mapping since 2026-06-05: 3+ commits/hr
|
|
10
|
+
* → fast pace, mid-conflict → verify-first proactivity (see deriveCadence,
|
|
11
|
+
* applied below self-report so the user's explicit word still wins).
|
|
11
12
|
* ───────────────────────────────────────────────────────────────────────── */
|
|
12
13
|
const GIT_TIMEOUT_MS = 700;
|
|
13
14
|
function git(args, cwd) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Ordered weakest-cue → strongest is not needed; each kind is independent and
|
|
2
|
+
// the first match wins. Order them by how decisive the framing is.
|
|
3
|
+
const PATTERNS = [
|
|
4
|
+
{
|
|
5
|
+
kind: "debug",
|
|
6
|
+
re: /\b(debug(ging)?|stack ?trace|tracebacks?|why (is|are|does|do|won'?t|isn'?t|can'?t)|stuck on|can'?t figure|keeps? (failing|crashing|breaking)|throwing|segfault|regression)\b/i,
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
kind: "think",
|
|
10
|
+
re: /\b(thinking through|let'?s think|think about|weigh(ing)? (the|our|up)|trade-?offs?|brainstorm|explore (the )?options|pros and cons|not sure (which|whether|if)|help me decide|which approach|architect(ure|ing)?\b.*\b(should|best)|design (the|a) )\b/i,
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
kind: "ship",
|
|
14
|
+
re: /\b(ship it|let'?s ship|ready to ship|send it|just send|locked in|lock(ing)? in|crank(ing)? (out|through)|grind(ing)? (out|through)|knock (this|these|it) out|let'?s go\b|let'?s finish|wrap (this|it) up|push it through)\b/i,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
kind: "focus",
|
|
18
|
+
re: /\b(deep work|heads ?down|focus mode|in the zone|no distractions|need to concentrate)\b/i,
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
/** Detect a single dominant intent kind from a prompt, or null when the
|
|
22
|
+
* wording carries no clear cadence cue. Pure + exported for fixture tests. */
|
|
23
|
+
export function detectPromptIntent(prompt) {
|
|
24
|
+
for (const { kind, re } of PATTERNS) {
|
|
25
|
+
if (re.test(prompt))
|
|
26
|
+
return kind;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export async function getIntentSignal(prompt) {
|
|
31
|
+
if (!prompt)
|
|
32
|
+
return null;
|
|
33
|
+
const kind = detectPromptIntent(prompt);
|
|
34
|
+
if (!kind)
|
|
35
|
+
return null; // no cue → no signal (silent, never a guess)
|
|
36
|
+
return { source: "intent", kind };
|
|
37
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* Moon phase — the first esoteric, opt-in provider (see BACKLOG).
|
|
6
|
+
*
|
|
7
|
+
* Pure offline math: phase from days since a known new moon, no API, no
|
|
8
|
+
* platform dependency. OFF by default — it only speaks if the user opted in
|
|
9
|
+
* via ~/.cadence/config.json: { "providers": { "moon": true } }
|
|
10
|
+
*
|
|
11
|
+
* Render-only by design: esoteric signals color the vibe, they never move
|
|
12
|
+
* dials unless the user explicitly maps them (BACKLOG: "vibe-only unless
|
|
13
|
+
* the user maps them").
|
|
14
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
15
|
+
const CONFIG_FILE = join(homedir(), ".cadence", "config.json");
|
|
16
|
+
// Mean synodic month. Anchor: the new moon of 2000-01-06 18:14 UTC.
|
|
17
|
+
// Mean-cycle math drifts up to ~14h from true phase — irrelevant at the
|
|
18
|
+
// "waxing gibbous" level of precision we render.
|
|
19
|
+
const SYNODIC_DAYS = 29.53058867;
|
|
20
|
+
const EPOCH_NEW_MOON_MS = Date.UTC(2000, 0, 6, 18, 14);
|
|
21
|
+
const PHASES = [
|
|
22
|
+
"new",
|
|
23
|
+
"waxing crescent",
|
|
24
|
+
"first quarter",
|
|
25
|
+
"waxing gibbous",
|
|
26
|
+
"full",
|
|
27
|
+
"waning gibbous",
|
|
28
|
+
"last quarter",
|
|
29
|
+
"waning crescent",
|
|
30
|
+
];
|
|
31
|
+
// Pure and clock-injected so it's fixture-testable.
|
|
32
|
+
export function moonPhase(now) {
|
|
33
|
+
const days = (now.getTime() - EPOCH_NEW_MOON_MS) / 86_400_000;
|
|
34
|
+
const fraction = (((days % SYNODIC_DAYS) + SYNODIC_DAYS) % SYNODIC_DAYS) / SYNODIC_DAYS;
|
|
35
|
+
// Disc illumination: 0 at new (fraction 0), 100 at full (fraction 0.5).
|
|
36
|
+
const illumination = Math.round((1 - Math.cos(2 * Math.PI * fraction)) * 50);
|
|
37
|
+
// Eight equal buckets, centered on the cardinal points (new = ±1/16 around 0).
|
|
38
|
+
const idx = Math.floor(((fraction + 1 / 16) % 1) * 8);
|
|
39
|
+
return { phase: PHASES[idx] ?? "new", illumination };
|
|
40
|
+
}
|
|
41
|
+
export async function getMoonSignal(now = new Date()) {
|
|
42
|
+
try {
|
|
43
|
+
const cfg = JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
|
|
44
|
+
if (cfg.providers?.moon !== true)
|
|
45
|
+
return null; // opt-in only, off by default
|
|
46
|
+
return { source: "moon", ...moonPhase(now) };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null; // no config / bad JSON → simply off, never throw
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/providers/music.js
CHANGED
|
@@ -3,6 +3,8 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { tagsToVibe } from "../vibe.js";
|
|
6
|
+
import { getSpotifyNowPlaying } from "./spotify.js";
|
|
7
|
+
import { loadProviders } from "../config.js";
|
|
6
8
|
import { debug } from "../debug.js";
|
|
7
9
|
/* ─────────────────────────────────────────────────────────────────────────
|
|
8
10
|
* Music = identity + vibe. No Spotify Web API, no auth, no Premium.
|
|
@@ -63,7 +65,11 @@ export function osascript(script) {
|
|
|
63
65
|
});
|
|
64
66
|
});
|
|
65
67
|
}
|
|
66
|
-
|
|
68
|
+
// macOS now-playing via the desktop apps' scripting interface. Darwin-only —
|
|
69
|
+
// osascript doesn't exist elsewhere, so non-Mac falls through to Spotify.
|
|
70
|
+
async function getLocalNowPlaying() {
|
|
71
|
+
if (process.platform !== "darwin")
|
|
72
|
+
return null;
|
|
67
73
|
for (const player of PLAYERS) {
|
|
68
74
|
if (!(await isRunning(player))) {
|
|
69
75
|
debug("music", `${player} not running`);
|
|
@@ -79,6 +85,11 @@ async function getNowPlaying() {
|
|
|
79
85
|
}
|
|
80
86
|
return null;
|
|
81
87
|
}
|
|
88
|
+
// Local desktop apps first (zero-setup on Mac); the opt-in Spotify token path
|
|
89
|
+
// second, so a Linux/Windows user who supplied creds still gets music.
|
|
90
|
+
async function getNowPlaying(providers) {
|
|
91
|
+
return (await getLocalNowPlaying()) ?? (await getSpotifyNowPlaying(providers));
|
|
92
|
+
}
|
|
82
93
|
async function loadCache() {
|
|
83
94
|
try {
|
|
84
95
|
return JSON.parse(await readFile(CACHE_FILE, "utf-8"));
|
|
@@ -166,8 +177,8 @@ async function getTags(artist) {
|
|
|
166
177
|
await saveCache(cache);
|
|
167
178
|
return tags ?? [];
|
|
168
179
|
}
|
|
169
|
-
export async function getMusicSignal() {
|
|
170
|
-
const np = await getNowPlaying();
|
|
180
|
+
export async function getMusicSignal(providers) {
|
|
181
|
+
const np = await getNowPlaying(providers ?? (await loadProviders()));
|
|
171
182
|
if (!np)
|
|
172
183
|
return null;
|
|
173
184
|
const tags = await getTags(np.artist);
|
|
@@ -179,5 +190,6 @@ export async function getMusicSignal() {
|
|
|
179
190
|
player: np.player || undefined,
|
|
180
191
|
vibe: vibe && vibe.moods.length ? vibe.moods.join(", ") : undefined,
|
|
181
192
|
energy: vibe?.energy,
|
|
193
|
+
acoustic: vibe?.acoustic,
|
|
182
194
|
};
|
|
183
195
|
}
|
|
@@ -2,7 +2,12 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
const STATE_FILE = join(homedir(), ".cadence", "state.txt");
|
|
5
|
-
|
|
5
|
+
// 2h, shortened from 4h: a self-report should track the room you're in now, not
|
|
6
|
+
// the one you were in this morning. Cadence nudges you to refresh as it nears
|
|
7
|
+
// expiry (see session-start composeHint).
|
|
8
|
+
export const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
|
|
9
|
+
// When less than this is left, the session greeting invites a refresh.
|
|
10
|
+
export const REFRESH_SOON_MS = 30 * 60 * 1000;
|
|
6
11
|
export async function getSelfReportSignal() {
|
|
7
12
|
try {
|
|
8
13
|
const [text, info] = await Promise.all([readFile(STATE_FILE, "utf-8"), stat(STATE_FILE)]);
|
|
@@ -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
|
+
}
|