- name
- lifetract
- description
- Query personal life-tracking data: Samsung Health (sleep, steps, heart rate, stress, exercise, weight) + aTimeLogger (18 time categories) + Home Assistant REST (live sensors via ha.junghanacs.com). All records use Denote IDs (YYYYMMDDTHHMMSS) for cross-referencing with denotecli. DB mode (lifetract.db) for instant queries, individual Samsung commands have CSV fallback, and today's DB gaps can use Home Assistant.
# lifetract — Life Tracking CLI
Query and analyze personal health and time-tracking data.
All records carry Denote IDs (`YYYYMMDDTHHMMSS`) — same axis as denotecli.
Binary is bundled in the skill directory. Invoke via `{baseDir}/lifetract`.
All output is JSON.
## Why This Exists (not sqlite3/pandas)
Do NOT open lifetract.db or CSV files directly with Python/sqlite3/pandas.
1. **Denote ID mapping** — Raw CSVs use Samsung's epoch timestamps. The CLI converts them to `YYYYMMDDTHHMMSS` Denote IDs for cross-referencing with denotecli/gitcli.
2. **Multi-source join** — Sleep, heart rate, steps, stress, exercise, time tracking from different tables/sources, unified per-day. Manual SQL gets this wrong.
3. **JSON for agents** — Structured output ready for reasoning. No parsing needed.
## When to Use
- "오늘 몸 상태" → `lifetract today`
- "어제 뭐 했지?" → `lifetract read 2026-03-09`
- "최근 수면 패턴" → `lifetract sleep --days 30 --summary`
- "이번 주 시간 사용" → `lifetract time --days 7`
- "운동 기록" → `lifetract exercise --days 30`
- "30일 추이" → `lifetract timeline --days 30`
## Quick Start
```bash
lifetract status # 데이터 소스 + DB 상태
lifetract import --exec # CSV+aTimeLogger → lifetract.db (1.5초)
lifetract today # 오늘 통합 요약
lifetract read 2025-10-04 # 특정 날짜 종합 (건강+시간추적)
lifetract timeline --days 30 # 30일 횡단 뷰
```
## Architecture
```
lifetract.db 있음 → 모든 명령: DB 쿼리 (~90ms) → JSON
lifetract.db 없음 → sleep/steps/heart/stress/exercise: CSV 파싱 (~300ms) → JSON
→ time/timeline/today/read: exit 1
```
- `lifetract import --exec` 실행 후 모든 조회가 DB 모드
- DB 없으면 개별 Samsung 명령(`sleep`/`steps`/`heart`/`stress`/`exercise`)만 CSV 직접 파싱
- `time`과 시간축을 합치는 `timeline`/`today`/`read`는 DB가 없으면 **exit 1** — aTimeLogger를 못 읽은 구멍을 0으로 내보내지 않는다
## Commands
### status — 데이터 소스 확인
```bash
lifetract status
```
```json
{
"samsung_health": {"path": "...", "available": true, "csv_count": 78},
"atimelogger": {"path": "...", "available": true, "size_mb": 5.0},
"database": {
"path": "...", "available": true, "size_mb": 37.3, "mode": "db",
"last_time_block": "2026-07-13", "last_sleep": "2026-07-13", "last_steps": "2026-07-13",
"stale_days": 1, "freshness_checked": true, "warnings": []
}
}
```
**`last_*` / `stale_days` / `warnings` 가 이 명령의 요점이다.** Samsung export 는 사람이
폰에서 손으로 내보내야 흐른다 — 안 넣어주면 조용히 낡는다. 숫자를 저널에 사실로 박기
전에 여기부터 봐라 (§시간 계약 4항).
**`warnings: []` 는 `freshness_checked: true` 일 때만 "성하다"는 뜻이다.** DB 가 없거나
검사 자체가 실패하면 `freshness_checked: false` 이고, 그때의 빈 목록은 "이상 없음"이 아니라
**"보지 않았음"** 이다. 검사하지 않은 것이 합격으로 보이면 그건 검사가 아니다.
### import — DB 생성
```bash
lifetract import # dry-run: 매니페스트 확인
lifetract import --exec # 실행: CSV+aTimeLogger → lifetract.db
```
202,479 rows, ~38MB, ~2s. Active tables: sleep, sleep_stage, heart_rate, steps_daily,
stress, exercise, weight, atl_category, atl_interval. HRV는 은퇴했다: Samsung export 1,058행에
`rmssd`가 없고 `binning_data`만 있어, 예전 importer가 전부 `0.0`으로 넣던 빈 껍데기였다.
**import 는 자기가 뭘 잃었는지 말한다.** `total_rows` 말고 **`status` 를 먼저 봐라.**
```json
{
"status": "warning",
"warnings": ["stress: 27,598 rows (2026-07-14 12:25) → 0 — stream lost [empty]"],
"total_rows": 175941,
"prev_total_rows": 203539,
"tables": [
{"name": "stress", "rows": 0, "status": "empty", "prev_rows": 27598, "delta": -27598}
]
}
```
| 낱말 | 뜻 |
|---|---|
| `ok` | 읽었고, 지난번보다 줄지 않았다 |
| `empty` | 읽히긴 했는데 **0 행**. 지난번에 행이 있었다면 **잃은 것** |
| `shrunk` | 지난 import 보다 **적다** — Samsung export 는 누적 덤프라 줄면 이상하다 |
직전 행수는 DB 안 `import_log` 원장에 산다 (import 가 DB 를 지워도 이월된다).
**첫 import 는 비교 대상이 없으니 경고하지 않는다** — `note` 가 그렇게 말한다.
원장을 직접 읽는다면 **`GROUP BY import_id`** 를 써라. `imported_at` 은 한 import 를
묶지 못한다 (옛 행들은 초 경계를 넘어 2~3 개로 쪼개져 있다).
**`rows` 는 DB 에 실제로 앉은 행수다** — 우리가 건넨 행수가 아니라. 예전엔 `INSERT` 결과를
안 보고 세서, DB 가 거부한 행도 "imported" 로 원장에 박혔다. **손실 가드가 그 가짜 숫자를
기준으로 삼고 있었다.**
**`rejected` 는 승격을 막지 않지만 반드시 보고된다.** 재본 적 없는 시각(1970-01-01 epoch,
2000-01-01)을 실은 행과, Samsung 이 같은 날을 다시 동기화해 최신 개정판으로 대체된 행이다.
조용히 버리는 도구는 조용히 잃는 도구와 같은 침묵이라 매 run 이 이유별 개수를 말한다.
거절은 영구적인 손실 예산이 아니다. 정상 baseline 뒤에는 accepted rows 가 하나라도 줄면
현재 rejected 가 몇 개든 `shrunk` 다. 새 거절 정책은 전수 대조한 명시적 baseline 전환으로만
들어오며, 산술 허용량을 상시 열어두지 않는다.
**경고가 하나라도 있으면 승격되지 않는다 — 첫 import 도 예외가 아니다. 우회 플래그는 없다.**
예전엔 "빈 것만" 막았는데, Samsung 여러 스트림 성공 + aTimeLogger 실패 같은 **부분 성공**은 그대로
운영 DB 가 됐다. 그 순간 "못 봤다"를 말할 에러 경로가 사라진다 — DB 가 있으니 이후로는 그냥
`[]` 다. **불완전한 DB 는 운영 자리에 오지 않는다.** 스트림 하나가 빠진 DB 는 그 스트림에
대해 영원히 `[]` 를 답하고, 소비자(관측소)는 그 구멍을 0 으로 기록한다.
**`rows` 옆의 `invalid` 를 봐라.** 파일에 행은 있는데 필수 필드가 없거나 시각·필수 수치가
파싱 안 되는 행 수다. Samsung 이 헤더 하나만 개명하거나 숫자 자리에 garbage를 넣어도 예전엔
`empty` 또는 측정값 0으로 보였다 (첫 import 는 baseline 이 없어 경고조차 없었다).
**`invalid > 0` 이면 승격이 막힌다.** 빈 선택 필드는 0으로 둘 수 있지만, 값이 있는데 읽히지
않는 것은 0이 아니다.
**`atl_category` 는 독립 스트림이다.** `time` 조회는 카테고리를 INNER JOIN 하므로,
카테고리가 사라지면 블록 수가 그대로여도 **시간축이 전멸한다.** 예전 원장은 interval 만
세서 이 손실을 못 봤다 (`status: ok`, `warnings: []`, time 30일 → 0). 세지 않는 스트림은
잃어도 모른다. 고아 블록(없는 카테고리를 가리키는 블록) 검사도 함께 돈다.
*왜 있나: 2026-07-14, 글롭 하나가 7MB stress 대신 1KB histogram 을 집어 27,598 행이
통째로 0 이 됐는데 import 는 `"ok"` 라고 했다. 테스트는 초록불이었다. 잡은 건 총 행수가
203,539 → 175,941 로 떨어진 걸 **사람이 눈으로 본 것**뿐이었다. 이제 도구가 말한다.*
### read — Denote ID로 조회
```bash
lifetract read 20250115T000000 # Day ID → 그날 종합
lifetract read 2025-01-15 # 같은 결과 (날짜 단축형)
lifetract read 20250115T233000 # Event ID → 개별 수면/운동
```
Day 조회 시 건강 메트릭 + aTimeLogger 시간 카테고리 + 수면 세션 + 운동 모두 포함.
### today — 오늘 요약
```bash
lifetract today
```
```json
// 데이터 있는 날 (read 2025-10-04 형태)
{"date": "2025-10-04", "steps": 41382, "sleep_hours": 1.5, "avg_hr": 93.1, "stress_avg": 20.9, "time_categories": [...], "source": "db"}
// 데이터 없는 날 — DB 가 빈 자리는 자동으로 HA 가 채움 (phase 7 read-only fallback)
{"date": "2026-05-26", "steps": 7099, "sleep_hours": 4.8, "avg_hr": 137, "stress_avg": 0, "source": "db+ha", "ha_sources": ["steps","heart_rate","sleep"]}
```
`time_categories` 가 비면 JSON 에서 키 자체가 빠진다 (omitempty). 데이터 있는 날 vs 없는 날 둘 다 정상 출력.
**자동 HA fallback (오늘 자리에 한정)**: DB 가 오늘 자리를 비웠으면 (Samsung CSV 가 아직 안 들어왔으면) `today` 와 `read <오늘>` 이 자동으로 HA 라이브 값으로 채운다. `source` 가 `"db+ha"` 로 바뀌고, `ha_sources` 가 어떤 필드가 HA 에서 왔는지 알려준다. *과거 날짜는 enrichment 안 됨* — HA recorder 는 backfill 자리가 아니다. 끄려면 `LIFETRACT_NO_HA=1`. Sleep 은 *옛 row 가 오늘로 잡히는 stale* 자리도 감지해서 HA 로 덮어쓴다 (최근 36h 의 sleep_duration history 를 합산 — main sleep + nap 둘 다 잡음).
### timeline — 날짜별 횡단 뷰
```bash
lifetract timeline --days 7
lifetract timeline --days 30
```
denotecli 저널과 같은 날짜 키(`YYYYMMDDT000000`)로 정렬. 건강+시간+운동 통합.
### sleep / steps / heart / stress / exercise
```bash
lifetract sleep --days 7
lifetract sleep --days 30 --summary
lifetract steps --days 7
lifetract heart --days 7
lifetract stress --days 7
lifetract exercise --days 30
```
**걸음 수 계약:** `steps_daily` 는 측정일마다 정확히 한 행이다. 날짜는 Samsung의
`day_time` 만 읽으며 `create_time` 으로 대신하지 않는다. `day_time` 은 epoch-ms와
벽시계 문자열 양쪽을 지원하고, 못 읽거나 미래면 invalid로 승격을 막는다. 같은 날의
재동기화 행은 최신 `update_time` 한 건만 남기며, 동시각에 값이 충돌하면 임의로 고르지
않고 invalid다. DB도 날짜 UNIQUE로 같은 불변식을 강제한다.
### time — aTimeLogger 시간 추적
```bash
lifetract time --days 7
lifetract time --days 30 --category 본짓
```
카테고리: 본짓, 수면, 가족, 식사, 독서, 운동, 걷기, 수행, 셀프토크, 낮잠, 준비, 집안일, 이동, 쇼핑, 딴짓, 유튜브, 짧은휴식, 여가활동 (18종)
### export — 공개용 내보내기 계획
```bash
lifetract export
```
### ha — Home Assistant REST (live sensors)
```bash
lifetract ha ping # 연결 확인
lifetract ha state heart_rate # 도메인 이름으로 한 sensor 가져오기
lifetract ha state sleep_duration # (또는 literal entity_id 도 OK)
lifetract ha states # 등록된 24개 known sensor 일괄 조회
lifetract ha entities # HA 가 노출하는 모든 entity (raw, known 플래그 표시)
lifetract ha history sleep_duration --days 7 # 7일치 state 변화 (HA recorder)
```
```json
// ha state heart_rate
{
"entity_id": "sensor.sm_s942n_s26_glgman_heart_rate",
"kind": "heart_rate",
"state": "111.0",
"value": 111,
"unit": "bpm",
"last_changed": "2026-05-17T22:34:11Z",
"attributes": {...}
}
```
**토큰**: `pass show 2fa/totp/ha/junghanacs` (primary) → env `HA_TOKEN` (fallback) → `~/.lifetract/ha.env`. 토큰값 자체는 절대 commit/push 금지.
**도메인 kind**: `sleep_duration`, `steps_daily`, `distance_daily`, `floors_daily`, `heart_rate`, `resting_heart_rate`, `heart_rate_variability`, `weight`, `body_fat`, `height`, `calories_burned`, `active_calories_burned`, `basal_metabolic_rate`, `hydration`, `detected_activity`, `geocoded_location`, `battery`, `sleep_confidence`, `respiratory_rate`, `oxygen_saturation`, `body_temperature`, `blood_glucose`, `systolic_blood_pressure`, `diastolic_blood_pressure` (24종).
새 sensor 추가 = `lifetract/ha_entities.go` 의 `KnownEntities` 에 한 줄.
**`ha history` 동작**: HA recorder 는 *state 변화 시점에만* row 저장. recorder 30일 보관은 "있는 데이터 보존" 이지 "없는 데이터 채워줌" 이 아님. HA 인프라가 띄워진 시점 이전 데이터는 영원히 안 잡힘. 과거는 Samsung CSV export 가 유일한 길. HA history = *내일부터의 적립* 자리.
```json
// ha history sleep_duration --days 7
{
"entity_id": "sensor.sm_s942n_s26_glgman_sleep_duration",
"kind": "sleep_duration",
"unit": "min",
"days": 7,
"from": "2026-05-11T...+09:00",
"to": "2026-05-18T...+09:00",
"count": 2,
"points": [
{"last_changed": "...", "value": 427, "unit": "min", "attributes": {"endTime": "..."}},
...
]
}
```
**현재 상태 (phase 7 read-only)**: `cmdToday` / `cmdRead <오늘>` 이 DB miss 또는 stale sleep 자리에서 자동으로 HA `GetState` + `GetHistory` 를 호출해 응답에 채워준다 (`source: "db+ha"`, `ha_sources: [...]`). DB upsert 는 의도적으로 하지 않는다. Samsung export 가 영구 SSOT이고 HA recorder 는 30일 보조면이라, 덜 아는 값을 본 DB에 흡수하지 않는다.
## Flags
| Flag | Default | 설명 |
|------|---------|------|
| `--days N` | 7 | 창 길이 (**무시되지 않는다** — 아래 조합표) |
| `--from YYYY-MM-DD` | — | 창 시작 (포함) |
| `--to YYYY-MM-DD` | — | 창 끝 (**배타적**) |
| `--data-dir DIR` | `~/repos/gh/self-tracking-data` | 데이터 루트 |
| `--shealth-dir DIR` | 최신 자동감지 | Samsung Health 디렉토리 |
| `--summary` | false | 요약 모드 |
| `--category CAT` | 전체 | 시간 카테고리 필터 |
| `--exec` | false | import 실행 모드 |
### 창 조합 — 모든 조합이 뜻을 갖는다
| 조합 | 창 |
|------|-----|
| `--days N` | `[내일-N, 내일)` — **오늘 포함 정확히 N일** (`1`=오늘) |
| `--days N --to T` | `[T-N, T)` — **T 에 끝나는 N일** |
| `--days N --from F` | `[F, F+N)` — **F 에 시작하는 N일** |
| `--from F --to T` | `[F, T)` |
| `--from F` | `[F, 내일)` |
Voir sur GitHub