@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.
- package/README.md +98 -0
- package/SKILL.md +190 -0
- package/audio/earth_breathing.m4a +0 -0
- package/audio/earth_meditation.m4a +0 -0
- package/audio/earth_zhanZhuang.m4a +0 -0
- package/audio/fire_breathing.m4a +0 -0
- package/audio/fire_meditation.m4a +0 -0
- package/audio/fire_zhanZhuang.m4a +0 -0
- package/audio/metal_breathing.m4a +0 -0
- package/audio/metal_meditation.m4a +0 -0
- package/audio/metal_zhanZhuang.m4a +0 -0
- package/audio/water_breathing.m4a +0 -0
- package/audio/water_meditation.m4a +0 -0
- package/audio/water_zhanZhuang.m4a +0 -0
- package/audio/wood_breathing.m4a +0 -0
- package/audio/wood_meditation.m4a +0 -0
- package/audio/wood_zhanZhuang.m4a +0 -0
- package/dist/src/audio.d.ts +13 -0
- package/dist/src/audio.js +88 -0
- package/dist/src/cache.d.ts +7 -0
- package/dist/src/cache.js +31 -0
- package/dist/src/client.d.ts +22 -0
- package/dist/src/client.js +65 -0
- package/dist/src/cooldown.d.ts +5 -0
- package/dist/src/cooldown.js +35 -0
- package/dist/src/headphones.d.ts +6 -0
- package/dist/src/headphones.js +50 -0
- package/dist/src/health/google-fit.d.ts +13 -0
- package/dist/src/health/google-fit.js +108 -0
- package/dist/src/health/index.d.ts +6 -0
- package/dist/src/health/index.js +42 -0
- package/dist/src/health/oauth.d.ts +6 -0
- package/dist/src/health/oauth.js +69 -0
- package/dist/src/health/oura.d.ts +14 -0
- package/dist/src/health/oura.js +130 -0
- package/dist/src/health/sync.d.ts +7 -0
- package/dist/src/health/sync.js +194 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +107 -0
- package/dist/src/mock.d.ts +8 -0
- package/dist/src/mock.js +176 -0
- package/dist/src/preferences.d.ts +13 -0
- package/dist/src/preferences.js +47 -0
- package/dist/src/session.d.ts +15 -0
- package/dist/src/session.js +40 -0
- package/dist/src/setup-cli.js +2 -0
- package/dist/src/setup.d.ts +17 -0
- package/dist/src/setup.js +323 -0
- package/dist/src/tools.d.ts +4 -0
- package/dist/src/tools.js +420 -0
- package/dist/src/types.d.ts +86 -0
- package/dist/src/types.js +52 -0
- package/dist/tests/audio.test.d.ts +1 -0
- package/dist/tests/audio.test.js +67 -0
- package/dist/tests/cache.test.d.ts +1 -0
- package/dist/tests/cache.test.js +61 -0
- package/dist/tests/client.test.d.ts +1 -0
- package/dist/tests/client.test.js +95 -0
- package/dist/tests/cooldown.test.d.ts +1 -0
- package/dist/tests/cooldown.test.js +66 -0
- package/dist/tests/e2e.test.d.ts +1 -0
- package/dist/tests/e2e.test.js +144 -0
- package/dist/tests/guards.test.d.ts +1 -0
- package/dist/tests/guards.test.js +169 -0
- package/dist/tests/headphones.test.d.ts +1 -0
- package/dist/tests/headphones.test.js +46 -0
- package/dist/tests/mock.test.d.ts +1 -0
- package/dist/tests/mock.test.js +194 -0
- package/dist/tests/preferences.test.d.ts +1 -0
- package/dist/tests/preferences.test.js +71 -0
- package/dist/tests/session.test.d.ts +1 -0
- package/dist/tests/session.test.js +85 -0
- package/dist/tests/sync.test.d.ts +1 -0
- package/dist/tests/sync.test.js +54 -0
- package/package.json +29 -0
- 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,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
|
+
}
|