@cullumco/cadence 0.1.3 → 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 +114 -66
- 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/cli.js
CHANGED
|
@@ -4,15 +4,18 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { getMusicSignal } from "./providers/music.js";
|
|
6
6
|
import { getSelfReportSignal } from "./providers/selfreport.js";
|
|
7
|
-
import {
|
|
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) {
|
|
17
20
|
try {
|
|
18
21
|
const text = (await readFile(STATE_FILE, "utf-8")).trim();
|
|
@@ -37,21 +40,27 @@ async function cmdClear() {
|
|
|
37
40
|
// or null when there's nothing to say. Shared by `test` and the bare command.
|
|
38
41
|
async function buildPreview() {
|
|
39
42
|
const signals = [];
|
|
40
|
-
const
|
|
41
|
-
|
|
43
|
+
const providers = await loadProviders();
|
|
44
|
+
const [music, report, environment, git, esoteric, overrides] = await Promise.all([
|
|
45
|
+
getMusicSignal(providers).catch(() => null),
|
|
42
46
|
getSelfReportSignal().catch(() => null),
|
|
43
|
-
|
|
47
|
+
getEnvironmentSignal(new Date(), {
|
|
48
|
+
focusedAppEnabled: providerEnabled(providers, "focusedApp"),
|
|
49
|
+
}).catch(() => null),
|
|
44
50
|
getGitSignal(process.cwd()).catch(() => null),
|
|
51
|
+
getEsotericSignal(providers).catch(() => null),
|
|
45
52
|
loadOverrides(),
|
|
46
53
|
]);
|
|
47
54
|
if (music)
|
|
48
55
|
signals.push(music);
|
|
49
56
|
if (report)
|
|
50
57
|
signals.push(report);
|
|
51
|
-
if (
|
|
52
|
-
signals.push(
|
|
58
|
+
if (environment)
|
|
59
|
+
signals.push(environment);
|
|
53
60
|
if (git)
|
|
54
61
|
signals.push(git);
|
|
62
|
+
if (esoteric)
|
|
63
|
+
signals.push(esoteric);
|
|
55
64
|
if (signals.length === 0 && Object.keys(overrides).length === 0)
|
|
56
65
|
return null;
|
|
57
66
|
const state = { signals, capturedAt: Date.now() };
|
|
@@ -62,7 +71,7 @@ async function buildPreview() {
|
|
|
62
71
|
async function cmdTest() {
|
|
63
72
|
const block = await buildPreview();
|
|
64
73
|
if (!block) {
|
|
65
|
-
console.log(' (no signals — play something, set: cadence
|
|
74
|
+
console.log(' (no signals — play something, set: cadence report "...", or pin a dial: cadence set pace fast)');
|
|
66
75
|
return;
|
|
67
76
|
}
|
|
68
77
|
console.log("\n" + block + "\n");
|
|
@@ -70,16 +79,170 @@ async function cmdTest() {
|
|
|
70
79
|
// The legibility view: every signal Cadence can read — live value, or the
|
|
71
80
|
// reason it's absent. Unlike `test`, this never goes silent.
|
|
72
81
|
async function cmdSignals() {
|
|
73
|
-
const [music, report,
|
|
82
|
+
const [music, report, environment, git, providers] = await Promise.all([
|
|
74
83
|
getMusicSignal().catch(() => null),
|
|
75
84
|
getSelfReportSignal().catch(() => null),
|
|
76
|
-
|
|
85
|
+
getEnvironmentSignal(new Date()).catch(() => null),
|
|
77
86
|
getGitSignal(process.cwd()).catch(() => null),
|
|
87
|
+
loadProviders(),
|
|
78
88
|
]);
|
|
79
89
|
console.log("\n" +
|
|
80
|
-
renderSignalsTable({
|
|
90
|
+
renderSignalsTable({
|
|
91
|
+
music,
|
|
92
|
+
report,
|
|
93
|
+
environment,
|
|
94
|
+
git,
|
|
95
|
+
providers,
|
|
96
|
+
now: Date.now(),
|
|
97
|
+
platform: process.platform,
|
|
98
|
+
}) +
|
|
81
99
|
"\n");
|
|
82
100
|
}
|
|
101
|
+
// ── opt-in provider registry: the consent layer ────────────────────────────
|
|
102
|
+
async function loadCfg() {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function saveCfg(cfg) {
|
|
111
|
+
await mkdir(CADENCE_DIR, { recursive: true });
|
|
112
|
+
await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
113
|
+
}
|
|
114
|
+
function cfgProviders(cfg) {
|
|
115
|
+
return cfg["providers"] && typeof cfg["providers"] === "object"
|
|
116
|
+
? cfg["providers"]
|
|
117
|
+
: {};
|
|
118
|
+
}
|
|
119
|
+
function knownProvider(name) {
|
|
120
|
+
return name != null && name in OPT_IN_PROVIDERS;
|
|
121
|
+
}
|
|
122
|
+
function listProviders() {
|
|
123
|
+
console.log(" opt-in signals (off until you enable them):");
|
|
124
|
+
for (const [name, desc] of Object.entries(OPT_IN_PROVIDERS)) {
|
|
125
|
+
console.log(` ${name.padEnd(14)} ${desc}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function cmdEnable(args) {
|
|
129
|
+
const [name, ...valueParts] = args;
|
|
130
|
+
if (!name) {
|
|
131
|
+
console.log(" usage: cadence enable <signal> [value] e.g. cadence enable typingTempo");
|
|
132
|
+
listProviders();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!knownProvider(name)) {
|
|
136
|
+
console.error(` unknown signal "${name}".`);
|
|
137
|
+
if (name === "spotify")
|
|
138
|
+
console.error(" spotify takes credentials — run: cadence spotify");
|
|
139
|
+
listProviders();
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
const cfg = await loadCfg();
|
|
143
|
+
const providers = cfgProviders(cfg);
|
|
144
|
+
providers[name] = valueParts.length ? valueParts.join(" ") : true;
|
|
145
|
+
cfg["providers"] = providers;
|
|
146
|
+
await saveCfg(cfg);
|
|
147
|
+
console.log(` enabled ${name}${valueParts.length ? ` = "${valueParts.join(" ")}"` : ""}`);
|
|
148
|
+
}
|
|
149
|
+
// The whole-product kill switch. State (pins, opt-ins, self-report) survives a
|
|
150
|
+
// pause untouched — resume picks up exactly where you left off.
|
|
151
|
+
async function cmdPause() {
|
|
152
|
+
const cfg = await loadCfg();
|
|
153
|
+
cfg["paused"] = true;
|
|
154
|
+
await saveCfg(cfg);
|
|
155
|
+
console.log(" cadence paused — prompts go through untouched. resume: cadence resume");
|
|
156
|
+
}
|
|
157
|
+
async function cmdResume() {
|
|
158
|
+
const cfg = await loadCfg();
|
|
159
|
+
delete cfg["paused"];
|
|
160
|
+
await saveCfg(cfg);
|
|
161
|
+
console.log(" cadence resumed — reading the room again. preview: cadence test");
|
|
162
|
+
}
|
|
163
|
+
async function cmdDisable(args) {
|
|
164
|
+
const [name] = args;
|
|
165
|
+
if (!name) {
|
|
166
|
+
console.log(" usage: cadence disable <signal>");
|
|
167
|
+
listProviders();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const cfg = await loadCfg();
|
|
171
|
+
const providers = cfgProviders(cfg);
|
|
172
|
+
delete providers[name];
|
|
173
|
+
cfg["providers"] = providers;
|
|
174
|
+
await saveCfg(cfg);
|
|
175
|
+
console.log(` disabled ${name}`);
|
|
176
|
+
}
|
|
177
|
+
// Spotify is the cross-platform music source — opt-in, browser-authorized via
|
|
178
|
+
// PKCE (no client secret to keep). Bring your own Spotify app client id (or
|
|
179
|
+
// bake one in below / via env for a zero-config experience).
|
|
180
|
+
const DEFAULT_SPOTIFY_CLIENT_ID = ""; // register a "Cadence" app and set this to ship zero-config
|
|
181
|
+
const SPOTIFY_HELP = ` cadence spotify — link Spotify as a cross-platform now-playing source
|
|
182
|
+
|
|
183
|
+
macOS already reads Spotify.app / Music.app with zero setup. This is for
|
|
184
|
+
Linux / Windows (or anyone not scripting the desktop app).
|
|
185
|
+
|
|
186
|
+
One-time setup, then we handle the token dance for you:
|
|
187
|
+
1. Create an app at https://developer.spotify.com/dashboard
|
|
188
|
+
2. Add this redirect URI to it: ${REDIRECT_URI}
|
|
189
|
+
3. cadence spotify connect <clientId> (opens your browser once)
|
|
190
|
+
|
|
191
|
+
Then it's just another music signal — vibe still comes from MusicBrainz.
|
|
192
|
+
Advanced (skip the browser): cadence spotify <clientId> <refreshToken>
|
|
193
|
+
Turn it off: cadence spotify off`;
|
|
194
|
+
async function cmdSpotifyConnect(clientIdArg) {
|
|
195
|
+
const clientId = clientIdArg || process.env["CADENCE_SPOTIFY_CLIENT_ID"] || DEFAULT_SPOTIFY_CLIENT_ID;
|
|
196
|
+
if (!clientId) {
|
|
197
|
+
console.log(" cadence spotify connect needs a Spotify app client id.\n");
|
|
198
|
+
console.log(SPOTIFY_HELP);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (!process.stdin.isTTY) {
|
|
202
|
+
console.log(" spotify connect is interactive — run it in a terminal.");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const refreshToken = await connectSpotify(clientId, (m) => console.log(m));
|
|
207
|
+
const cfg = await loadCfg();
|
|
208
|
+
const providers = cfgProviders(cfg);
|
|
209
|
+
providers["spotify"] = { clientId, refreshToken };
|
|
210
|
+
cfg["providers"] = providers;
|
|
211
|
+
await saveCfg(cfg);
|
|
212
|
+
console.log(" ✓ Spotify linked — currently-playing is now a cross-platform signal");
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
console.error(` couldn't link Spotify: ${e instanceof Error ? e.message : String(e)}`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function cmdSpotify(args) {
|
|
220
|
+
const [first, second, third] = args;
|
|
221
|
+
if (first === "connect")
|
|
222
|
+
return cmdSpotifyConnect(second);
|
|
223
|
+
if (first === "off") {
|
|
224
|
+
const cfg = await loadCfg();
|
|
225
|
+
const providers = cfgProviders(cfg);
|
|
226
|
+
delete providers["spotify"];
|
|
227
|
+
cfg["providers"] = providers;
|
|
228
|
+
await saveCfg(cfg);
|
|
229
|
+
console.log(" unlinked Spotify");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// advanced manual path: clientId + refreshToken (+ optional secret)
|
|
233
|
+
if (first && second) {
|
|
234
|
+
const cfg = await loadCfg();
|
|
235
|
+
const providers = cfgProviders(cfg);
|
|
236
|
+
providers["spotify"] = third
|
|
237
|
+
? { clientId: first, refreshToken: second, clientSecret: third }
|
|
238
|
+
: { clientId: first, refreshToken: second };
|
|
239
|
+
cfg["providers"] = providers;
|
|
240
|
+
await saveCfg(cfg);
|
|
241
|
+
console.log(" Spotify linked — currently-playing is now a cross-platform signal");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
console.log(SPOTIFY_HELP);
|
|
245
|
+
}
|
|
83
246
|
const LEVELS = ["low", "medium", "high"];
|
|
84
247
|
async function cmdSet(args) {
|
|
85
248
|
const [dial, value] = args;
|
|
@@ -189,18 +352,23 @@ async function hasUserInput() {
|
|
|
189
352
|
return state.trim().length > 0 || Object.keys(cfg).length > 0;
|
|
190
353
|
}
|
|
191
354
|
const INPUTS_FOOTER = ` where you can input:
|
|
192
|
-
cadence
|
|
355
|
+
cadence report "..." how you are right now (2h TTL)
|
|
193
356
|
cadence set <dial> <level> pin a dial: ${DIALS.join(", ")}
|
|
194
357
|
cadence set-location <lat> <lon> opt into weather
|
|
195
358
|
cadence start interactive setup
|
|
196
359
|
cadence help everything else`;
|
|
197
360
|
// Bare \`cadence\`: live status + where to input — not a help dump.
|
|
198
361
|
async function cmdRoot() {
|
|
362
|
+
if (await isPaused()) {
|
|
363
|
+
console.log("\n cadence is paused — prompts go through untouched.");
|
|
364
|
+
console.log(" resume: cadence resume\n");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
199
367
|
if (!(await hasUserInput())) {
|
|
200
368
|
console.log("\n cadence — agents that read the room");
|
|
201
369
|
console.log(" It hasn't heard from you yet. Fastest start:\n");
|
|
202
370
|
console.log(' cadence start guided setup (~30s)');
|
|
203
|
-
console.log(' cadence
|
|
371
|
+
console.log(' cadence report "ship mode" or just say how you are\n');
|
|
204
372
|
return;
|
|
205
373
|
}
|
|
206
374
|
const block = await buildPreview();
|
|
@@ -215,7 +383,7 @@ async function cmdRoot() {
|
|
|
215
383
|
// Guided first run: three prompts, every one skippable, nothing destructive.
|
|
216
384
|
async function cmdStart() {
|
|
217
385
|
if (!process.stdin.isTTY) {
|
|
218
|
-
console.log(' cadence start is interactive — run it in a terminal, or use: cadence
|
|
386
|
+
console.log(' cadence start is interactive — run it in a terminal, or use: cadence report "..."');
|
|
219
387
|
return;
|
|
220
388
|
}
|
|
221
389
|
const { createInterface } = await import("node:readline/promises");
|
|
@@ -228,10 +396,10 @@ async function cmdStart() {
|
|
|
228
396
|
if (state) {
|
|
229
397
|
await mkdir(CADENCE_DIR, { recursive: true });
|
|
230
398
|
await writeFile(STATE_FILE, state, "utf-8");
|
|
231
|
-
console.log(' ✓ set — expires after
|
|
399
|
+
console.log(' ✓ set — expires after 2h; update anytime: cadence report "..."\n');
|
|
232
400
|
}
|
|
233
401
|
else {
|
|
234
|
-
console.log(' skipped — later: cadence
|
|
402
|
+
console.log(' skipped — later: cadence report "..."\n');
|
|
235
403
|
}
|
|
236
404
|
// 2 ── dial pins: overrides, so only offered, never pushed
|
|
237
405
|
console.log(` 2/3 Pin any dials? Pins override inference until unset.`);
|
|
@@ -279,12 +447,15 @@ const HELP = `
|
|
|
279
447
|
|
|
280
448
|
daily:
|
|
281
449
|
cadence live status + where to input
|
|
282
|
-
cadence start guided setup (
|
|
283
|
-
cadence
|
|
284
|
-
cadence
|
|
285
|
-
|
|
450
|
+
cadence start guided setup (self-report, dials, weather — all skippable)
|
|
451
|
+
cadence report "..." set your self-report (e.g. "two beers, ship mode")
|
|
452
|
+
cadence report print current self-report
|
|
453
|
+
("cadence state" still works as an alias)
|
|
454
|
+
cadence clear clear self-report
|
|
286
455
|
cadence test preview what the hook would inject right now
|
|
287
456
|
cadence signals every signal — live value, or why it's absent
|
|
457
|
+
cadence pause silence all hooks (state survives untouched)
|
|
458
|
+
cadence resume start reading the room again
|
|
288
459
|
|
|
289
460
|
dials (your determination — pinned dials override inference):
|
|
290
461
|
cadence dials show the mixing board and what's pinned
|
|
@@ -293,16 +464,28 @@ const HELP = `
|
|
|
293
464
|
dials: pace, tone, posture, proactivity
|
|
294
465
|
(env also works: CADENCE_PACE=fast)
|
|
295
466
|
|
|
296
|
-
|
|
467
|
+
environment (time & day are automatic; weather is opt-in):
|
|
297
468
|
cadence set-location <lat> <lon> [name] turn on weather for your area
|
|
469
|
+
|
|
470
|
+
opt-in signals (off until you turn them on — as much as you're willing to give):
|
|
471
|
+
cadence enable <signal> [value] turn an opt-in signal on (e.g. typingTempo)
|
|
472
|
+
cadence disable <signal> turn it back off
|
|
473
|
+
see them all: cadence signals
|
|
474
|
+
|
|
475
|
+
music (macOS reads Spotify.app / Music.app automatically):
|
|
476
|
+
cadence spotify connect <id> link Spotify (cross-platform, opens browser)
|
|
477
|
+
cadence spotify off unlink it
|
|
298
478
|
`;
|
|
299
479
|
async function main() {
|
|
300
480
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
301
481
|
switch (cmd) {
|
|
302
482
|
case "start":
|
|
303
483
|
return cmdStart();
|
|
304
|
-
case "
|
|
305
|
-
return
|
|
484
|
+
case "report":
|
|
485
|
+
return cmdReport(rest);
|
|
486
|
+
case "state": // deprecated alias for `report` — kept for alpha installs
|
|
487
|
+
console.error(' note: "cadence state" is now "cadence report" (alias kept for now)');
|
|
488
|
+
return cmdReport(rest);
|
|
306
489
|
case "clear":
|
|
307
490
|
return cmdClear();
|
|
308
491
|
case "test":
|
|
@@ -317,6 +500,16 @@ async function main() {
|
|
|
317
500
|
return cmdDials();
|
|
318
501
|
case "set-location":
|
|
319
502
|
return cmdLocation(rest);
|
|
503
|
+
case "pause":
|
|
504
|
+
return cmdPause();
|
|
505
|
+
case "resume":
|
|
506
|
+
return cmdResume();
|
|
507
|
+
case "enable":
|
|
508
|
+
return cmdEnable(rest);
|
|
509
|
+
case "disable":
|
|
510
|
+
return cmdDisable(rest);
|
|
511
|
+
case "spotify":
|
|
512
|
+
return cmdSpotify(rest);
|
|
320
513
|
case undefined:
|
|
321
514
|
return cmdRoot(); // live status + inputs, not the help dump
|
|
322
515
|
case "help":
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
moon: "current moon phase (offline) → esoteric flavor",
|
|
28
|
+
horoscope: "daily horoscope for your sign, e.g. `cadence enable horoscope leo`",
|
|
29
|
+
};
|
|
30
|
+
export async function loadConfig() {
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
|
|
33
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return {}; // no/garbled config → empty, never throw
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** The opt-in registry, or `{}` if the user hasn't opted into anything. */
|
|
40
|
+
export function readProviders(cfg) {
|
|
41
|
+
const p = cfg["providers"];
|
|
42
|
+
return p && typeof p === "object" ? p : {};
|
|
43
|
+
}
|
|
44
|
+
export async function loadProviders() {
|
|
45
|
+
return readProviders(await loadConfig());
|
|
46
|
+
}
|
|
47
|
+
/* A provider is enabled when its key carries a truthy value: `true`, a config
|
|
48
|
+
* object (`{ ics: "…" }`), or a setting string (`"leo"`). `false`, `null`,
|
|
49
|
+
* empty string, or an empty object all read as "off" — tri-state honesty, so
|
|
50
|
+
* `"horoscope": ""` doesn't silently count as consent. */
|
|
51
|
+
export function providerEnabled(providers, name) {
|
|
52
|
+
const v = providers[name];
|
|
53
|
+
if (v == null || v === false || v === "")
|
|
54
|
+
return false;
|
|
55
|
+
if (typeof v === "object")
|
|
56
|
+
return Object.keys(v).length > 0;
|
|
57
|
+
return Boolean(v);
|
|
58
|
+
}
|
|
59
|
+
/** The raw setting for a provider (e.g. the horoscope sign, the calendar ics
|
|
60
|
+
* object), or undefined when it's off. Lets a provider read its own config
|
|
61
|
+
* without re-parsing the file. */
|
|
62
|
+
export function providerSetting(providers, name) {
|
|
63
|
+
return providerEnabled(providers, name) ? providers[name] : undefined;
|
|
64
|
+
}
|
|
65
|
+
/* ── pause: the whole-product kill switch ──────────────────────────────────
|
|
66
|
+
* An ambient layer that injects into every prompt needs a visible, instant
|
|
67
|
+
* off switch. `cadence pause` sets `"paused": true`; every hook checks it
|
|
68
|
+
* FIRST and exits silently — no signals read, no subprocesses spawned, no
|
|
69
|
+
* block injected. State (pins, opt-ins, self-report) is preserved untouched,
|
|
70
|
+
* so `cadence resume` picks up exactly where you left off. */
|
|
71
|
+
export function readPaused(cfg) {
|
|
72
|
+
return cfg["paused"] === true;
|
|
73
|
+
}
|
|
74
|
+
export async function isPaused() {
|
|
75
|
+
return readPaused(await loadConfig());
|
|
76
|
+
}
|
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,53 @@ 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(), { focusedAppEnabled: providerEnabled(providers, "focusedApp") }),
|
|
35
39
|
getGitSignal(cwd),
|
|
36
|
-
getActivitySignal(prompt),
|
|
40
|
+
getActivitySignal(prompt, Date.now(), { tempoEnabled }),
|
|
41
|
+
getIntentSignal(prompt),
|
|
42
|
+
getEsotericSignal(providers),
|
|
37
43
|
]);
|
|
38
44
|
const signals = [];
|
|
39
45
|
if (music.status === "fulfilled" && music.value)
|
|
40
46
|
signals.push(music.value);
|
|
41
47
|
if (report.status === "fulfilled" && report.value)
|
|
42
48
|
signals.push(report.value);
|
|
43
|
-
if (
|
|
44
|
-
signals.push(
|
|
49
|
+
if (environment.status === "fulfilled" && environment.value)
|
|
50
|
+
signals.push(environment.value);
|
|
45
51
|
if (git.status === "fulfilled" && git.value)
|
|
46
52
|
signals.push(git.value);
|
|
47
53
|
if (activity.status === "fulfilled" && activity.value)
|
|
48
54
|
signals.push(activity.value);
|
|
55
|
+
if (intent.status === "fulfilled" && intent.value)
|
|
56
|
+
signals.push(intent.value);
|
|
57
|
+
if (esoteric.status === "fulfilled" && esoteric.value)
|
|
58
|
+
signals.push(esoteric.value);
|
|
49
59
|
return signals;
|
|
50
60
|
}
|
|
51
61
|
async function main() {
|
|
62
|
+
// Paused = the user asked for silence. Check FIRST: no signals read, no
|
|
63
|
+
// subprocesses spawned, nothing injected. `cadence resume` turns it back on.
|
|
64
|
+
if (await isPaused())
|
|
65
|
+
process.exit(0);
|
|
52
66
|
const { cwd, prompt } = await readStdin();
|
|
53
67
|
const projectDir = cwd ?? process.cwd();
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
// Pins + the opt-in registry are tiny local reads; load them first so signal
|
|
69
|
+
// collection knows which opt-in providers to run, then race only the
|
|
70
|
+
// subprocess-heavy collection against the budget.
|
|
71
|
+
const [overrides, providers] = await Promise.all([loadOverrides(), loadProviders()]);
|
|
72
|
+
const signals = await Promise.race([
|
|
73
|
+
collectSignals(projectDir, prompt, providers),
|
|
74
|
+
// unref: the losing timer must not hold the process open after the
|
|
75
|
+
// race settles — Claude Code waits on our EXIT, not our output.
|
|
76
|
+
new Promise((resolve) => setTimeout(() => {
|
|
77
|
+
debug("hook", `signal collection exceeded ${TOTAL_BUDGET_MS}ms budget — injecting without signals`);
|
|
78
|
+
resolve([]);
|
|
79
|
+
}, TOTAL_BUDGET_MS).unref()),
|
|
63
80
|
]);
|
|
64
81
|
// Nothing to say: no signals AND no pinned dials.
|
|
65
82
|
if (signals.length === 0 && Object.keys(overrides).length === 0) {
|
|
@@ -70,12 +87,14 @@ async function main() {
|
|
|
70
87
|
const reframe = buildReframe(cadence);
|
|
71
88
|
const stateWithCadence = { ...state, cadence, pinned, reframe };
|
|
72
89
|
const block = render(stateWithCadence);
|
|
90
|
+
// Exit in the write callback (stdout to a pipe can flush async): a straggling
|
|
91
|
+
// provider subprocess must never keep the user's prompt waiting on our exit.
|
|
73
92
|
process.stdout.write(JSON.stringify({
|
|
74
93
|
hookSpecificOutput: {
|
|
75
94
|
hookEventName: "UserPromptSubmit",
|
|
76
95
|
additionalContext: block,
|
|
77
96
|
},
|
|
78
|
-
}));
|
|
97
|
+
}), () => process.exit(0));
|
|
79
98
|
}
|
|
80
99
|
main().catch((err) => {
|
|
81
100
|
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.
|