catence 0.2.0-beta.16 → 0.2.0-beta.18

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.
@@ -0,0 +1,325 @@
1
+ # Catence development guide
2
+
3
+ This is the from-source counterpart to the three deployment guides. Use it to run, edit, and verify Catence without publishing a beta — locally on your machine and on a remote Docker host where you cannot live-debug until a new image is built.
4
+
5
+ Production installs use published artifacts (`npm install catence@beta` / `uv tool install catence-console` and `scripts/deploy-console.sh` → `console/Dockerfile.registry`). Everything below uses **your checkout directly**, so you can iterate on runtime, ELT, and Console code and test the real volume/config/secret paths before any release.
6
+
7
+ Related references: [`deployment/local-mcp.md`](deployment/local-mcp.md), [`deployment/local-console.md`](deployment/local-console.md), [`deployment/docker.md`](deployment/docker.md), [`configuration.md`](configuration.md), [`llm-providers.md`](llm-providers.md).
8
+
9
+ ## Which path when
10
+
11
+ | Goal | Path | Why |
12
+ |------|------|-----|
13
+ | Test ELT, MCP tools, queries, or TypeScript/Python logic | **Local from source** (`tsx` + `uv run`) | Fastest loop; no Docker, no image build |
14
+ | Test the Console UI, Chainlit auth, or the authenticated dashboard proxy | **Local Console from source** (`catence-console serve` against a live `CATENCE_HOME`) | Same stack as production but without a container |
15
+ | Test the Docker volume layout, `/data` seeding, `config.json` / `chat-history.sqlite3` persistence, or `deploy-console.sh`-adjacent wiring | **Docker source build** (`console/Dockerfile`) | One `docker build` from the checkout — no registry, no `CATENCE_NPM_VERSION` |
16
+ | Iterate inside a container without rebuilding (host file → container) | **Docker live bind-mount** (override entrypoint/command, keep `/data` on a volume) | Edit on host, restart container, re-test |
17
+
18
+ Most changes do not need Docker at all. Use Docker only when you are explicitly testing the container boundary (file permissions, volume seeding, Console networking, MCP bind).
19
+
20
+ ## Prerequisites
21
+
22
+ - Node.js 22+
23
+ - Python 3.12 ≤ version < 3.14, with [`uv`](https://docs.astral.sh/uv/)
24
+ - `git`, `curl`
25
+
26
+ ```sh
27
+ git clone https://github.com/rifusaki/catence.git
28
+ cd catence
29
+ npm ci
30
+ uv sync --project console
31
+ ```
32
+
33
+ No install step writes to a data home. The data home is created only by `catence-data setup` / `catence-data demo`.
34
+
35
+ ## Local from source (no Docker, no beta)
36
+
37
+ ### Choose a catalog home
38
+
39
+ The runtime, CLI, and Console all resolve the same home (`~/.catence` by default). Point it elsewhere with `CATENCE_HOME` or per-command `--home` so development never touches a personal catalog.
40
+
41
+ ```sh
42
+ # Isolated home for this checkout/session (recommended for development)
43
+ export CATENCE_HOME="$PWD/.catence-dev"
44
+ catence-data setup --athlete alex --label "Alex"
45
+ # or explicitly: catence-data --home "$PWD/.catence-dev" setup --athlete alex --label "Alex"
46
+ ```
47
+
48
+ The layout after `setup` is the same one Docker later mounts at `/data` — see [`configuration.md`](configuration.md) and [`deployment/local-console.md`](deployment/local-console.md#what-gets-stored-where):
49
+
50
+ ```text
51
+ $CATENCE_HOME/
52
+ catalog.json
53
+ config.json # Console model profiles — no secrets
54
+ console/chat-history.sqlite3
55
+ athletes/alex/
56
+ secrets/providers.json # mode 0600, written only via `secret set --value-stdin`
57
+ ```
58
+
59
+ Tear down a dev home with `rm -rf "$CATENCE_HOME"`; it is never committed (covered by `.gitignore`).
60
+
61
+ For a no-credentials smoke test, use the generated dataset instead:
62
+
63
+ ```sh
64
+ catence-data demo # creates/reuses ~/.catence-demo with athlete `demo`
65
+ npm run mcp -- demo # stdio MCP against the demo catalog
66
+ # The demo home is fixed to ~/.catence-demo; pass --home to move it.
67
+ ```
68
+
69
+ ### Provider credentials — file (production) or env (dev)
70
+
71
+ **Preferred (production):** credentials are stored per athlete in `secrets/providers.json` (mode 0600) — never in `config.json`. Same interface as [`deployment/local-mcp.md`](deployment/local-mcp.md#3-store-provider-credentials-stdin-only-never-shell-history):
72
+
73
+ ```sh
74
+ # Garmin
75
+ printf %s 'alex@example.com' | npm run catence-data -- --athlete alex secret set --provider garmin --field email --value-stdin
76
+ printf %s 'your-garmin-password' | npm run catence-data -- --athlete alex secret set --provider garmin --field password --value-stdin
77
+ # Intervals.icu
78
+ printf %s 'intervals-api-key' | npm run catence-data -- --athlete alex secret set --provider intervals --field apiKey --value-stdin
79
+ printf %s '12345' | npm run catence-data -- --athlete alex secret set --provider intervals --field athleteId --value-stdin
80
+ # Strava (create an API application at https://www.strava.com/settings/api)
81
+ printf %s 'strava-client-id' | npm run catence-data -- --athlete alex secret set --provider strava --field clientId --value-stdin
82
+ printf %s 'strava-client-secret' | npm run catence-data -- --athlete alex secret set --provider strava --field clientSecret --value-stdin
83
+ ```
84
+
85
+ File values always win. Without a file entry the worker's inherited `GARMIN_*` / `INTERVALS_*` / `STRAVA_*` are normally stripped so an unset provider cannot silently inherit another athlete's credentials.
86
+
87
+ **Easier for local/Docker dev (`src/core/runtime/secrets.ts:51`):** when a `providers.json` field is missing, the worker also checks env fallbacks:
88
+
89
+ ```sh
90
+ # Option A — per-athlete scoped (always allowed, multi-athlete safe, no flag)
91
+ export CATENCE_ATHLETE_ALEX_GARMIN_EMAIL='alex@example.com'
92
+ export CATENCE_ATHLETE_ALEX_GARMIN_PASSWORD='…'
93
+ export CATENCE_ATHLETE_ALEX_INTERVALS_API_KEY='…'
94
+ export CATENCE_ATHLETE_ALEX_INTERVALS_ATHLETE_ID='12345'
95
+ export CATENCE_ATHLETE_ALEX_STRAVA_CLIENT_ID='…'
96
+ export CATENCE_ATHLETE_ALEX_STRAVA_CLIENT_SECRET='…'
97
+ # Prefix is CATENCE_ATHLETE_<ID>_ where <ID> is the athlete id upper-cased with '-' → '_'.
98
+
99
+ # Option B — generic (single-athlete dev, opt-in via flag)
100
+ export CATENCE_ALLOW_ENV_SECRETS=1 # or CATENCE_SECRETS_FROM_ENV=1 — values 1/true/yes/on
101
+ export GARMIN_EMAIL='alex@example.com'
102
+ export GARMIN_PASSWORD='…'
103
+ export INTERVALS_API_KEY='…'
104
+ export INTERVALS_ATHLETE_ID='12345'
105
+ export STRAVA_CLIENT_ID='…'
106
+ export STRAVA_CLIENT_SECRET='…'
107
+ ```
108
+
109
+ Resolution per field: `providers.json` → `CATENCE_ATHLETE_<ID>_<VAR>` → (if `CATENCE_ALLOW_ENV_SECRETS` set) `VAR`. For Docker, put these in `.env.dev` and pass through `environment:` in `docker-compose.dev.yml` (or `--env-file .env.dev`); the `secret set --value-stdin` flow still works inside `docker compose run --entrypoint catence-data` for one-off seeding. See [`configuration.md`](configuration.md#provider-credentials-via-env-dev-fallback) and `.env.example`.
110
+
111
+ ### Model profiles (where model definitions live)
112
+
113
+ `config.json` in the data home holds **definitions only** — profile ids, LiteLLM model names, and the *names* of environment variables that hold keys/URLs (`apiKeyEnv`, `apiBaseEnv`, `apiVersionEnv`). The values stay in the process environment. See [`configuration.md`](configuration.md#consoleprofiles) and [`llm-providers.md`](llm-providers.md) for the schema.
114
+
115
+ For development, three options (all write to `config.json` in `CATENCE_HOME`):
116
+
117
+ ```sh
118
+ # 1) Start from the documented example
119
+ cp config.example.json "$CATENCE_HOME/config.json"
120
+ # then set the env var the profile names, e.g. OPENAI_API_KEY / OPENCODE_API_KEY
121
+
122
+ # 2) Let the Console wizard create one on first chat (replaces the whole `console` section)
123
+ # 3) Merge OpenCode Go / Zen profiles without clobbering existing ones
124
+ npm run discover:opencode-go -- --write "$CATENCE_HOME/config.json"
125
+ npm run discover:opencode-zen -- --write "$CATENCE_HOME/config.json" --free-only
126
+
127
+ # Verify
128
+ npm run console:doctor -- --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp
129
+ ```
130
+
131
+ Changing `config.json` is picked up on the next Console request — no repository file tracks deployment-specific models or keys. Keep `config.example.json` as the canonical checked-in example.
132
+
133
+ ### Running MCP, ELT, and tests without building `dist`
134
+
135
+ `package.json` exposes `tsx` dev scripts so edits are live without `npm run build`. Large edits should still pass `npm run check` (`tsc --noEmit` plus the runtime-boundary guard):
136
+
137
+ ```sh
138
+ npm run mcp -- --help
139
+ npm run mcp -- --home "$CATENCE_HOME" # stdio MCP (same wire as the packaged `catence` bin)
140
+ npm run catence-data -- --home "$CATENCE_HOME" status --athlete alex
141
+ npm run catence-data -- --home "$CATENCE_HOME" sync --athlete alex --provider garmin --from 2025-07-29
142
+ npm run catence-data -- --home "$CATENCE_HOME" build-retrieval-index --athlete alex
143
+
144
+ # Hot-reload loop for MCP iteration (restarts on src/ change)
145
+ npx tsx watch src/interfaces/mcp/main.ts -- --home "$CATENCE_HOME"
146
+
147
+ # Tests (DuckDB integration tests use a 15s per-test timeout)
148
+ npm test
149
+ npm run test:watch
150
+ npm run check
151
+ ```
152
+
153
+ `npm run mcp` and `npm run catence-data` are thin wrappers for `tsx src/interfaces/{mcp,cli}/main.ts`; the packaged `dist/` is only needed for `npm pack` / `prepublishOnly` validation (`npm run release:check`).
154
+
155
+ ### Running the Console locally against the dev catalog
156
+
157
+ The Console normally auto-starts a matching packaged runtime (`npx catence@<pinned>`). For development, point it at your live `tsx` runtime instead via `--mcp-url`:
158
+
159
+ ```sh
160
+ # Terminal 1 — live MCP/ELT runtime on loopback
161
+ npx tsx watch src/interfaces/mcp/main.ts -- serve --home "$CATENCE_HOME" --host 127.0.0.1 --port 8787
162
+
163
+ # Terminal 2 — Console using that runtime (no auto-started `npx` runtime)
164
+ export CATENCE_CONSOLE_USERNAME='coach'
165
+ export CATENCE_CONSOLE_PASSWORD_HASH="$(uv run --project console catence-console auth hash-password)"
166
+ export CHAINLIT_AUTH_SECRET="$(openssl rand -hex 32)"
167
+ export OPENAI_API_KEY='…' # or ANTHROPIC_API_KEY / OPENCODE_API_KEY …
168
+ npm run console -- serve --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp --ui-port 8000
169
+ # open http://127.0.0.1:8000
170
+ ```
171
+
172
+ `--mcp-url` is the feature that makes the live bind-mount loop below possible — the same flag works when the runtime lives inside a container (see next section).
173
+
174
+ ## Docker without publishing a beta
175
+
176
+ ### Why `scripts/deploy-console.sh` forces a publish
177
+
178
+ The production scaffold (`scripts/deploy-console.sh`) resolves `CATENCE_NPM_VERSION` / `CATENCE_CONSOLE_VERSION` from the **public registries** and bakes `console/Dockerfile.registry` — a three-line image that runs `npm install --global catence@${CATENCE_NPM_VERSION}` and `uv pip install catence-console==${CATENCE_CONSOLE_VERSION}`. Rebuilding that scaffold on a server cannot test local edits until they exist on npm/PyPI, which is the bottleneck this guide removes.
179
+
180
+ ### Source build — `console/Dockerfile` (reliable, still requires a rebuild)
181
+
182
+ `console/Dockerfile` is the from-source image: build context is the **repository root**, it runs `npm ci && npm run build && npm install --global .`, then `uv sync --project console`. Build it directly in any checkout, on any server with Docker:
183
+
184
+ ```sh
185
+ # From the repository root — tagged independently from the registry channel tags
186
+ docker build -f console/Dockerfile -t catence-console:dev .
187
+
188
+ # Run it the same way `docker.md` does, but with your local env
189
+ docker run --rm -p 127.0.0.1:8000:8000 -v catence-dev-data:/data \
190
+ -e CATENCE_CONSOLE_USERNAME='coach' \
191
+ -e CATENCE_CONSOLE_PASSWORD_HASH='replace-with-bcrypt-hash' \
192
+ -e CHAINLIT_AUTH_SECRET='replace-with-a-long-random-secret' \
193
+ -e OPENAI_API_KEY='…' \
194
+ catence-console:dev
195
+
196
+ # Or bind-mount a host data directory for easy inspection
197
+ mkdir -p /srv/catence-dev
198
+ docker run --rm -p 127.0.0.1:8000:8000 -v /srv/catence-dev:/data \
199
+ --env-file .env.dev \
200
+ catence-console:dev
201
+ ```
202
+
203
+ Seed and inspect the volume exactly as in [`deployment/docker.md`](deployment/docker.md):
204
+
205
+ ```sh
206
+ docker run --rm -v catence-dev-data:/data --entrypoint catence-data catence-console:dev setup --athlete alex --label "Alex" --home /data
207
+ docker run --rm -v catence-dev-data:/data --entrypoint sh catence-console:dev -c 'ls -R /data; cat /data/config.json'
208
+ ```
209
+
210
+ Rebuilding the image is the only step that reflects code changes under this path; the data volume and `config.json` are preserved across rebuilds.
211
+
212
+ ### Live loop — bind-mount the checkout into the container (no rebuild per edit)
213
+
214
+ When you need to edit on the server itself, mount the checkout into a dev container and have the entrypoint run the `tsx` watcher / `uv run` directly. The scaffold below is intentionally small — it keeps the **data volume** as the only state, so Console auth and `config.json` behave identically to production.
215
+
216
+ Minimal override compose (save as `docker-compose.dev.yml` alongside the checkout):
217
+
218
+ ```yaml
219
+ services:
220
+ console:
221
+ build:
222
+ context: .
223
+ dockerfile: console/Dockerfile
224
+ image: catence-console:dev
225
+ ports:
226
+ - "127.0.0.1:8000:8000"
227
+ - "127.0.0.1:8787:8787"
228
+ environment:
229
+ CATENCE_HOME: /data
230
+ CATENCE_CONSOLE_USERNAME: ${CATENCE_CONSOLE_USERNAME:-}
231
+ CATENCE_CONSOLE_PASSWORD_HASH: ${CATENCE_CONSOLE_PASSWORD_HASH:-}
232
+ CHAINLIT_AUTH_SECRET: ${CHAINLIT_AUTH_SECRET:-}
233
+ # Provider credentials via env (see "Provider credentials" above):
234
+ # Per-athlete scoped vars work without a flag; generic vars need CATENCE_ALLOW_ENV_SECRETS=1.
235
+ CATENCE_ALLOW_ENV_SECRETS: ${CATENCE_ALLOW_ENV_SECRETS:-}
236
+ GARMIN_EMAIL: ${GARMIN_EMAIL:-}
237
+ GARMIN_PASSWORD: ${GARMIN_PASSWORD:-}
238
+ INTERVALS_API_KEY: ${INTERVALS_API_KEY:-}
239
+ INTERVALS_ATHLETE_ID: ${INTERVALS_ATHLETE_ID:-}
240
+ STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
241
+ STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
242
+ # Per-athlete example (add your athlete id upper-cased, '-' → '_'):
243
+ # CATENCE_ATHLETE_ALEX_GARMIN_EMAIL: ${CATENCE_ATHLETE_ALEX_GARMIN_EMAIL:-}
244
+ OPENAI_API_KEY: ${OPENAI_API_KEY:-}
245
+ OPENAI_API_BASE: ${OPENAI_API_BASE:-}
246
+ ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
247
+ OPENCODE_API_KEY: ${OPENCODE_API_KEY:-}
248
+ OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-}
249
+ OPENCODE_GO_API_BASE: ${OPENCODE_GO_API_BASE:-}
250
+ OPENCODE_GO_MESSAGES_API_BASE: ${OPENCODE_GO_MESSAGES_API_BASE:-}
251
+ OPENCODE_ZEN_API_BASE: ${OPENCODE_ZEN_API_BASE:-}
252
+ OPENCODE_ZEN_RESPONSES_API_BASE: ${OPENCODE_ZEN_RESPONSES_API_BASE:-}
253
+ volumes:
254
+ - catence-dev-data:/data # keep Console data on the volume
255
+ - .:/app # live source
256
+ - /app/node_modules # keep container's installed deps
257
+ - /app/console/.venv # keep container's venv
258
+ - /app/dist # keep container build isolated (see .dockerignore)
259
+ - /app/.catence-dev # hide host dev home from container
260
+ - /app/.catence # hide host home from container
261
+ command: >-
262
+ sh -c "npm run build &&
263
+ uv run --project /app/console catence-console serve
264
+ --home /data --ui-host 0.0.0.0 --ui-port 8000
265
+ --mcp-host 0.0.0.0 --mcp-port 8787"
266
+ # for no-restart TS hot-reload, replace the command with:
267
+ # npx tsx watch src/interfaces/mcp/main.ts -- serve --home /data --host 0.0.0.0 --port 8787
268
+ # and run Console separately with --mcp-url
269
+
270
+ volumes:
271
+ catence-dev-data:
272
+ ```
273
+
274
+ ```sh
275
+ # .env.dev — never committed
276
+ cat > .env.dev <<'EOF'
277
+ CATENCE_CONSOLE_USERNAME=coach
278
+ CATENCE_CONSOLE_PASSWORD_HASH='replace-with-bcrypt-hash'
279
+ CHAINLIT_AUTH_SECRET='replace-with-long-random-string'
280
+ OPENAI_API_KEY='…'
281
+ # Provider creds via env (easier than secret set for dev) — either:
282
+ # Per-athlete (always, multi-athlete safe): CATENCE_ATHLETE_ALEX_GARMIN_EMAIL=…
283
+ # Generic (needs flag): CATENCE_ALLOW_ENV_SECRETS=1 + GARMIN_EMAIL=…
284
+ EOF
285
+
286
+ docker compose -f docker-compose.dev.yml --env-file .env.dev up --build
287
+ ```
288
+
289
+ Edits to `src/` and `console/catence_console/` on the host are visible inside; `docker compose restart console` picks up TypeScript changes after `npm run build`, while adding `npx tsx watch` to the command removes even that step. The `/data` volume is still the authority for `config.json`, `secrets/providers.json`, and `console/chat-history.sqlite3`, so the same `catence-data secret set --value-stdin --home /data` flow works via:
290
+
291
+ ```sh
292
+ docker compose -f docker-compose.dev.yml --env-file .env.dev exec console \
293
+ catence-data --home /data status --athlete alex
294
+ ```
295
+
296
+ An alternative that avoids running `npm` inside the mount loop is to keep the runtime on the host entirely: run `npx tsx watch src/interfaces/mcp/main.ts -- serve --home /srv/catence-dev ...` on the host and start the Console container with `--mcp-url http://host.docker.internal:8787/mcp` (or the host's Tailscale IP) and `--env-file .env.dev` — the same `--mcp-url` trick from the local Console section.
297
+
298
+ ## Where secrets and model definitions live (dev and Docker)
299
+
300
+ | What | Local dev | Docker dev/prod | Notes |
301
+ |------|-----------|-----------------|-------|
302
+ | Catalog + athlete stores | `CATENCE_HOME` (default `~/.catence`, dev: `$PWD/.catence-dev`) | `/data` (volume or bind mount) | Same on-disk layout; `CATENCE_HOME` controls it in both modes |
303
+ | Provider credentials (Garmin, Intervals, Strava) | `CATENCE_HOME/athletes/<id>/secrets/providers.json` (mode 0600) **or** env fallback when file field missing | `/data/athletes/<id>/secrets/providers.json` or same env fallback inside container | File wins; env fallback is `CATENCE_ATHLETE_<ID>_<VAR>` (always) or bare `GARMIN_*`/`INTERVALS_*`/`STRAVA_*` when `CATENCE_ALLOW_ENV_SECRETS=1` — see § Provider credentials above. Bare provider env vars are otherwise stripped before workers start |
304
+ | Model profiles + limits (`console.profiles`, `console.limits`) | `CATENCE_HOME/config.json` | `/data/config.json` (seeded from `config.example.json` on first deploy) | Versioned definitions + env-var names only. Credentials are read from the environment at serve time |
305
+ | API keys / base URLs | Process env (shell `export`, Docker `--env-file .env.dev`) | `.env` / `.env.dev` passed via `environment:` / `--env-file` | Named by the profile's `apiKeyEnv`/`apiBaseEnv`/`apiVersionEnv` (`OPENAI_API_KEY`, `OPENCODE_API_KEY`, `OPENCODE_GO_API_KEY`, …). `catence-console doctor` reports missing vars without printing values |
306
+ | Chat history | `CATENCE_HOME/console/chat-history.sqlite3` | `/data/console/chat-history.sqlite3` | Shared between runs when the volume is reused |
307
+
308
+ Recommended dev defaults:
309
+
310
+ - Keep a dedicated `CATENCE_HOME` (or local bind-mount path) per checkout or per test run; do not point development at a personal `~/.catence` catalog with real provider tokens.
311
+ - Commit no secrets: `.env*` (including `.env.dev`/`.env.beta`) and checkout-local `.catence-*` homes (which contain `athletes/<id>/secrets/providers.json`) are already ignored by `.gitignore`; the default `~/.catence` is outside the repo and naturally untracked.
312
+ - Treat `config.example.json` as the canonical model-definition template — copy its `console` section into the dev home's `config.json` and set only the one provider key you are testing.
313
+
314
+ ## Iteration notes
315
+
316
+ - Re-run `npm run check && npm test` before pushing; `vitest` integration tests create and migrate real DuckDB stores with a 15 s per-test timeout.
317
+ - `catence-data update` tracks the registry channel (`latest` vs `beta`); from source the update path is `git pull`, `npm ci && npm run build`, `uv sync --project console`.
318
+ - The deployed scaffold's `sync.sh` / `hash-password.sh` / `doctor.sh` helpers are generated by `scripts/deploy-console.sh`; the dev container does not generate them — use the `npm run` / `uv run` equivalents above, or run the same `catence-data --home /data` commands via `docker compose exec`.
319
+ - If the Console settings panel shows no Model dropdown, the data home's `config.json` has no `console.profiles` — re-copy `config.example.json` or re-run the discovery script, then restart the Console.
320
+
321
+ ## Troubleshooting
322
+
323
+ - **Doctor reports runtime unreachable** — the Console was started without `--mcp-url` and its auto-started runtime is on a different port. Start the runtime explicitly and pass `--mcp-url http://127.0.0.1:8787/mcp` to the Console, or use `uv run --project console catence-console doctor --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp`.
324
+ - **Docker `run` container has a separate loopback** — `docker compose run` cannot see a `docker compose exec` MCP server. Use `exec` for `doctor`/`sync` against the running container, or use `run` only for one-off `setup`/`secret set` with `--value-stdin`.
325
+ - **Refusing to initialize** — the data home contains unrelated files. The error lists them; only `config.json`, `console/`, and Chainlit's `.files/`, `.chainlit/`, `public/` are allowed alongside a catalog.
@@ -198,6 +198,12 @@ The script warns on stderr and skips any model that does not fit one of these
198
198
  families. Rerun it to refresh the model lists; existing profiles, limits, and
199
199
  `defaultProfile` are preserved unless `--set-default` is passed.
200
200
 
201
+ You do not have to run the script manually: the Console dashboard's **Sync
202
+ data** button (and `POST /api/v1/sync` with `"refreshModels": true`) refreshes
203
+ these profiles from the live catalog before starting a data sync. Discovery
204
+ failures — offline machine, catalog unreachable — are reported as a warning and
205
+ never block or fail the sync itself.
206
+
201
207
  ```sh
202
208
  export OPENCODE_GO_API_KEY='…' # any non-empty value satisfies the key check
203
209
  export OPENCODE_GO_API_BASE='https://opencode.ai/zen/go/v1'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0-beta.16",
3
+ "version": "0.2.0-beta.18",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -29,6 +29,12 @@ def parse_args() -> argparse.Namespace:
29
29
  parser.add_argument("--historical-only", action="store_true", help="Skip current account and collection endpoints.")
30
30
  parser.add_argument("--skip-daily", action="store_true", help="Do not fetch date-scoped daily data.")
31
31
  parser.add_argument("--skip-activities", action="store_true", help="Do not fetch date-scoped activities.")
32
+ parser.add_argument(
33
+ "--lt-history-from",
34
+ dest="lt_history_from",
35
+ type=date.fromisoformat,
36
+ help="Backfill lactate-threshold history from this date instead of the daily window.",
37
+ )
32
38
  return parser.parse_args()
33
39
 
34
40
 
@@ -72,6 +78,7 @@ def main() -> None:
72
78
  daily_to_date=args.daily_to_date,
73
79
  activity_to_date=args.activity_to_date,
74
80
  include_non_historical=not args.historical_only,
81
+ lactate_threshold_history_from=args.lt_history_from,
75
82
  )
76
83
  except WorkerInterrupted:
77
84
  reporter.finish("interrupted")
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import json
4
5
  from pathlib import Path
5
6
 
6
7
  from .staging import StagingWriter
@@ -13,14 +14,21 @@ def parse_args() -> argparse.Namespace:
13
14
  parser = argparse.ArgumentParser(description="Backfill swim lengths from already-archived Garmin activity FIT files.")
14
15
  parser.add_argument("--data-dir", required=True, type=Path)
15
16
  parser.add_argument("--output", required=True, type=Path)
17
+ parser.add_argument(
18
+ "--activities",
19
+ type=Path,
20
+ help=(
21
+ "JSON list of {remote_activity_id, started_on} rows produced by the TypeScript side. "
22
+ "Preferred over reading DuckDB because the parent process may hold the write lock."
23
+ ),
24
+ )
16
25
  return parser.parse_args()
17
26
 
18
27
 
19
- def main() -> None:
20
- args = parse_args()
21
- database_path = args.data_dir / "catence.duckdb"
22
- if not database_path.exists():
23
- raise SystemExit(f"Catalog database not found: {database_path}")
28
+ def load_swim_activities(activities_file: Path | None, database_path: Path) -> list[tuple[str, str | None]]:
29
+ if activities_file is not None:
30
+ payload = json.loads(activities_file.read_text())
31
+ return [(str(row["remote_activity_id"]), row.get("started_on")) for row in payload]
24
32
  import duckdb
25
33
 
26
34
  con = duckdb.connect(str(database_path), read_only=True)
@@ -35,6 +43,15 @@ def main() -> None:
35
43
  """.format(placeholders=", ".join(f"'{sport}'" for sport in SWIM_SPORTS)),
36
44
  ).fetchall()
37
45
  con.close()
46
+ return [(str(remote_id), started_at_utc) for remote_id, started_at_utc in rows]
47
+
48
+
49
+ def main() -> None:
50
+ args = parse_args()
51
+ database_path = args.data_dir / "catence.duckdb"
52
+ if args.activities is None and not database_path.exists():
53
+ raise SystemExit(f"Catalog database not found: {database_path}")
54
+ rows = load_swim_activities(args.activities, database_path)
38
55
 
39
56
  writer = StagingWriter(args.data_dir, args.output)
40
57
  staged = 0
@@ -44,6 +44,52 @@ def functional_threshold_power_items(value: Any) -> list[dict[str, Any]]:
44
44
  return []
45
45
 
46
46
 
47
+ def threshold_range_items(value: Any) -> list[dict[str, Any]]:
48
+ """Extract dated points from a lactate-threshold range series response."""
49
+ items = functional_threshold_power_items(value)
50
+ return [item for item in items if isinstance(item.get("value"), (int, float)) and not isinstance(item.get("value"), bool)]
51
+
52
+
53
+ def lactate_threshold_days(payload: Any) -> dict[str, dict[str, float]]:
54
+ """Combine speed/heart-rate and power range series into per-day thresholds.
55
+
56
+ The range responses carry no weight or power-to-weight fields; deriving
57
+ W/kg is left to downstream joins against daily weight history.
58
+ """
59
+ combined: dict[str, dict[str, float]] = {}
60
+ if not isinstance(payload, Mapping):
61
+ return combined
62
+ for block, key in (("speed", "speed"), ("heart_rate", "heartRate")):
63
+ for item in threshold_range_items(payload.get(block)):
64
+ day = ftp_history_date(item)
65
+ if day:
66
+ combined.setdefault(day, {})[key] = float(item["value"])
67
+ for item in threshold_range_items(payload.get("power")):
68
+ day = ftp_history_date(item)
69
+ if day:
70
+ combined.setdefault(day, {})["functionalThresholdPower"] = float(item["value"])
71
+ return combined
72
+
73
+
74
+ def lactate_threshold_payload(day: str, threshold: Mapping[str, float]) -> dict[str, Any]:
75
+ """Compose a singleton-shaped payload the normalizer already understands."""
76
+ observed_at = f"{day}T12:00:00"
77
+ payload: dict[str, Any] = {"calendarDate": observed_at}
78
+ speed = threshold.get("speed")
79
+ heart_rate = threshold.get("heartRate")
80
+ power = threshold.get("functionalThresholdPower")
81
+ if speed is not None or heart_rate is not None:
82
+ speed_and_heart_rate: dict[str, Any] = {"calendarDate": observed_at}
83
+ if speed is not None:
84
+ speed_and_heart_rate["speed"] = speed
85
+ if heart_rate is not None:
86
+ speed_and_heart_rate["heartRate"] = heart_rate
87
+ payload["speed_and_heart_rate"] = speed_and_heart_rate
88
+ if power is not None:
89
+ payload["power"] = {"calendarDate": observed_at, "functionalThresholdPower": power, "origin": "RUNNING"}
90
+ return payload
91
+
92
+
47
93
  def ftp_history_date(payload: Mapping[str, Any]) -> str | None:
48
94
  for key in ("until", "calendarDate", "date", "updatedDate", "from"):
49
95
  value = payload.get(key)
@@ -111,6 +157,7 @@ class GarminStagingWorker:
111
157
  daily_to_date: date | None = None,
112
158
  activity_to_date: date | None = None,
113
159
  include_non_historical: bool = True,
160
+ lactate_threshold_history_from: date | None = None,
114
161
  ) -> None:
115
162
  assert_read_only_registry()
116
163
  end = to_date or date.today()
@@ -132,6 +179,14 @@ class GarminStagingWorker:
132
179
  self._hrv_history(daily_from_date, daily_end)
133
180
  self._check_interrupted()
134
181
  self._score_history(daily_from_date, daily_end)
182
+ # Lactate-threshold history uses the widest requested window so a
183
+ # single sync can catch stores up that were created before per-day
184
+ # LT capture existed; runs even when the daily window is skipped.
185
+ lt_windows = [window for window in (daily_from_date, lactate_threshold_history_from) if window is not None]
186
+ lt_start = min(lt_windows) if lt_windows else None
187
+ if lt_start is not None and lt_start <= daily_end:
188
+ self._check_interrupted()
189
+ self._lactate_threshold_history(lt_start, daily_end)
135
190
  if activity_from_date and activity_from_date <= activity_end:
136
191
  self._check_interrupted()
137
192
  self._activities(activity_from_date, activity_end)
@@ -319,6 +374,41 @@ class GarminStagingWorker:
319
374
  self.progress.advance(completed=completed_windows)
320
375
  window_start = window_end + timedelta(days=1)
321
376
 
377
+ def _lactate_threshold_history(self, from_date: date, to_date: date) -> None:
378
+ """Stage daily running lactate-threshold history from Garmin's range endpoints."""
379
+ window_start = from_date
380
+ total_windows = (to_date - from_date).days // 365 + 1
381
+ if self.progress is not None:
382
+ self.progress.set_stage("lt_history")
383
+ self.progress.advance(total=total_windows)
384
+ completed_windows = 0
385
+ while window_start <= to_date:
386
+ self._check_interrupted()
387
+ # Garmin rejects range windows longer than 366 days.
388
+ window_end = min(to_date, window_start + timedelta(days=365))
389
+ start, end = window_start.isoformat(), window_end.isoformat()
390
+ if self.progress is not None:
391
+ self.progress.advance(step=start)
392
+ payload, raw_hash = self._capture(
393
+ "lactate_threshold_range",
394
+ lambda start=start, end=end: self.api.get_lactate_threshold(latest=False, start_date=start, end_date=end, aggregation="daily"),
395
+ None,
396
+ {"from": start, "to": end, "aggregation": "daily"},
397
+ )
398
+ if payload is not None and raw_hash is not None:
399
+ for item_day, threshold in lactate_threshold_days(payload).items():
400
+ self.writer.source_entity(
401
+ "lactate_threshold",
402
+ f"daily:{item_day}",
403
+ lactate_threshold_payload(item_day, threshold),
404
+ raw_hash,
405
+ occurred_on=item_day,
406
+ )
407
+ completed_windows += 1
408
+ if self.progress is not None:
409
+ self.progress.advance(completed=completed_windows)
410
+ window_start = window_end + timedelta(days=1)
411
+
322
412
  def _max_metrics_history(self, from_date: date, to_date: date) -> None:
323
413
  """Stage range max-metric responses by their actual calendar date."""
324
414
  window_start = from_date
@@ -492,6 +582,49 @@ class GarminStagingWorker:
492
582
  if isinstance(payload, dict):
493
583
  self.writer.source_entity("activity", child_id, payload, raw_hash, parent_remote_id=parent_activity_id)
494
584
 
585
+ def _is_swim_activity(self, summary: dict[str, Any], activity: dict[str, Any] | None, details: dict[str, Any] | None) -> bool:
586
+ """Return True when any of the Garmin sport/type fields indicates a swim."""
587
+ candidates: list[str] = []
588
+ # Direct sport string fields on the list summary (including flat activityType string).
589
+ for key in ("sport", "sportType", "sport_type", "activityType", "activityTypeKey"):
590
+ value = summary.get(key)
591
+ if isinstance(value, str) and value:
592
+ candidates.append(value)
593
+ # Nested activityType shapes on the list summary.
594
+ for key in ("activityType", "activityTypeDTO"):
595
+ nested = summary.get(key)
596
+ if isinstance(nested, dict):
597
+ type_key = nested.get("typeKey") or nested.get("type_key") or nested.get("type") or nested.get("key")
598
+ if isinstance(type_key, str) and type_key:
599
+ candidates.append(type_key)
600
+ # Detailed payloads provide the authoritative typeKey.
601
+ for payload in (activity, details):
602
+ if not isinstance(payload, dict):
603
+ continue
604
+ for key in ("activityType", "activityTypeDTO"):
605
+ nested = payload.get(key)
606
+ if isinstance(nested, dict):
607
+ type_key = nested.get("typeKey")
608
+ if isinstance(type_key, str) and type_key:
609
+ candidates.append(type_key)
610
+ for key in ("sport", "sportType"):
611
+ value = payload.get(key)
612
+ if isinstance(value, str) and value:
613
+ candidates.append(value)
614
+ summary_dto = payload.get("summaryDTO")
615
+ if isinstance(summary_dto, dict):
616
+ for key in ("activityType", "activityTypeDTO"):
617
+ nested = summary_dto.get(key)
618
+ if isinstance(nested, dict):
619
+ type_key = nested.get("typeKey")
620
+ if isinstance(type_key, str) and type_key:
621
+ candidates.append(type_key)
622
+ for candidate in candidates:
623
+ lowered = candidate.lower()
624
+ if lowered == "lap_swimming" or "swim" in lowered:
625
+ return True
626
+ return False
627
+
495
628
  def _activity_details(self, activity_id: str, summary: dict[str, Any]) -> None:
496
629
  calls = {
497
630
  "activity": ("get_activity", "activity_detail"), "activity_details": ("get_activity_details", "activity_detail"),
@@ -501,17 +634,19 @@ class GarminStagingWorker:
501
634
  "activity_exercise_sets": ("get_activity_exercise_sets", "activity_exercise_set"), "activity_gear": ("get_activity_gear", "activity_gear"),
502
635
  }
503
636
  details: dict[str, Any] | None = None
637
+ activity_payload: dict[str, Any] | None = None
504
638
  for endpoint, (method, entity_type) in calls.items():
505
639
  payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)(activity_id), activity_id)
506
640
  if payload is not None:
507
641
  self._entity(endpoint, entity_type, payload, raw_hash, parent_remote_id=activity_id)
508
642
  if endpoint == "activity" and isinstance(payload, dict):
643
+ activity_payload = payload
509
644
  self._multisport_children(activity_id, payload)
510
645
  if endpoint == "activity_details" and isinstance(payload, dict):
511
646
  details = payload
512
- self._activity_files(activity_id, summary, details)
647
+ self._activity_files(activity_id, summary, activity_payload, details)
513
648
 
514
- def _activity_files(self, activity_id: str, summary: dict[str, Any], details: dict[str, Any] | None) -> None:
649
+ def _activity_files(self, activity_id: str, summary: dict[str, Any], activity: dict[str, Any] | None, details: dict[str, Any] | None) -> None:
515
650
  try:
516
651
  from garminconnect import Garmin
517
652
  formats = {
@@ -557,8 +692,7 @@ class GarminStagingWorker:
557
692
  occurred_on=start_date[:10],
558
693
  )
559
694
  if original_path and original_path.exists():
560
- sport = str(summary.get("sportType") or summary.get("sport") or "").lower()
561
- if sport == "lap_swimming" or "swim" in sport:
695
+ if self._is_swim_activity(summary, activity, details):
562
696
  lengths = fit_archive_to_swim_lengths(f"garmin:{activity_id}", original_path)
563
697
  if lengths:
564
698
  start_date = str(summary.get("startTimeLocal") or summary.get("startTimeGMT") or date.today().isoformat())