@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
package/dist/cli.js
CHANGED
|
@@ -3,55 +3,77 @@ import { writeFile, mkdir, readFile } from "node:fs/promises";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { getMusicSignal } from "./providers/music.js";
|
|
6
|
-
import { getSelfReportSignal } from "./providers/selfreport.js";
|
|
7
|
-
import {
|
|
6
|
+
import { getSelfReportSignal, STALE_AFTER_MS } from "./providers/selfreport.js";
|
|
7
|
+
import { getEnvironmentSignal } from "./providers/environment.js";
|
|
8
8
|
import { getGitSignal } from "./providers/git.js";
|
|
9
|
+
import { getEsotericSignal } from "./providers/esoteric.js";
|
|
9
10
|
import { deriveCadence, buildReframe, loadOverrides, applyOverrides, resolveDialLevel, DIALS, DIAL_WORDS, } from "./cadence.js";
|
|
10
11
|
import { render } from "./inject.js";
|
|
11
12
|
import { renderSignalsTable } from "./signals-view.js";
|
|
13
|
+
import { loadProviders, providerEnabled, isPaused, OPT_IN_PROVIDERS } from "./config.js";
|
|
14
|
+
import { connectSpotify, REDIRECT_URI } from "./spotify-auth.js";
|
|
12
15
|
const CADENCE_DIR = join(homedir(), ".cadence");
|
|
13
16
|
const STATE_FILE = join(CADENCE_DIR, "state.txt");
|
|
14
17
|
const CONFIG_FILE = join(CADENCE_DIR, "config.json");
|
|
15
|
-
async function
|
|
18
|
+
async function cmdReport(args) {
|
|
16
19
|
if (args.length === 0) {
|
|
20
|
+
// Same TTL the hook applies — printing an expired report as if it were
|
|
21
|
+
// live would contradict what actually gets injected.
|
|
22
|
+
const report = await getSelfReportSignal();
|
|
23
|
+
if (report) {
|
|
24
|
+
const rem = Math.max(0, STALE_AFTER_MS - (Date.now() - report.setAt));
|
|
25
|
+
const h = Math.floor(rem / 3_600_000);
|
|
26
|
+
const m = Math.floor((rem % 3_600_000) / 60_000);
|
|
27
|
+
console.log(`${report.text} (${h > 0 ? `${h}h${String(m).padStart(2, "0")}m` : `${m}m`} left)`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
17
30
|
try {
|
|
18
|
-
const
|
|
19
|
-
console.log(
|
|
31
|
+
const stale = (await readFile(STATE_FILE, "utf-8")).trim();
|
|
32
|
+
console.log(stale
|
|
33
|
+
? `(last self-report expired — refresh: cadence report "...")`
|
|
34
|
+
: "(no self-report set)");
|
|
20
35
|
}
|
|
21
36
|
catch {
|
|
22
|
-
console.log("(no
|
|
37
|
+
console.log("(no self-report set)");
|
|
23
38
|
}
|
|
24
39
|
return;
|
|
25
40
|
}
|
|
26
41
|
const text = args.join(" ");
|
|
27
42
|
await mkdir(CADENCE_DIR, { recursive: true });
|
|
28
43
|
await writeFile(STATE_FILE, text, "utf-8");
|
|
29
|
-
console.log(`
|
|
44
|
+
console.log(` self-report set: "${text}"`);
|
|
30
45
|
}
|
|
31
46
|
async function cmdClear() {
|
|
32
47
|
await mkdir(CADENCE_DIR, { recursive: true });
|
|
33
48
|
await writeFile(STATE_FILE, "", "utf-8");
|
|
34
|
-
console.log("
|
|
49
|
+
console.log(" self-report cleared");
|
|
35
50
|
}
|
|
36
51
|
// Collects live signals and renders the exact block the hook would inject,
|
|
37
52
|
// or null when there's nothing to say. Shared by `test` and the bare command.
|
|
38
53
|
async function buildPreview() {
|
|
39
54
|
const signals = [];
|
|
40
|
-
const
|
|
41
|
-
|
|
55
|
+
const providers = await loadProviders();
|
|
56
|
+
const [music, report, environment, git, esoteric, overrides] = await Promise.all([
|
|
57
|
+
getMusicSignal(providers).catch(() => null),
|
|
42
58
|
getSelfReportSignal().catch(() => null),
|
|
43
|
-
|
|
59
|
+
getEnvironmentSignal(new Date(), {
|
|
60
|
+
focusedAppEnabled: providerEnabled(providers, "focusedApp"),
|
|
61
|
+
wifiEnabled: providerEnabled(providers, "wifi"),
|
|
62
|
+
}).catch(() => null),
|
|
44
63
|
getGitSignal(process.cwd()).catch(() => null),
|
|
64
|
+
getEsotericSignal(providers).catch(() => null),
|
|
45
65
|
loadOverrides(),
|
|
46
66
|
]);
|
|
47
67
|
if (music)
|
|
48
68
|
signals.push(music);
|
|
49
69
|
if (report)
|
|
50
70
|
signals.push(report);
|
|
51
|
-
if (
|
|
52
|
-
signals.push(
|
|
71
|
+
if (environment)
|
|
72
|
+
signals.push(environment);
|
|
53
73
|
if (git)
|
|
54
74
|
signals.push(git);
|
|
75
|
+
if (esoteric)
|
|
76
|
+
signals.push(esoteric);
|
|
55
77
|
if (signals.length === 0 && Object.keys(overrides).length === 0)
|
|
56
78
|
return null;
|
|
57
79
|
const state = { signals, capturedAt: Date.now() };
|
|
@@ -62,7 +84,7 @@ async function buildPreview() {
|
|
|
62
84
|
async function cmdTest() {
|
|
63
85
|
const block = await buildPreview();
|
|
64
86
|
if (!block) {
|
|
65
|
-
console.log(' (no signals — play something, set: cadence
|
|
87
|
+
console.log(' (no signals — play something, set: cadence report "...", or pin a dial: cadence set pace fast)');
|
|
66
88
|
return;
|
|
67
89
|
}
|
|
68
90
|
console.log("\n" + block + "\n");
|
|
@@ -70,16 +92,173 @@ async function cmdTest() {
|
|
|
70
92
|
// The legibility view: every signal Cadence can read — live value, or the
|
|
71
93
|
// reason it's absent. Unlike `test`, this never goes silent.
|
|
72
94
|
async function cmdSignals() {
|
|
73
|
-
const
|
|
95
|
+
const providers = await loadProviders();
|
|
96
|
+
const [music, report, environment, git] = await Promise.all([
|
|
74
97
|
getMusicSignal().catch(() => null),
|
|
75
98
|
getSelfReportSignal().catch(() => null),
|
|
76
|
-
|
|
99
|
+
getEnvironmentSignal(new Date(), {
|
|
100
|
+
focusedAppEnabled: providerEnabled(providers, "focusedApp"),
|
|
101
|
+
wifiEnabled: providerEnabled(providers, "wifi"),
|
|
102
|
+
}).catch(() => null),
|
|
77
103
|
getGitSignal(process.cwd()).catch(() => null),
|
|
78
104
|
]);
|
|
79
105
|
console.log("\n" +
|
|
80
|
-
renderSignalsTable({
|
|
106
|
+
renderSignalsTable({
|
|
107
|
+
music,
|
|
108
|
+
report,
|
|
109
|
+
environment,
|
|
110
|
+
git,
|
|
111
|
+
providers,
|
|
112
|
+
now: Date.now(),
|
|
113
|
+
platform: process.platform,
|
|
114
|
+
}) +
|
|
81
115
|
"\n");
|
|
82
116
|
}
|
|
117
|
+
// ── opt-in provider registry: the consent layer ────────────────────────────
|
|
118
|
+
async function loadCfg() {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function saveCfg(cfg) {
|
|
127
|
+
await mkdir(CADENCE_DIR, { recursive: true });
|
|
128
|
+
await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
129
|
+
}
|
|
130
|
+
function cfgProviders(cfg) {
|
|
131
|
+
return cfg["providers"] && typeof cfg["providers"] === "object"
|
|
132
|
+
? cfg["providers"]
|
|
133
|
+
: {};
|
|
134
|
+
}
|
|
135
|
+
function knownProvider(name) {
|
|
136
|
+
return name != null && name in OPT_IN_PROVIDERS;
|
|
137
|
+
}
|
|
138
|
+
function listProviders() {
|
|
139
|
+
console.log(" opt-in signals (off until you enable them):");
|
|
140
|
+
for (const [name, desc] of Object.entries(OPT_IN_PROVIDERS)) {
|
|
141
|
+
console.log(` ${name.padEnd(14)} ${desc}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function cmdEnable(args) {
|
|
145
|
+
const [name, ...valueParts] = args;
|
|
146
|
+
if (!name) {
|
|
147
|
+
console.log(" usage: cadence enable <signal> [value] e.g. cadence enable typingTempo");
|
|
148
|
+
listProviders();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!knownProvider(name)) {
|
|
152
|
+
console.error(` unknown signal "${name}".`);
|
|
153
|
+
if (name === "spotify")
|
|
154
|
+
console.error(" spotify takes credentials — run: cadence spotify");
|
|
155
|
+
listProviders();
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const cfg = await loadCfg();
|
|
159
|
+
const providers = cfgProviders(cfg);
|
|
160
|
+
providers[name] = valueParts.length ? valueParts.join(" ") : true;
|
|
161
|
+
cfg["providers"] = providers;
|
|
162
|
+
await saveCfg(cfg);
|
|
163
|
+
console.log(` enabled ${name}${valueParts.length ? ` = "${valueParts.join(" ")}"` : ""}`);
|
|
164
|
+
}
|
|
165
|
+
// The whole-product kill switch. State (pins, opt-ins, self-report) survives a
|
|
166
|
+
// pause untouched — resume picks up exactly where you left off.
|
|
167
|
+
async function cmdPause() {
|
|
168
|
+
const cfg = await loadCfg();
|
|
169
|
+
cfg["paused"] = true;
|
|
170
|
+
await saveCfg(cfg);
|
|
171
|
+
console.log(" cadence paused — prompts go through untouched. resume: cadence resume");
|
|
172
|
+
}
|
|
173
|
+
async function cmdResume() {
|
|
174
|
+
const cfg = await loadCfg();
|
|
175
|
+
delete cfg["paused"];
|
|
176
|
+
await saveCfg(cfg);
|
|
177
|
+
console.log(" cadence resumed — reading the room again. preview: cadence test");
|
|
178
|
+
}
|
|
179
|
+
async function cmdDisable(args) {
|
|
180
|
+
const [name] = args;
|
|
181
|
+
if (!name) {
|
|
182
|
+
console.log(" usage: cadence disable <signal>");
|
|
183
|
+
listProviders();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const cfg = await loadCfg();
|
|
187
|
+
const providers = cfgProviders(cfg);
|
|
188
|
+
delete providers[name];
|
|
189
|
+
cfg["providers"] = providers;
|
|
190
|
+
await saveCfg(cfg);
|
|
191
|
+
console.log(` disabled ${name}`);
|
|
192
|
+
}
|
|
193
|
+
// Spotify is the cross-platform music source — opt-in, browser-authorized via
|
|
194
|
+
// PKCE (no client secret to keep). Bring your own Spotify app client id (or
|
|
195
|
+
// bake one in below / via env for a zero-config experience).
|
|
196
|
+
const DEFAULT_SPOTIFY_CLIENT_ID = ""; // register a "Cadence" app and set this to ship zero-config
|
|
197
|
+
const SPOTIFY_HELP = ` cadence spotify — link Spotify as a cross-platform now-playing source
|
|
198
|
+
|
|
199
|
+
macOS already reads Spotify.app / Music.app with zero setup. This is for
|
|
200
|
+
Linux / Windows (or anyone not scripting the desktop app).
|
|
201
|
+
|
|
202
|
+
One-time setup, then we handle the token dance for you:
|
|
203
|
+
1. Create an app at https://developer.spotify.com/dashboard
|
|
204
|
+
2. Add this redirect URI to it: ${REDIRECT_URI}
|
|
205
|
+
3. cadence spotify connect <clientId> (opens your browser once)
|
|
206
|
+
|
|
207
|
+
Then it's just another music signal — vibe still comes from MusicBrainz.
|
|
208
|
+
Advanced (skip the browser): cadence spotify <clientId> <refreshToken>
|
|
209
|
+
Turn it off: cadence spotify off`;
|
|
210
|
+
async function cmdSpotifyConnect(clientIdArg) {
|
|
211
|
+
const clientId = clientIdArg || process.env["CADENCE_SPOTIFY_CLIENT_ID"] || DEFAULT_SPOTIFY_CLIENT_ID;
|
|
212
|
+
if (!clientId) {
|
|
213
|
+
console.log(" cadence spotify connect needs a Spotify app client id.\n");
|
|
214
|
+
console.log(SPOTIFY_HELP);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!process.stdin.isTTY) {
|
|
218
|
+
console.log(" spotify connect is interactive — run it in a terminal.");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const refreshToken = await connectSpotify(clientId, (m) => console.log(m));
|
|
223
|
+
const cfg = await loadCfg();
|
|
224
|
+
const providers = cfgProviders(cfg);
|
|
225
|
+
providers["spotify"] = { clientId, refreshToken };
|
|
226
|
+
cfg["providers"] = providers;
|
|
227
|
+
await saveCfg(cfg);
|
|
228
|
+
console.log(" ✓ Spotify linked — currently-playing is now a cross-platform signal");
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
console.error(` couldn't link Spotify: ${e instanceof Error ? e.message : String(e)}`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async function cmdSpotify(args) {
|
|
236
|
+
const [first, second, third] = args;
|
|
237
|
+
if (first === "connect")
|
|
238
|
+
return cmdSpotifyConnect(second);
|
|
239
|
+
if (first === "off") {
|
|
240
|
+
const cfg = await loadCfg();
|
|
241
|
+
const providers = cfgProviders(cfg);
|
|
242
|
+
delete providers["spotify"];
|
|
243
|
+
cfg["providers"] = providers;
|
|
244
|
+
await saveCfg(cfg);
|
|
245
|
+
console.log(" unlinked Spotify");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// advanced manual path: clientId + refreshToken (+ optional secret)
|
|
249
|
+
if (first && second) {
|
|
250
|
+
const cfg = await loadCfg();
|
|
251
|
+
const providers = cfgProviders(cfg);
|
|
252
|
+
providers["spotify"] = third
|
|
253
|
+
? { clientId: first, refreshToken: second, clientSecret: third }
|
|
254
|
+
: { clientId: first, refreshToken: second };
|
|
255
|
+
cfg["providers"] = providers;
|
|
256
|
+
await saveCfg(cfg);
|
|
257
|
+
console.log(" Spotify linked — currently-playing is now a cross-platform signal");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
console.log(SPOTIFY_HELP);
|
|
261
|
+
}
|
|
83
262
|
const LEVELS = ["low", "medium", "high"];
|
|
84
263
|
async function cmdSet(args) {
|
|
85
264
|
const [dial, value] = args;
|
|
@@ -189,18 +368,23 @@ async function hasUserInput() {
|
|
|
189
368
|
return state.trim().length > 0 || Object.keys(cfg).length > 0;
|
|
190
369
|
}
|
|
191
370
|
const INPUTS_FOOTER = ` where you can input:
|
|
192
|
-
cadence
|
|
371
|
+
cadence report "..." how you are right now (2h TTL)
|
|
193
372
|
cadence set <dial> <level> pin a dial: ${DIALS.join(", ")}
|
|
194
373
|
cadence set-location <lat> <lon> opt into weather
|
|
195
374
|
cadence start interactive setup
|
|
196
375
|
cadence help everything else`;
|
|
197
376
|
// Bare \`cadence\`: live status + where to input — not a help dump.
|
|
198
377
|
async function cmdRoot() {
|
|
378
|
+
if (await isPaused()) {
|
|
379
|
+
console.log("\n cadence is paused — prompts go through untouched.");
|
|
380
|
+
console.log(" resume: cadence resume\n");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
199
383
|
if (!(await hasUserInput())) {
|
|
200
384
|
console.log("\n cadence — agents that read the room");
|
|
201
385
|
console.log(" It hasn't heard from you yet. Fastest start:\n");
|
|
202
386
|
console.log(' cadence start guided setup (~30s)');
|
|
203
|
-
console.log(' cadence
|
|
387
|
+
console.log(' cadence report "ship mode" or just say how you are\n');
|
|
204
388
|
return;
|
|
205
389
|
}
|
|
206
390
|
const block = await buildPreview();
|
|
@@ -215,7 +399,7 @@ async function cmdRoot() {
|
|
|
215
399
|
// Guided first run: three prompts, every one skippable, nothing destructive.
|
|
216
400
|
async function cmdStart() {
|
|
217
401
|
if (!process.stdin.isTTY) {
|
|
218
|
-
console.log(' cadence start is interactive — run it in a terminal, or use: cadence
|
|
402
|
+
console.log(' cadence start is interactive — run it in a terminal, or use: cadence report "..."');
|
|
219
403
|
return;
|
|
220
404
|
}
|
|
221
405
|
const { createInterface } = await import("node:readline/promises");
|
|
@@ -228,10 +412,10 @@ async function cmdStart() {
|
|
|
228
412
|
if (state) {
|
|
229
413
|
await mkdir(CADENCE_DIR, { recursive: true });
|
|
230
414
|
await writeFile(STATE_FILE, state, "utf-8");
|
|
231
|
-
console.log(' ✓ set — expires after
|
|
415
|
+
console.log(' ✓ set — expires after 2h; update anytime: cadence report "..."\n');
|
|
232
416
|
}
|
|
233
417
|
else {
|
|
234
|
-
console.log(' skipped — later: cadence
|
|
418
|
+
console.log(' skipped — later: cadence report "..."\n');
|
|
235
419
|
}
|
|
236
420
|
// 2 ── dial pins: overrides, so only offered, never pushed
|
|
237
421
|
console.log(` 2/3 Pin any dials? Pins override inference until unset.`);
|
|
@@ -279,12 +463,15 @@ const HELP = `
|
|
|
279
463
|
|
|
280
464
|
daily:
|
|
281
465
|
cadence live status + where to input
|
|
282
|
-
cadence start guided setup (
|
|
283
|
-
cadence
|
|
284
|
-
cadence
|
|
285
|
-
|
|
466
|
+
cadence start guided setup (self-report, dials, weather — all skippable)
|
|
467
|
+
cadence report "..." set your self-report (e.g. "two beers, ship mode")
|
|
468
|
+
cadence report print current self-report
|
|
469
|
+
("cadence state" still works as an alias)
|
|
470
|
+
cadence clear clear self-report
|
|
286
471
|
cadence test preview what the hook would inject right now
|
|
287
472
|
cadence signals every signal — live value, or why it's absent
|
|
473
|
+
cadence pause silence all hooks (state survives untouched)
|
|
474
|
+
cadence resume start reading the room again
|
|
288
475
|
|
|
289
476
|
dials (your determination — pinned dials override inference):
|
|
290
477
|
cadence dials show the mixing board and what's pinned
|
|
@@ -293,16 +480,28 @@ const HELP = `
|
|
|
293
480
|
dials: pace, tone, posture, proactivity
|
|
294
481
|
(env also works: CADENCE_PACE=fast)
|
|
295
482
|
|
|
296
|
-
|
|
483
|
+
environment (time & day are automatic; weather is opt-in):
|
|
297
484
|
cadence set-location <lat> <lon> [name] turn on weather for your area
|
|
485
|
+
|
|
486
|
+
opt-in signals (off until you turn them on — as much as you're willing to give):
|
|
487
|
+
cadence enable <signal> [value] turn an opt-in signal on (e.g. typingTempo)
|
|
488
|
+
cadence disable <signal> turn it back off
|
|
489
|
+
see them all: cadence signals
|
|
490
|
+
|
|
491
|
+
music (macOS reads Spotify.app / Music.app automatically):
|
|
492
|
+
cadence spotify connect <id> link Spotify (cross-platform, opens browser)
|
|
493
|
+
cadence spotify off unlink it
|
|
298
494
|
`;
|
|
299
495
|
async function main() {
|
|
300
496
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
301
497
|
switch (cmd) {
|
|
302
498
|
case "start":
|
|
303
499
|
return cmdStart();
|
|
304
|
-
case "
|
|
305
|
-
return
|
|
500
|
+
case "report":
|
|
501
|
+
return cmdReport(rest);
|
|
502
|
+
case "state": // deprecated alias for `report` — kept for alpha installs
|
|
503
|
+
console.error(' note: "cadence state" is now "cadence report" (alias kept for now)');
|
|
504
|
+
return cmdReport(rest);
|
|
306
505
|
case "clear":
|
|
307
506
|
return cmdClear();
|
|
308
507
|
case "test":
|
|
@@ -317,6 +516,16 @@ async function main() {
|
|
|
317
516
|
return cmdDials();
|
|
318
517
|
case "set-location":
|
|
319
518
|
return cmdLocation(rest);
|
|
519
|
+
case "pause":
|
|
520
|
+
return cmdPause();
|
|
521
|
+
case "resume":
|
|
522
|
+
return cmdResume();
|
|
523
|
+
case "enable":
|
|
524
|
+
return cmdEnable(rest);
|
|
525
|
+
case "disable":
|
|
526
|
+
return cmdDisable(rest);
|
|
527
|
+
case "spotify":
|
|
528
|
+
return cmdSpotify(rest);
|
|
320
529
|
case undefined:
|
|
321
530
|
return cmdRoot(); // live status + inputs, not the help dump
|
|
322
531
|
case "help":
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* Config + the opt-in provider registry.
|
|
6
|
+
*
|
|
7
|
+
* `~/.cadence/config.json` already held pinned dials and a weather location.
|
|
8
|
+
* It now also holds a `providers` block — the consent registry. Signals that
|
|
9
|
+
* read something the user might not want shared (their calendar, the app
|
|
10
|
+
* they're in, an esoteric feed) stay OFF until the user names them here:
|
|
11
|
+
*
|
|
12
|
+
* { "providers": { "typingTempo": true, "focusedApp": true,
|
|
13
|
+
* "calendar": { "ics": "https://…" }, "horoscope": "leo" } }
|
|
14
|
+
*
|
|
15
|
+
* The rule is "as many signals as the user is willing to give": nothing
|
|
16
|
+
* privacy-adjacent fires on inference alone. Always resolves — a missing or
|
|
17
|
+
* garbled file reads as "nothing opted in," never throws.
|
|
18
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
19
|
+
const CONFIG_FILE = join(homedir(), ".cadence", "config.json");
|
|
20
|
+
/* The opt-in signals the user can turn on, and a one-line description for the
|
|
21
|
+
* CLI. Adding a row here is what makes `cadence enable <name>` accept it — the
|
|
22
|
+
* single source of truth shared by the CLI, the signals view, and the
|
|
23
|
+
* providers. Grow this as opt-in providers land. */
|
|
24
|
+
export const OPT_IN_PROVIDERS = {
|
|
25
|
+
typingTempo: "prompt rhythm — rapid-fire vs. one long considered prompt → pace",
|
|
26
|
+
focusedApp: "frontmost non-terminal app (macOS) → flavor in the context line",
|
|
27
|
+
wifi: "wifi network name (macOS) → place context, home vs. office vs. café",
|
|
28
|
+
moon: "current moon phase (offline) → esoteric flavor",
|
|
29
|
+
horoscope: "daily horoscope for your sign, e.g. `cadence enable horoscope leo`",
|
|
30
|
+
};
|
|
31
|
+
export async function loadConfig() {
|
|
32
|
+
try {
|
|
33
|
+
const raw = JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
|
|
34
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return {}; // no/garbled config → empty, never throw
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** The opt-in registry, or `{}` if the user hasn't opted into anything. */
|
|
41
|
+
export function readProviders(cfg) {
|
|
42
|
+
const p = cfg["providers"];
|
|
43
|
+
return p && typeof p === "object" ? p : {};
|
|
44
|
+
}
|
|
45
|
+
export async function loadProviders() {
|
|
46
|
+
return readProviders(await loadConfig());
|
|
47
|
+
}
|
|
48
|
+
/* A provider is enabled when its key carries a truthy value: `true`, a config
|
|
49
|
+
* object (`{ ics: "…" }`), or a setting string (`"leo"`). `false`, `null`,
|
|
50
|
+
* empty string, or an empty object all read as "off" — tri-state honesty, so
|
|
51
|
+
* `"horoscope": ""` doesn't silently count as consent. */
|
|
52
|
+
export function providerEnabled(providers, name) {
|
|
53
|
+
const v = providers[name];
|
|
54
|
+
if (v == null || v === false || v === "")
|
|
55
|
+
return false;
|
|
56
|
+
if (typeof v === "object")
|
|
57
|
+
return Object.keys(v).length > 0;
|
|
58
|
+
return Boolean(v);
|
|
59
|
+
}
|
|
60
|
+
/** The raw setting for a provider (e.g. the horoscope sign, the calendar ics
|
|
61
|
+
* object), or undefined when it's off. Lets a provider read its own config
|
|
62
|
+
* without re-parsing the file. */
|
|
63
|
+
export function providerSetting(providers, name) {
|
|
64
|
+
return providerEnabled(providers, name) ? providers[name] : undefined;
|
|
65
|
+
}
|
|
66
|
+
/* ── pause: the whole-product kill switch ──────────────────────────────────
|
|
67
|
+
* An ambient layer that injects into every prompt needs a visible, instant
|
|
68
|
+
* off switch. `cadence pause` sets `"paused": true`; every hook checks it
|
|
69
|
+
* FIRST and exits silently — no signals read, no subprocesses spawned, no
|
|
70
|
+
* block injected. State (pins, opt-ins, self-report) is preserved untouched,
|
|
71
|
+
* so `cadence resume` picks up exactly where you left off. */
|
|
72
|
+
export function readPaused(cfg) {
|
|
73
|
+
return cfg["paused"] === true;
|
|
74
|
+
}
|
|
75
|
+
export async function isPaused() {
|
|
76
|
+
return readPaused(await loadConfig());
|
|
77
|
+
}
|
package/dist/hook.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { getMusicSignal } from "./providers/music.js";
|
|
3
3
|
import { getSelfReportSignal } from "./providers/selfreport.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getEnvironmentSignal } from "./providers/environment.js";
|
|
5
5
|
import { getGitSignal } from "./providers/git.js";
|
|
6
6
|
import { getActivitySignal } from "./providers/activity.js";
|
|
7
|
+
import { getIntentSignal } from "./providers/intent.js";
|
|
8
|
+
import { getEsotericSignal } from "./providers/esoteric.js";
|
|
7
9
|
import { deriveCadence, buildReframe, loadOverrides, applyOverrides } from "./cadence.js";
|
|
10
|
+
import { loadProviders, providerEnabled, isPaused } from "./config.js";
|
|
8
11
|
import { render } from "./inject.js";
|
|
9
12
|
import { debug } from "./debug.js";
|
|
10
13
|
const TOTAL_BUDGET_MS = 1500;
|
|
@@ -27,39 +30,56 @@ async function readStdin() {
|
|
|
27
30
|
return {};
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
|
-
async function collectSignals(cwd, prompt) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
+
async function collectSignals(cwd, prompt, providers) {
|
|
34
|
+
const tempoEnabled = providerEnabled(providers, "typingTempo");
|
|
35
|
+
const [music, report, environment, git, activity, intent, esoteric] = await Promise.allSettled([
|
|
36
|
+
getMusicSignal(providers),
|
|
33
37
|
getSelfReportSignal(),
|
|
34
|
-
|
|
38
|
+
getEnvironmentSignal(new Date(), {
|
|
39
|
+
focusedAppEnabled: providerEnabled(providers, "focusedApp"),
|
|
40
|
+
wifiEnabled: providerEnabled(providers, "wifi"),
|
|
41
|
+
}),
|
|
35
42
|
getGitSignal(cwd),
|
|
36
|
-
getActivitySignal(prompt),
|
|
43
|
+
getActivitySignal(prompt, Date.now(), { tempoEnabled }),
|
|
44
|
+
getIntentSignal(prompt),
|
|
45
|
+
getEsotericSignal(providers),
|
|
37
46
|
]);
|
|
38
47
|
const signals = [];
|
|
39
48
|
if (music.status === "fulfilled" && music.value)
|
|
40
49
|
signals.push(music.value);
|
|
41
50
|
if (report.status === "fulfilled" && report.value)
|
|
42
51
|
signals.push(report.value);
|
|
43
|
-
if (
|
|
44
|
-
signals.push(
|
|
52
|
+
if (environment.status === "fulfilled" && environment.value)
|
|
53
|
+
signals.push(environment.value);
|
|
45
54
|
if (git.status === "fulfilled" && git.value)
|
|
46
55
|
signals.push(git.value);
|
|
47
56
|
if (activity.status === "fulfilled" && activity.value)
|
|
48
57
|
signals.push(activity.value);
|
|
58
|
+
if (intent.status === "fulfilled" && intent.value)
|
|
59
|
+
signals.push(intent.value);
|
|
60
|
+
if (esoteric.status === "fulfilled" && esoteric.value)
|
|
61
|
+
signals.push(esoteric.value);
|
|
49
62
|
return signals;
|
|
50
63
|
}
|
|
51
64
|
async function main() {
|
|
65
|
+
// Paused = the user asked for silence. Check FIRST: no signals read, no
|
|
66
|
+
// subprocesses spawned, nothing injected. `cadence resume` turns it back on.
|
|
67
|
+
if (await isPaused())
|
|
68
|
+
process.exit(0);
|
|
52
69
|
const { cwd, prompt } = await readStdin();
|
|
53
70
|
const projectDir = cwd ?? process.cwd();
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
// Pins + the opt-in registry are tiny local reads; load them first so signal
|
|
72
|
+
// collection knows which opt-in providers to run, then race only the
|
|
73
|
+
// subprocess-heavy collection against the budget.
|
|
74
|
+
const [overrides, providers] = await Promise.all([loadOverrides(), loadProviders()]);
|
|
75
|
+
const signals = await Promise.race([
|
|
76
|
+
collectSignals(projectDir, prompt, providers),
|
|
77
|
+
// unref: the losing timer must not hold the process open after the
|
|
78
|
+
// race settles — Claude Code waits on our EXIT, not our output.
|
|
79
|
+
new Promise((resolve) => setTimeout(() => {
|
|
80
|
+
debug("hook", `signal collection exceeded ${TOTAL_BUDGET_MS}ms budget — injecting without signals`);
|
|
81
|
+
resolve([]);
|
|
82
|
+
}, TOTAL_BUDGET_MS).unref()),
|
|
63
83
|
]);
|
|
64
84
|
// Nothing to say: no signals AND no pinned dials.
|
|
65
85
|
if (signals.length === 0 && Object.keys(overrides).length === 0) {
|
|
@@ -70,12 +90,14 @@ async function main() {
|
|
|
70
90
|
const reframe = buildReframe(cadence);
|
|
71
91
|
const stateWithCadence = { ...state, cadence, pinned, reframe };
|
|
72
92
|
const block = render(stateWithCadence);
|
|
93
|
+
// Exit in the write callback (stdout to a pipe can flush async): a straggling
|
|
94
|
+
// provider subprocess must never keep the user's prompt waiting on our exit.
|
|
73
95
|
process.stdout.write(JSON.stringify({
|
|
74
96
|
hookSpecificOutput: {
|
|
75
97
|
hookEventName: "UserPromptSubmit",
|
|
76
98
|
additionalContext: block,
|
|
77
99
|
},
|
|
78
|
-
}));
|
|
100
|
+
}), () => process.exit(0));
|
|
79
101
|
}
|
|
80
102
|
main().catch((err) => {
|
|
81
103
|
const msg = err instanceof Error ? err.message : String(err);
|
package/dist/inject.js
CHANGED
|
@@ -28,10 +28,16 @@ function renderGit(g) {
|
|
|
28
28
|
].filter(Boolean);
|
|
29
29
|
return ` git: ${parts.join(", ")}`;
|
|
30
30
|
}
|
|
31
|
+
function renderIntent(i) {
|
|
32
|
+
if (!i.kind)
|
|
33
|
+
return [];
|
|
34
|
+
return [` intent: ${i.kind} (read from your prompt)`];
|
|
35
|
+
}
|
|
31
36
|
function renderActivity(a) {
|
|
32
37
|
const parts = [
|
|
33
38
|
a.minSinceLastPrompt != null ? `min_since_prompt=${a.minSinceLastPrompt}` : null,
|
|
34
39
|
a.promptLength != null ? `prompt_len=${a.promptLength}` : null,
|
|
40
|
+
a.tempo ? `tempo=${a.tempo}` : null,
|
|
35
41
|
].filter(Boolean);
|
|
36
42
|
return ` activity: { ${parts.join(" ")} }`;
|
|
37
43
|
}
|
|
@@ -42,7 +48,7 @@ function renderPlace(p) {
|
|
|
42
48
|
].filter(Boolean);
|
|
43
49
|
return ` place: { ${parts.join(" ")} }`;
|
|
44
50
|
}
|
|
45
|
-
function
|
|
51
|
+
function renderEnvironment(a) {
|
|
46
52
|
// Human-readable atmosphere line, e.g.
|
|
47
53
|
// "friday late night, rainy, unplugged 8%, dark mode, on Home-wifi, up 14h"
|
|
48
54
|
const parts = [
|
|
@@ -52,6 +58,7 @@ function renderAmbient(a) {
|
|
|
52
58
|
? `unplugged${a.batteryPct != null ? ` ${a.batteryPct}%` : ""}`
|
|
53
59
|
: null,
|
|
54
60
|
a.focus === true ? "focus on" : null,
|
|
61
|
+
a.focusedApp ? `in ${quote(a.focusedApp)}` : null,
|
|
55
62
|
a.darkMode === true ? "dark mode" : null,
|
|
56
63
|
a.displays != null && a.displays > 1 ? `${a.displays} displays` : null,
|
|
57
64
|
a.network ? `on ${quote(a.network)}` : null,
|
|
@@ -60,6 +67,13 @@ function renderAmbient(a) {
|
|
|
60
67
|
].filter(Boolean);
|
|
61
68
|
return ` context: ${parts.join(", ")}`;
|
|
62
69
|
}
|
|
70
|
+
function renderEsoteric(e) {
|
|
71
|
+
const parts = [
|
|
72
|
+
e.moonPhase ? `moon ${e.moonPhase}` : null,
|
|
73
|
+
e.horoscope ? `${e.sign ?? "horoscope"}: ${quote(e.horoscope)}` : null,
|
|
74
|
+
].filter(Boolean);
|
|
75
|
+
return parts.length ? [` esoteric: ${parts.join(" · ")}`] : [];
|
|
76
|
+
}
|
|
63
77
|
function renderCadence(c, pinned) {
|
|
64
78
|
const dials = Object.keys(DIAL_WORDS)
|
|
65
79
|
.map((d) => {
|
|
@@ -78,12 +92,16 @@ export function render(state) {
|
|
|
78
92
|
lines.push(renderReport(sig));
|
|
79
93
|
else if (sig.source === "git")
|
|
80
94
|
lines.push(renderGit(sig));
|
|
95
|
+
else if (sig.source === "intent")
|
|
96
|
+
lines.push(...renderIntent(sig));
|
|
81
97
|
else if (sig.source === "activity")
|
|
82
98
|
lines.push(renderActivity(sig));
|
|
83
99
|
else if (sig.source === "place")
|
|
84
100
|
lines.push(renderPlace(sig));
|
|
85
|
-
else if (sig.source === "
|
|
86
|
-
lines.push(
|
|
101
|
+
else if (sig.source === "environment")
|
|
102
|
+
lines.push(renderEnvironment(sig));
|
|
103
|
+
else if (sig.source === "esoteric")
|
|
104
|
+
lines.push(...renderEsoteric(sig));
|
|
87
105
|
}
|
|
88
106
|
// Render even with zero signals if the user pinned dials — a hand-set board
|
|
89
107
|
// is itself a signal worth injecting.
|