@daobrew/wellness-mcp 0.1.0

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.
Files changed (76) hide show
  1. package/README.md +98 -0
  2. package/SKILL.md +190 -0
  3. package/audio/earth_breathing.m4a +0 -0
  4. package/audio/earth_meditation.m4a +0 -0
  5. package/audio/earth_zhanZhuang.m4a +0 -0
  6. package/audio/fire_breathing.m4a +0 -0
  7. package/audio/fire_meditation.m4a +0 -0
  8. package/audio/fire_zhanZhuang.m4a +0 -0
  9. package/audio/metal_breathing.m4a +0 -0
  10. package/audio/metal_meditation.m4a +0 -0
  11. package/audio/metal_zhanZhuang.m4a +0 -0
  12. package/audio/water_breathing.m4a +0 -0
  13. package/audio/water_meditation.m4a +0 -0
  14. package/audio/water_zhanZhuang.m4a +0 -0
  15. package/audio/wood_breathing.m4a +0 -0
  16. package/audio/wood_meditation.m4a +0 -0
  17. package/audio/wood_zhanZhuang.m4a +0 -0
  18. package/dist/src/audio.d.ts +13 -0
  19. package/dist/src/audio.js +88 -0
  20. package/dist/src/cache.d.ts +7 -0
  21. package/dist/src/cache.js +31 -0
  22. package/dist/src/client.d.ts +22 -0
  23. package/dist/src/client.js +65 -0
  24. package/dist/src/cooldown.d.ts +5 -0
  25. package/dist/src/cooldown.js +35 -0
  26. package/dist/src/headphones.d.ts +6 -0
  27. package/dist/src/headphones.js +50 -0
  28. package/dist/src/health/google-fit.d.ts +13 -0
  29. package/dist/src/health/google-fit.js +108 -0
  30. package/dist/src/health/index.d.ts +6 -0
  31. package/dist/src/health/index.js +42 -0
  32. package/dist/src/health/oauth.d.ts +6 -0
  33. package/dist/src/health/oauth.js +69 -0
  34. package/dist/src/health/oura.d.ts +14 -0
  35. package/dist/src/health/oura.js +130 -0
  36. package/dist/src/health/sync.d.ts +7 -0
  37. package/dist/src/health/sync.js +194 -0
  38. package/dist/src/index.d.ts +2 -0
  39. package/dist/src/index.js +107 -0
  40. package/dist/src/mock.d.ts +8 -0
  41. package/dist/src/mock.js +176 -0
  42. package/dist/src/preferences.d.ts +13 -0
  43. package/dist/src/preferences.js +47 -0
  44. package/dist/src/session.d.ts +15 -0
  45. package/dist/src/session.js +40 -0
  46. package/dist/src/setup-cli.js +2 -0
  47. package/dist/src/setup.d.ts +17 -0
  48. package/dist/src/setup.js +323 -0
  49. package/dist/src/tools.d.ts +4 -0
  50. package/dist/src/tools.js +420 -0
  51. package/dist/src/types.d.ts +86 -0
  52. package/dist/src/types.js +52 -0
  53. package/dist/tests/audio.test.d.ts +1 -0
  54. package/dist/tests/audio.test.js +67 -0
  55. package/dist/tests/cache.test.d.ts +1 -0
  56. package/dist/tests/cache.test.js +61 -0
  57. package/dist/tests/client.test.d.ts +1 -0
  58. package/dist/tests/client.test.js +95 -0
  59. package/dist/tests/cooldown.test.d.ts +1 -0
  60. package/dist/tests/cooldown.test.js +66 -0
  61. package/dist/tests/e2e.test.d.ts +1 -0
  62. package/dist/tests/e2e.test.js +144 -0
  63. package/dist/tests/guards.test.d.ts +1 -0
  64. package/dist/tests/guards.test.js +169 -0
  65. package/dist/tests/headphones.test.d.ts +1 -0
  66. package/dist/tests/headphones.test.js +46 -0
  67. package/dist/tests/mock.test.d.ts +1 -0
  68. package/dist/tests/mock.test.js +194 -0
  69. package/dist/tests/preferences.test.d.ts +1 -0
  70. package/dist/tests/preferences.test.js +71 -0
  71. package/dist/tests/session.test.d.ts +1 -0
  72. package/dist/tests/session.test.js +85 -0
  73. package/dist/tests/sync.test.d.ts +1 -0
  74. package/dist/tests/sync.test.js +54 -0
  75. package/package.json +29 -0
  76. package/src/setup-cli.js +2 -0
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @daobrew/wellness-mcp
2
+
3
+ Biometric stress detection & TCM-guided recovery for developers using Claude Code.
4
+
5
+ Reads your health data (Apple Watch, Oura Ring), detects stress patterns through Traditional Chinese Medicine theory, and plays therapeutic breathing music with binaural beats — right inside your coding session.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @daobrew/wellness-mcp
11
+ npx daobrew-wellness-setup
12
+ ```
13
+
14
+ Setup asks two questions (data source + ambient mode). Done in 30 seconds.
15
+
16
+ ## What it does
17
+
18
+ - **Detects stress** from real biometrics (HRV, heart rate, steps, sleep)
19
+ - **Maps to TCM patterns**: Wood/Tension, Fire/Overdrive, Earth/Stagnation, Metal/Constriction, Water/Depletion
20
+ - **Plays breathing music** with binaural beats (4Hz theta) and breath-paced volume modulation
21
+ - **Works inside Claude Code** — no app switching, no popups
22
+
23
+ ## Usage
24
+
25
+ **On-demand** — ask anytime:
26
+ ```
27
+ /stress → check your current wellness state
28
+ /breathe → start a breathing session for your top stress pattern
29
+ /stop → stop current session
30
+ ```
31
+
32
+ **Ambient** — auto-plays when stress is detected:
33
+ - Enable with: `"enable ambient mode"` in Claude Code
34
+ - Requires headphones (binaural beats need stereo separation)
35
+ - 30-minute cooldown between sessions
36
+ - Say `"disable wellness"` to turn off
37
+
38
+ ## Data Sources
39
+
40
+ | Source | Data | Setup |
41
+ |--------|------|-------|
42
+ | **Apple Watch** | HRV, HR, steps, sleep | Install [DaoBrew Health Sync](https://testflight.apple.com/join/6XTNFvv5) on iPhone |
43
+ | **Oura Ring** | HRV, HR, sleep, readiness | Say `"connect oura"` in Claude Code |
44
+
45
+ Without a wearable, only step count is available (Earth/Spleen pattern only).
46
+
47
+ ## 9 Tools + 3 Prompts
48
+
49
+ | Tool | Description |
50
+ |------|-------------|
51
+ | `daobrew_get_wellness_state` | Current Yin/Yang balance + Five Element stress scores |
52
+ | `daobrew_get_element_detail` | Deep-dive evidence for a specific stress pattern |
53
+ | `daobrew_start_breathing_session` | Start guided breathing with audio playback |
54
+ | `daobrew_get_session_result` | Post-session HRV improvement metrics |
55
+ | `daobrew_get_session_history` | Recent session history and trends |
56
+ | `daobrew_stop_session` | Stop current breathing session |
57
+ | `daobrew_status` | Server mode, headphones, preferences, active session |
58
+ | `daobrew_set_monitoring` | Configure ambient mode, volume, cooldown |
59
+ | `daobrew_connect_source` | Connect Oura Ring or Apple Watch |
60
+
61
+ ## How it works
62
+
63
+ ```
64
+ Apple Watch / Oura
65
+
66
+ DaoBrew Backend (Gemini LLM + TCM scoring)
67
+
68
+ MCP Server (local, TypeScript)
69
+
70
+ Claude Code ←→ afplay/mpv (binaural + breath modulation)
71
+ ```
72
+
73
+ ## Privacy
74
+
75
+ - Only scored wellness data — no raw health records in agent context
76
+ - Health data stored locally (`~/.daobrew/`)
77
+ - Headphone detection prevents accidental speaker playback
78
+ - Ambient mode requires explicit opt-in
79
+
80
+ ## Configuration
81
+
82
+ Change settings in Claude Code: `"set volume to 0.5"`, `"change cooldown to 15 minutes"`, `"disable wellness"`
83
+
84
+ Or edit `~/.daobrew/prefs.json` directly.
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ cd daobrew-wellness-mcp
90
+ npm install
91
+ npm run build
92
+ npm test # 74 tests
93
+ npm run setup # interactive setup
94
+ ```
95
+
96
+ ## License
97
+
98
+ MIT
package/SKILL.md ADDED
@@ -0,0 +1,190 @@
1
+ ---
2
+ name: daobrew-wellness
3
+ description: "DaoBrew Wellness is a biometric stress detection and micro-recovery skill powered by Traditional Chinese Medicine theory. It reads health data (Apple Health, Health Connect, or DaoBrew Cuff) and identifies five stress patterns: Wood/Tension, Fire/Overdrive, Earth/Stagnation, Metal/Constriction, Water/Depletion. Use this skill whenever: the user has been coding for 30+ minutes without a break, the user mentions feeling stressed/tired/burned out/unfocused, the user asks about their health or energy state, you notice a long unbroken work session, the user asks for a break or recovery suggestion, OR at regular intervals during extended work sessions (every 30 minutes recommended). This skill should be checked proactively — don't wait for the user to ask. Developers who install this skill want their agent to watch out for them."
4
+ ---
5
+
6
+ # DaoBrew Wellness — Agent Skill for Biometric Stress Detection & Recovery
7
+
8
+ ## What This Skill Does
9
+
10
+ DaoBrew reads the user's real-time biometric data and returns a structured wellness assessment based on two systems:
11
+
12
+ 1. **Yin/Yang Balance** — Two independent 0-100 scores measuring Recovery Reservoir (Yin, parasympathetic) and Vitality Engine (Yang, sympathetic activity). Both can be high or low simultaneously — they are NOT opposites.
13
+
14
+ 2. **Five Element Patterns** (Wu Xing) — Five organ-system detectors mapped to TCM, any combination can co-occur:
15
+
16
+ | Element | Organ | What It Detects | Key Signal |
17
+ |---------|-------|----------------|------------|
18
+ | **Wood** | Liver 肝 | Qi stagnation, fight-or-flight stuck ON | HRV below baseline + erratic HR |
19
+ | **Fire** | Heart 心 | Heart Fire rising, restlessness | Elevated resting HR at night |
20
+ | **Earth** | Spleen 脾 | Spleen Qi sluggish, low energy | Low step count + low active energy |
21
+ | **Metal** | Lung 肺 | Lung Qi faltering, shallow breathing | Abnormal respiratory rate |
22
+ | **Water** | Kidney 肾 | Kidney Essence low, deep depletion | HRV declining over 2+ weeks |
23
+
24
+ ## Two Modes
25
+
26
+ ### On-Demand Mode (default)
27
+ User-initiated. Triggered by `/breathe`, `/stress`, or explicit user request. Uses `force_refresh: true` for fresh data.
28
+
29
+ ### Ambient Mode (proactive)
30
+ Agent-initiated. Requires explicit user opt-in via `daobrew_set_monitoring({ ambient_optin: true })`. Use cached data (no `force_refresh`). Present results in one brief line. Never interrupt mid-task — only at natural breakpoints. Ambient sessions use separate cooldown from on-demand.
31
+
32
+ **Block list** — Do NOT trigger ambient checks:
33
+ - While the user is actively debugging
34
+ - Mid-sentence in a conversation
35
+ - During urgent/time-sensitive tasks the user flagged
36
+ - During git operations (rebase, merge, conflict resolution)
37
+ - If the user said "stop checking" or "I don't want wellness updates"
38
+
39
+ ## Available Tools (9 tools)
40
+
41
+ ### `daobrew_get_wellness_state`
42
+ Returns the user's current wellness snapshot.
43
+
44
+ | Parameter | Type | Required | Description |
45
+ |-----------|------|----------|-------------|
46
+ | `force_refresh` | boolean | No | Bypass 30-min cache. Use `true` for on-demand, `false`/omit for ambient. |
47
+
48
+ **Returns:** Yin/Yang scores, quadrant, element scores, top signal, recommendation, `cache_age_seconds`, optional `active_session`.
49
+
50
+ **Quadrant meanings:**
51
+ - `peak` (Yin ≥ 50, Yang ≥ 50) → "In Flow"
52
+ - `pushing_it` (Yin < 50, Yang ≥ 50) → "Pushing It"
53
+ - `recharging` (Yin ≥ 50, Yang < 50) → "Recharging"
54
+ - `burnout` (Yin < 50, Yang < 50) → "Running on Empty"
55
+
56
+ ### `daobrew_get_element_detail`
57
+ Returns detailed evidence for a specific stress pattern.
58
+
59
+ | Parameter | Type | Required | Description |
60
+ |-----------|------|----------|-------------|
61
+ | `element` | string | Yes | `wood`, `fire`, `earth`, `metal`, `water` |
62
+
63
+ ### `daobrew_start_breathing_session`
64
+ Starts a guided resonance breathing session matched to the user's stress pattern.
65
+
66
+ | Parameter | Type | Required | Description |
67
+ |-----------|------|----------|-------------|
68
+ | `element` | string | Yes | Stress pattern to address |
69
+ | `tier` | string | No | `text` (terminal), `audio` (system audio), `full` (browser PWA). Default: `audio` |
70
+ | `mode` | string | No | `ambient` (agent-initiated) or `ondemand` (user-requested). Default: `ondemand` |
71
+ | `force` | boolean | No | Bypass cooldown timer for acute stress spikes. Default: `false` |
72
+
73
+ **Guard sequence** (checked in order):
74
+ 1. `disabled` → rejects with `status: "disabled"`
75
+ 2. `mode=ambient` without `ambient_optin` → rejects with `status: "requires_optin"`
76
+ 3. Session already running → rejects with `status: "session_active"`
77
+ 4. Cooldown active (unless `force=true`) → rejects with `status: "cooldown"`
78
+ 5. No headphones detected (audio/full tier, unless `headphones_trusted`) → rejects with `status: "no_headphones"`
79
+
80
+ If audio playback fails, automatically falls back to text tier with explanation.
81
+
82
+ ### `daobrew_get_session_result`
83
+ Retrieves the outcome of a completed breathing session including HRV changes.
84
+
85
+ | Parameter | Type | Required | Description |
86
+ |-----------|------|----------|-------------|
87
+ | `session_id` | string | Yes | From `start_breathing_session` |
88
+
89
+ ### `daobrew_get_session_history`
90
+ Returns recent session history and trends.
91
+
92
+ | Parameter | Type | Required | Description |
93
+ |-----------|------|----------|-------------|
94
+ | `days` | integer | No | Lookback window. Default: 7 |
95
+
96
+ ### `daobrew_stop_session`
97
+ Stops the current breathing session.
98
+
99
+ | Parameter | Type | Required | Description |
100
+ |-----------|------|----------|-------------|
101
+ | `session_id` | string | No | Stops current if omitted |
102
+
103
+ ### `daobrew_status`
104
+ Returns server mode, connected data sources, headphone status, preferences, active session. Use for first-run onboarding.
105
+
106
+ **Parameters:** none
107
+
108
+ ### `daobrew_set_monitoring`
109
+ Persists user preferences to `~/.daobrew/prefs.json`.
110
+
111
+ | Parameter | Type | Required | Description |
112
+ |-----------|------|----------|-------------|
113
+ | `ambient_optin` | boolean | No | Enable/disable ambient mode |
114
+ | `disabled` | boolean | No | Disable all wellness checks |
115
+ | `preferred_volume` | number | No | Audio volume 0.0-1.0 |
116
+ | `cooldown_minutes` | integer | No | Minutes between sessions |
117
+ | `headphones_trusted` | boolean | No | Skip headphone detection |
118
+ | `voiceover` | boolean | No | Enable/disable intro voiceover |
119
+
120
+ ### `daobrew_connect_source`
121
+ Connect a wearable data source for real biometric data.
122
+
123
+ | Parameter | Type | Required | Description |
124
+ |-----------|------|----------|-------------|
125
+ | `source` | string | Yes | `oura`, `google_fit`, `apple_watch` |
126
+
127
+ - **Oura/Google Fit**: Opens OAuth flow in browser, waits for callback
128
+ - **Apple Watch**: Returns TestFlight install link for DaoBrew Health Sync iPhone app
129
+
130
+ ## Prompts (3)
131
+
132
+ | Prompt | Description |
133
+ |--------|-------------|
134
+ | `/breathe` | Check wellness state and start a breathing session for top stress pattern |
135
+ | `/stress` | Check current stress levels and biometric wellness state |
136
+ | `/stop` | Stop the current breathing session |
137
+
138
+ ## How to Present Wellness Information
139
+
140
+ Keep it brief and natural. Do NOT dump raw JSON. Translate data into conversational TCM language.
141
+
142
+ **Good — proactive (ambient), mild concern:**
143
+ > Quick Qi check — you've been in "Pushing It" for a while. Yin Running Low (35), Yang Moving (72). Wood Qi activated. Want a 5-min breathing reset?
144
+
145
+ **Good — proactive (ambient), no concern:**
146
+ > Quick Qi check — In Flow. All clear.
147
+
148
+ **Good — on-demand, user asked:**
149
+ > Right now your Yin (recovery) is at 35 — Running Low. Yang (activity) is 72 — Moving. That puts you in "Pushing It" territory.
150
+ >
151
+ > Wood · Liver is activated (score 68) — low HRV, Liver Qi stagnation. Deep breathing would help. Want to try it?
152
+
153
+ **Bad — data dump:**
154
+ > Your daobrew_get_wellness_state returned yin=35, yang=72, quadrant=pushing_it...
155
+
156
+ **After a session:**
157
+ > Nice — HRV improved 46%, heart rate dropped 11bpm. Wood Qi rebalancing, score 68 → 42.
158
+
159
+ ## Safety & Privacy
160
+
161
+ - **Never hallucinate biometrics** — only report what the API returns
162
+ - **Never share health data** in outputs visible to others
163
+ - **Frame as wellness observations**, never medical advice ("biometrics suggest" not "you have")
164
+ - **Scored output only** — no raw health data in agent context
165
+ - **Disable immediately** when user says "stop checking": call `daobrew_set_monitoring({ disabled: true })`
166
+
167
+ ## Data Sources
168
+
169
+ | Source | Connection Method | Data |
170
+ |--------|------------------|------|
171
+ | **Oura Ring** | OAuth via `daobrew_connect_source({ source: "oura" })` | HRV, HR, sleep, readiness |
172
+ | **Google Fit** | OAuth via `daobrew_connect_source({ source: "google_fit" })` | HR, activity, sleep |
173
+ | **Apple Watch** | TestFlight iPhone app via `daobrew_connect_source({ source: "apple_watch" })` | HRV, HR, steps, respiratory, sleep |
174
+
175
+ ## Data Tiers
176
+
177
+ | Tier | Hardware | What Works | Limitations |
178
+ |------|----------|-----------|-------------|
179
+ | **Tier 1** | Phone only | Yang score, Earth pattern, basic screen/movement | Yin stays neutral, reduced Wood/Fire/Water accuracy |
180
+ | **Tier 2** | + Wearable | Full Yin/Yang, all five patterns, session HRV | — |
181
+ | **Tier 3** | + DaoBrew Cuff | All Tier 2 + thermal/haptic, continuous HRV | — |
182
+
183
+ The `data_tier` field tells you the user's tier. Don't reference HRV data for Tier 1 users.
184
+
185
+ ## Troubleshooting
186
+
187
+ - **No data**: Health permissions not granted or companion app not running
188
+ - **Stale data** (`cache_age_seconds` very high): Use `force_refresh: true`
189
+ - **All elements at 0**: System needs 3-7 days to establish baselines — skip proactive checks
190
+ - **Mock mode**: Results are simulated. Summary prefixed with `[Mock]`. Connect a wearable for real data.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,13 @@
1
+ import { Element } from "./types.js";
2
+ export interface PlaybackResult {
3
+ status: "playing" | "no_file" | "no_player" | "error";
4
+ pid: number | null;
5
+ file?: string;
6
+ error?: string;
7
+ }
8
+ /** Map task type to audio file suffix */
9
+ type TaskType = "breathing" | "meditation" | "zhanZhuang";
10
+ export declare function playAudio(element: Element, task?: TaskType, volume?: number): Promise<PlaybackResult>;
11
+ export declare function stopPlayback(): boolean;
12
+ export declare function generateTextBreathingScript(element: Element, durationSec: number, bpm: number): string;
13
+ export {};
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.playAudio = playAudio;
4
+ exports.stopPlayback = stopPlayback;
5
+ exports.generateTextBreathingScript = generateTextBreathingScript;
6
+ const child_process_1 = require("child_process");
7
+ const fs_1 = require("fs");
8
+ const path_1 = require("path");
9
+ const os_1 = require("os");
10
+ const types_js_1 = require("./types.js");
11
+ // Bundled audio inside the package (works standalone)
12
+ const PACKAGE_AUDIO_DIR = (0, path_1.join)(__dirname, "..", "..", "audio");
13
+ // User-level cache (e.g. prebaked binaural versions)
14
+ const AUDIO_CACHE_DIR = (0, path_1.join)((0, os_1.homedir)(), ".daobrew", "audio");
15
+ let currentProcess = null;
16
+ /** Resolve the audio file for an element + task. Checks cache dir, then bundled package audio. */
17
+ function resolveAudioFile(element, task = "breathing") {
18
+ const filename = `${element}_${task}.m4a`;
19
+ // 1. Check user cache dir (prebaked binaural versions take priority)
20
+ const cachePath = (0, path_1.join)(AUDIO_CACHE_DIR, filename);
21
+ if ((0, fs_1.existsSync)(cachePath))
22
+ return cachePath;
23
+ // 2. Check bundled audio inside the package
24
+ const packagePath = (0, path_1.join)(PACKAGE_AUDIO_DIR, filename);
25
+ if ((0, fs_1.existsSync)(packagePath))
26
+ return packagePath;
27
+ // 3. Legacy naming fallback ({element}_{bpm}bpm.m4a)
28
+ if ((0, fs_1.existsSync)(AUDIO_CACHE_DIR)) {
29
+ const files = (0, fs_1.readdirSync)(AUDIO_CACHE_DIR).filter(f => f.startsWith(`${element}_`) && f.endsWith(".m4a"));
30
+ if (files.length > 0)
31
+ return (0, path_1.join)(AUDIO_CACHE_DIR, files[0]);
32
+ }
33
+ return null;
34
+ }
35
+ async function playAudio(element, task = "breathing", volume = 0.4) {
36
+ if (process.platform !== "darwin" && process.platform !== "linux") {
37
+ return { status: "no_player", pid: null, error: `Unsupported platform: ${process.platform}` };
38
+ }
39
+ const audioPath = resolveAudioFile(element, task);
40
+ if (!audioPath) {
41
+ return { status: "no_file", pid: null, error: `Audio file not found for ${element}_${task}.m4a — check ~/.daobrew/audio/ or package audio/ directory.` };
42
+ }
43
+ stopPlayback();
44
+ try {
45
+ if (process.platform === "darwin") {
46
+ currentProcess = (0, child_process_1.spawn)("afplay", ["-v", String(volume), audioPath], {
47
+ stdio: "ignore", detached: true,
48
+ });
49
+ }
50
+ else {
51
+ currentProcess = (0, child_process_1.spawn)("mpv", [
52
+ "--no-video", "--really-quiet", `--volume=${Math.round(volume * 100)}`, audioPath,
53
+ ], { stdio: "ignore", detached: true });
54
+ }
55
+ currentProcess.unref();
56
+ const pid = currentProcess.pid ?? null;
57
+ currentProcess.on("exit", () => { currentProcess = null; });
58
+ return { status: "playing", pid, file: audioPath };
59
+ }
60
+ catch (err) {
61
+ return { status: "error", pid: null, error: err.message };
62
+ }
63
+ }
64
+ function stopPlayback() {
65
+ if (currentProcess) {
66
+ currentProcess.kill("SIGTERM");
67
+ currentProcess = null;
68
+ return true;
69
+ }
70
+ return false;
71
+ }
72
+ function generateTextBreathingScript(element, durationSec, bpm) {
73
+ const cycleDuration = 60 / bpm;
74
+ const inhaleDuration = cycleDuration / 3;
75
+ const exhaleDuration = (cycleDuration * 2) / 3;
76
+ const cycles = Math.floor(durationSec / cycleDuration);
77
+ let script = `\n${types_js_1.ELEMENT_LABELS[element]} · ${types_js_1.ELEMENT_ORGANS[element]} — ${types_js_1.ELEMENT_SHORT_SUMMARIES[element]}\n`;
78
+ script += `${cycles} breathing cycles at ${bpm} BPM\n\n`;
79
+ for (let i = 1; i <= Math.min(cycles, 6); i++) {
80
+ script += `Cycle ${i}/${cycles}:\n`;
81
+ script += ` Inhale... (${inhaleDuration.toFixed(1)}s)\n`;
82
+ script += ` Exhale slowly... (${exhaleDuration.toFixed(1)}s)\n\n`;
83
+ }
84
+ if (cycles > 6)
85
+ script += ` ... (${cycles - 6} more cycles)\n\n`;
86
+ script += `Session complete. Call daobrew_get_session_result for HRV changes.\n`;
87
+ return script;
88
+ }
@@ -0,0 +1,7 @@
1
+ export declare function get<T>(key: string): {
2
+ data: T;
3
+ age_seconds: number;
4
+ } | null;
5
+ export declare function set<T>(key: string, data: T): void;
6
+ export declare function invalidate(key: string): void;
7
+ export declare function ageSeconds(key: string): number;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.get = get;
4
+ exports.set = set;
5
+ exports.invalidate = invalidate;
6
+ exports.ageSeconds = ageSeconds;
7
+ const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes
8
+ const store = new Map();
9
+ function get(key) {
10
+ const entry = store.get(key);
11
+ if (!entry)
12
+ return null;
13
+ const age = Date.now() - entry.timestamp;
14
+ if (age > DEFAULT_TTL_MS) {
15
+ store.delete(key);
16
+ return null;
17
+ }
18
+ return { data: entry.data, age_seconds: Math.round(age / 1000) };
19
+ }
20
+ function set(key, data) {
21
+ store.set(key, { data, timestamp: Date.now() });
22
+ }
23
+ function invalidate(key) {
24
+ store.delete(key);
25
+ }
26
+ function ageSeconds(key) {
27
+ const entry = store.get(key);
28
+ if (!entry)
29
+ return -1;
30
+ return Math.round((Date.now() - entry.timestamp) / 1000);
31
+ }
@@ -0,0 +1,22 @@
1
+ import { Element, WellnessState, ElementDetail, SessionStart, SessionResult, SessionHistoryEntry, HealthSampleDTO } from "./types.js";
2
+ export interface ClientConfig {
3
+ apiKey: string;
4
+ baseUrl?: string;
5
+ timeoutMs?: number;
6
+ }
7
+ export declare class DaoBrewClient {
8
+ private apiKey;
9
+ private baseUrl;
10
+ private timeoutMs;
11
+ constructor(config: ClientConfig);
12
+ private request;
13
+ getWellnessState(): Promise<WellnessState>;
14
+ getElementDetail(element: Element): Promise<ElementDetail>;
15
+ startSession(element: Element, tier?: string): Promise<SessionStart>;
16
+ getSessionResult(sessionId: string): Promise<SessionResult>;
17
+ getSessionHistory(days?: number): Promise<SessionHistoryEntry[]>;
18
+ pushHealthSamples(samples: HealthSampleDTO[]): Promise<{
19
+ samples_received: number;
20
+ message: string;
21
+ }>;
22
+ }
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DaoBrewClient = void 0;
4
+ const child_process_1 = require("child_process");
5
+ const util_1 = require("util");
6
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
7
+ const DEFAULT_BASE_URL = "https://api.daobrew.com/api/v1";
8
+ const DEFAULT_TIMEOUT_MS = 15000;
9
+ class DaoBrewClient {
10
+ apiKey;
11
+ baseUrl;
12
+ timeoutMs;
13
+ constructor(config) {
14
+ this.apiKey = config.apiKey;
15
+ this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
16
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
17
+ }
18
+ async request(path, options = {}) {
19
+ const url = `${this.baseUrl}${path}`;
20
+ const args = [
21
+ "-sS", "--max-time", String(Math.ceil(this.timeoutMs / 1000)),
22
+ "-H", `Authorization: Bearer ${this.apiKey}`,
23
+ "-H", "Content-Type: application/json",
24
+ ];
25
+ if (options.method === "POST") {
26
+ args.push("-X", "POST");
27
+ if (options.body)
28
+ args.push("-d", options.body);
29
+ }
30
+ args.push(url);
31
+ const { stdout } = await execFileAsync("curl", args, { timeout: this.timeoutMs + 2000 });
32
+ const json = JSON.parse(stdout);
33
+ if (json.success === false) {
34
+ throw new Error(`DaoBrew API error: ${json.error?.message ?? json.error ?? "Unknown error"}`);
35
+ }
36
+ return json.data;
37
+ }
38
+ async getWellnessState() {
39
+ return this.request("/state/current?format=mcp");
40
+ }
41
+ async getElementDetail(element) {
42
+ return this.request(`/element/${element}/detail`);
43
+ }
44
+ async startSession(element, tier = "audio") {
45
+ return this.request("/session/start", {
46
+ method: "POST",
47
+ body: JSON.stringify({ element, tier }),
48
+ });
49
+ }
50
+ async getSessionResult(sessionId) {
51
+ return this.request(`/session/${sessionId}/outcome`);
52
+ }
53
+ async getSessionHistory(days = 7) {
54
+ return this.request(`/session/logs?limit=${days * 5}`);
55
+ }
56
+ async pushHealthSamples(samples) {
57
+ if (samples.length === 0)
58
+ return { samples_received: 0, message: "No samples to push" };
59
+ return this.request("/healthkit/samples", {
60
+ method: "POST",
61
+ body: JSON.stringify({ samples }),
62
+ });
63
+ }
64
+ }
65
+ exports.DaoBrewClient = DaoBrewClient;
@@ -0,0 +1,5 @@
1
+ export declare function activate(mode: "ambient" | "ondemand", durationMs?: number): void;
2
+ export declare function isActive(mode: "ambient" | "ondemand"): boolean;
3
+ export declare function remainingMinutes(mode: "ambient" | "ondemand"): number;
4
+ export declare function clear(mode: "ambient" | "ondemand"): void;
5
+ export declare function clearAll(): void;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.activate = activate;
4
+ exports.isActive = isActive;
5
+ exports.remainingMinutes = remainingMinutes;
6
+ exports.clear = clear;
7
+ exports.clearAll = clearAll;
8
+ const DEFAULT_COOLDOWN_MS = 30 * 60 * 1000; // 30 minutes
9
+ let ambientCooldownEnd = 0;
10
+ let ondemandCooldownEnd = 0;
11
+ function activate(mode, durationMs = DEFAULT_COOLDOWN_MS) {
12
+ if (mode === "ambient")
13
+ ambientCooldownEnd = Date.now() + durationMs;
14
+ else
15
+ ondemandCooldownEnd = Date.now() + durationMs;
16
+ }
17
+ function isActive(mode) {
18
+ const end = mode === "ambient" ? ambientCooldownEnd : ondemandCooldownEnd;
19
+ return Date.now() < end;
20
+ }
21
+ function remainingMinutes(mode) {
22
+ const end = mode === "ambient" ? ambientCooldownEnd : ondemandCooldownEnd;
23
+ const remaining = end - Date.now();
24
+ return remaining > 0 ? Math.ceil(remaining / 60000) : 0;
25
+ }
26
+ function clear(mode) {
27
+ if (mode === "ambient")
28
+ ambientCooldownEnd = 0;
29
+ else
30
+ ondemandCooldownEnd = 0;
31
+ }
32
+ function clearAll() {
33
+ ambientCooldownEnd = 0;
34
+ ondemandCooldownEnd = 0;
35
+ }
@@ -0,0 +1,6 @@
1
+ export interface HeadphoneStatus {
2
+ connected: boolean;
3
+ device: string | null;
4
+ detection_method: "switchaudio" | "system_profiler" | "pactl" | "none";
5
+ }
6
+ export declare function detectHeadphones(): Promise<HeadphoneStatus>;
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectHeadphones = detectHeadphones;
4
+ const child_process_1 = require("child_process");
5
+ const os_1 = require("os");
6
+ const util_1 = require("util");
7
+ const execAsync = (0, util_1.promisify)(child_process_1.exec);
8
+ // Blocklist: known built-in speakers to REJECT.
9
+ // Everything else (USB-C, DAC, Bluetooth, unknown external) = allowed.
10
+ const BUILTIN_SPEAKER_PATTERNS = [
11
+ "macbook pro speakers", "macbook air speakers", "macbook speakers",
12
+ "built-in output", "built-in speaker", "internal speakers",
13
+ "display audio", "imac speakers", "mac mini", "mac pro speakers",
14
+ "mac studio speakers",
15
+ ];
16
+ async function detectHeadphones() {
17
+ const os = (0, os_1.platform)();
18
+ if (os === "darwin") {
19
+ try {
20
+ const { stdout } = await execAsync("SwitchAudioSource -c -t output");
21
+ const device = stdout.trim();
22
+ const deviceLower = device.toLowerCase();
23
+ const isBuiltIn = BUILTIN_SPEAKER_PATTERNS.some(p => deviceLower.includes(p));
24
+ return { connected: !isBuiltIn, device, detection_method: "switchaudio" };
25
+ }
26
+ catch {
27
+ try {
28
+ const { stdout } = await execAsync("system_profiler SPBluetoothDataType -json 2>/dev/null");
29
+ const hasConnectedAudio = stdout.includes('"attrib_Connected" : "attrib_Yes"');
30
+ return { connected: hasConnectedAudio, device: null, detection_method: "system_profiler" };
31
+ }
32
+ catch {
33
+ return { connected: true, device: null, detection_method: "none" };
34
+ }
35
+ }
36
+ }
37
+ if (os === "linux") {
38
+ try {
39
+ const { stdout } = await execAsync("pactl list sinks short");
40
+ const hasExternal = stdout.toLowerCase().includes("bluetooth") ||
41
+ stdout.toLowerCase().includes("headphone") ||
42
+ stdout.toLowerCase().includes("usb");
43
+ return { connected: hasExternal, device: null, detection_method: "pactl" };
44
+ }
45
+ catch {
46
+ return { connected: true, device: null, detection_method: "none" };
47
+ }
48
+ }
49
+ return { connected: true, device: null, detection_method: "none" };
50
+ }