catence 0.2.0-beta.2 → 0.2.0-beta.21

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 (66) hide show
  1. package/README.md +28 -82
  2. package/config.example.json +25 -0
  3. package/dist/contracts/index.d.ts +1 -0
  4. package/dist/contracts/index.js +1 -0
  5. package/dist/contracts/progress.d.ts +23 -0
  6. package/dist/contracts/progress.js +49 -0
  7. package/dist/contracts/release.d.ts +1 -1
  8. package/dist/contracts/release.js +1 -1
  9. package/dist/core/logging.d.ts +16 -0
  10. package/dist/core/logging.js +68 -0
  11. package/dist/core/query/repository.d.ts +2 -0
  12. package/dist/core/query/repository.js +48 -0
  13. package/dist/core/runtime/catalog.js +17 -7
  14. package/dist/core/runtime/configuration.d.ts +3 -1
  15. package/dist/core/runtime/configuration.js +5 -1
  16. package/dist/core/runtime/opencode-go.d.ts +58 -0
  17. package/dist/core/runtime/opencode-go.js +246 -0
  18. package/dist/core/runtime/secrets.js +51 -13
  19. package/dist/core/runtime/self-update.d.ts +44 -0
  20. package/dist/core/runtime/self-update.js +138 -0
  21. package/dist/elt/application/detached-sync.d.ts +50 -0
  22. package/dist/elt/application/detached-sync.js +99 -0
  23. package/dist/elt/application/management.d.ts +15 -0
  24. package/dist/elt/application/management.js +435 -19
  25. package/dist/elt/ingestion/importer.d.ts +3 -2
  26. package/dist/elt/ingestion/importer.js +5 -4
  27. package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
  28. package/dist/elt/ingestion/providers/intervals.js +37 -2
  29. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  30. package/dist/elt/ingestion/providers/registry.js +18 -1
  31. package/dist/elt/ingestion/providers/strava/service.js +7 -1
  32. package/dist/elt/normalization/normalizers.js +111 -15
  33. package/dist/elt/storage/database.d.ts +21 -2
  34. package/dist/elt/storage/database.js +184 -6
  35. package/dist/elt/storage/migrations.js +25 -1
  36. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  37. package/dist/elt/storage/progress-sidecar.js +200 -0
  38. package/dist/interfaces/cli/commands.js +116 -1
  39. package/dist/interfaces/http/generation-status.d.ts +19 -0
  40. package/dist/interfaces/http/generation-status.js +55 -0
  41. package/dist/interfaces/http/server.d.ts +7 -1
  42. package/dist/interfaces/http/server.js +184 -1
  43. package/dist/interfaces/mcp/server.d.ts +2 -1
  44. package/dist/interfaces/mcp/server.js +50 -13
  45. package/dist/runtime/index.d.ts +8 -1
  46. package/dist/runtime/index.js +4 -1
  47. package/docs/architecture.md +11 -1
  48. package/docs/configuration.md +243 -0
  49. package/docs/deployment/docker.md +528 -0
  50. package/docs/deployment/local-console.md +263 -0
  51. package/docs/deployment/local-mcp.md +329 -0
  52. package/docs/development.md +337 -0
  53. package/docs/distribution.md +58 -0
  54. package/docs/llm-providers.md +249 -6
  55. package/docs/mcp-activity-retrieval.md +37 -30
  56. package/package.json +5 -1
  57. package/pyproject.toml +1 -1
  58. package/python/catence/providers/garmin/cli.py +43 -9
  59. package/python/catence/providers/garmin/progress.py +85 -0
  60. package/python/catence/providers/garmin/registry.py +1 -0
  61. package/python/catence/providers/garmin/streams.py +38 -0
  62. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  63. package/python/catence/providers/garmin/worker.py +372 -11
  64. package/scripts/discover-opencode-go.mjs +102 -0
  65. package/scripts/discover-opencode-zen.mjs +218 -0
  66. package/uv.lock +3 -3
@@ -2,9 +2,11 @@
2
2
 
3
3
  Catence Console talks to a model through [LiteLLM](https://docs.litellm.ai/).
4
4
  There are exactly three provider shapes: **OpenAI**, **Anthropic**, and a
5
- generic **OpenAI-compatible** profile. Azure and Opencode are not special cases
6
- in the code: both are reached through the OpenAI-compatible profile, and the
7
- steps for each are documented below.
5
+ generic **OpenAI-compatible** profile. Azure is not a special case in the code:
6
+ it is reached through the OpenAI-compatible profile. OpenCode Go can be reached
7
+ the same way through a local gateway, or directly at its own OpenAI-compatible
8
+ endpoint with native `openai/…` and `anthropic/…` model names; the steps for
9
+ each are documented below.
8
10
 
9
11
  ## How configuration is split
10
12
 
@@ -166,9 +168,90 @@ Notes:
166
168
 
167
169
  ### Opencode
168
170
 
169
- Opencode itself does not expose an OpenAI-compatible server; its `serve` command
170
- publishes Opencode's own API. To reuse the providers you already configured in
171
- Opencode, run an OpenAI-compatible gateway such as the
171
+ [OpenCode Go](https://opencode.ai) publishes an OpenAI-compatible API at
172
+ `https://opencode.ai/zen/go/v1`; its `GET /v1/models` listing is public and does
173
+ not require a key. The Console can use it directly, so most setups do not need a
174
+ local gateway.
175
+
176
+ The repository includes a discovery script that fetches the live model list and
177
+ merges ready-made Console profiles into `config.json`. From a Catence checkout:
178
+
179
+ ```sh
180
+ npm run discover:opencode-go -- --write ~/.catence/config.json
181
+ ```
182
+
183
+ The script adds two profiles, because OpenCode Go routes model families through
184
+ different API shapes:
185
+
186
+ - `opencode-go` — chat and responses models, base
187
+ `https://opencode.ai/zen/go/v1` (LiteLLM `openai/…` names).
188
+ - `opencode-go-messages` — messages models, base `https://opencode.ai/zen/go`
189
+ (LiteLLM `anthropic/…` names).
190
+
191
+ | Family | Profile prefix | Example |
192
+ | --- | --- | --- |
193
+ | Chat | `openai/<id>` | `openai/deepseek-v4-flash` |
194
+ | Responses | `openai/responses/<id>` | `openai/responses/grok-4.5` |
195
+ | Messages | `anthropic/<id>` | `anthropic/minimax-m2.5` |
196
+
197
+ The script warns on stderr and skips any model that does not fit one of these
198
+ families. Rerun it to refresh the model lists; existing profiles, limits, and
199
+ `defaultProfile` are preserved unless `--set-default` is passed. Re-running
200
+ never discards your own tweaks: per-model custom labels, `reasoningEffort`
201
+ pins, and `variants` maps survive every refresh. Models that have dropped out
202
+ of the live catalog are pruned from the managed profiles, so the Console never
203
+ offers a route the gateway no longer serves; if a pruned model was the
204
+ profile's default, discovery selects a current one instead.
205
+
206
+ You do not have to run the script manually: the Console dashboard's **Sync
207
+ data** button (and `POST /api/v1/sync` with `"refreshModels": true`) refreshes
208
+ these profiles from the live catalog before starting a data sync, and the
209
+ Models page's **Discover OpenCode Go models** button (or
210
+ `POST /api/v1/models/discover`) refreshes them without syncing anything.
211
+ Discovery failures — offline machine, catalog unreachable — are reported as a
212
+ warning and never block or fail the sync itself.
213
+
214
+ ```sh
215
+ export OPENCODE_GO_API_KEY='…' # any non-empty value satisfies the key check
216
+ export OPENCODE_GO_API_BASE='https://opencode.ai/zen/go/v1'
217
+ export OPENCODE_GO_MESSAGES_API_BASE='https://opencode.ai/zen/go'
218
+ ```
219
+
220
+ ```jsonc
221
+ {
222
+ "console": {
223
+ "defaultProfile": "opencode-go",
224
+ "profiles": {
225
+ "opencode-go": {
226
+ "label": "OpenCode Go",
227
+ "defaultModel": "flash",
228
+ "models": {
229
+ "flash": { "label": "DeepSeek V4 flash", "model": "openai/deepseek-v4-flash" },
230
+ "grok": { "label": "Grok 4.5", "model": "openai/responses/grok-4.5" }
231
+ },
232
+ "apiKeyEnv": "OPENCODE_GO_API_KEY",
233
+ "apiBaseEnv": "OPENCODE_GO_API_BASE"
234
+ },
235
+ "opencode-go-messages": {
236
+ "label": "OpenCode Go messages",
237
+ "defaultModel": "minimax",
238
+ "models": {
239
+ "minimax": { "label": "MiniMax M2.5", "model": "anthropic/minimax-m2.5" }
240
+ },
241
+ "apiKeyEnv": "OPENCODE_GO_API_KEY",
242
+ "apiBaseEnv": "OPENCODE_GO_MESSAGES_API_BASE"
243
+ }
244
+ }
245
+ }
246
+ }
247
+ ```
248
+
249
+ Verify with `catence-console doctor`. The `responses` models
250
+ (`openai/responses/…`) go through LiteLLM's responses bridge and are the least
251
+ battle-tested path; smoke-test one chat turn before relying on them.
252
+
253
+ If you instead want to reuse the providers already configured in your local
254
+ Opencode CLI, run an OpenAI-compatible gateway such as the
172
255
  [`opencode-llm-proxy`](https://github.com/KochC/opencode-llm-proxy) plugin, which
173
256
  serves Chat Completions at `http://127.0.0.1:4010/v1` and lists every configured
174
257
  model as `provider/model`.
@@ -202,6 +285,83 @@ Notes:
202
285
  `OPENCODE_LLM_PROXY_TOKEN`; otherwise any non-empty value satisfies the
203
286
  profile's required-environment check.
204
287
 
288
+ ### Opencode Zen
289
+
290
+ [OpenCode Zen](https://opencode.ai/zen) is a hosted model API with a curated
291
+ selection of coding and reasoning models. It exposes an OpenAI-compatible
292
+ endpoint at `https://opencode.ai/zen/v1` with a public `GET /v1/models`
293
+ listing. Several models are available for free (no billing details required);
294
+ paid models require an API key from [opencode.ai/auth](https://opencode.ai/auth).
295
+
296
+ The repository includes a discovery script that fetches the live model list and
297
+ merges ready-made Console profiles into `config.json`. From a Catence checkout:
298
+
299
+ ```sh
300
+ npm run discover:opencode-zen -- --write ~/.catence/config.json
301
+ ```
302
+
303
+ Add `--free-only` to include only the free models.
304
+
305
+ The script adds two profiles, because OpenCode Zen routes model families through
306
+ different API shapes:
307
+
308
+ - `opencode-zen` — chat/completions models (most models, including all free
309
+ models), base `https://opencode.ai/zen/v1` (LiteLLM `openai/<id>` names).
310
+ - `opencode-zen-responses` — responses API models (GPT-5.x series), base
311
+ `https://opencode.ai/zen/v1` (LiteLLM `openai/responses/<id>` names).
312
+
313
+ | Family | Profile prefix | Example |
314
+ | --- | --- | --- |
315
+ | Chat | `openai/<id>` | `openai/deepseek-v4-flash-free` |
316
+ | Responses | `openai/responses/<id>` | `openai/responses/gpt-5.6-luna` |
317
+
318
+ Free models carry a `-free` suffix (e.g., `deepseek-v4-flash-free`,
319
+ `nemotron-3-ultra-free`, `big-pickle`). The discovery script recognises them
320
+ automatically; `--free-only` filters the list to free models only.
321
+
322
+ ```sh
323
+ export OPENCODE_API_KEY='…' # required for paid models; any non-empty value for free
324
+ export OPENCODE_ZEN_API_BASE='https://opencode.ai/zen/v1'
325
+ export OPENCODE_ZEN_RESPONSES_API_BASE='https://opencode.ai/zen/v1'
326
+ ```
327
+
328
+ ```jsonc
329
+ {
330
+ "console": {
331
+ "defaultProfile": "opencode-zen",
332
+ "profiles": {
333
+ "opencode-zen": {
334
+ "label": "OpenCode Zen",
335
+ "defaultModel": "deepseek-v4-flash-free",
336
+ "models": {
337
+ "deepseek-v4-flash-free": { "label": "DeepSeek V4 Flash Free", "model": "openai/deepseek-v4-flash-free" },
338
+ "nemotron-3-ultra-free": { "label": "Nemotron 3 Ultra Free", "model": "openai/nemotron-3-ultra-free" },
339
+ "big-pickle": { "label": "Big Pickle", "model": "openai/big-pickle" }
340
+ },
341
+ "apiKeyEnv": "OPENCODE_API_KEY",
342
+ "apiBaseEnv": "OPENCODE_ZEN_API_BASE"
343
+ },
344
+ "opencode-zen-responses": {
345
+ "label": "OpenCode Zen (Responses)",
346
+ "defaultModel": "gpt-5.6-luna",
347
+ "models": {
348
+ "gpt-5.6-luna": { "label": "GPT 5.6 Luna", "model": "openai/responses/gpt-5.6-luna" },
349
+ "gpt-5.6-terra": { "label": "GPT 5.6 Terra", "model": "openai/responses/gpt-5.6-terra" }
350
+ },
351
+ "apiKeyEnv": "OPENCODE_API_KEY",
352
+ "apiBaseEnv": "OPENCODE_ZEN_RESPONSES_API_BASE"
353
+ }
354
+ }
355
+ }
356
+ }
357
+ ```
358
+
359
+ Verify with `catence-console doctor`. The `responses` models
360
+ (`openai/responses/…`) go through LiteLLM's responses bridge and are the least
361
+ battle-tested path; smoke-test one chat turn before relying on them.
362
+
363
+ The same `OPENCODE_API_KEY` works for both Opencode Go and Opencode Zen.
364
+
205
365
  ## Multiple deployments per provider
206
366
 
207
367
  A profile can list several models under one set of credentials; the Console
@@ -226,6 +386,58 @@ settings then offer a per-deployment choice.
226
386
  }
227
387
  ```
228
388
 
389
+ ## Reasoning effort
390
+
391
+ Models expose a per-chat **Thinking effort** dropdown in the Console settings.
392
+ By default it offers the OpenAI-standard levels — `minimal`, `low`, `medium`,
393
+ `high`, `xhigh` — which suit OpenAI and most OpenAI-compatible endpoints.
394
+
395
+ Some models (for example DeepSeek V4 Flash) use their own reasoning-effort
396
+ scheme rather than the OpenAI-standard list. For those, declare `variants` on
397
+ the model: a mapping of dropdown label to the exact value sent to the provider.
398
+ The chat dropdown then shows `Provider default` plus each label instead of the
399
+ OpenAI-standard list.
400
+
401
+ ```jsonc
402
+ {
403
+ "console": {
404
+ "profiles": {
405
+ "opencode-zen": {
406
+ "label": "OpenCode Zen",
407
+ "defaultModel": "deepseek-v4-flash-free",
408
+ "models": {
409
+ "deepseek-v4-flash-free": {
410
+ "label": "DeepSeek V4 Flash Free",
411
+ "model": "openai/deepseek-v4-flash-free",
412
+ "variants": { "High": "high", "Max": "max" }
413
+ }
414
+ },
415
+ "apiKeyEnv": "OPENCODE_API_KEY",
416
+ "apiBaseEnv": "OPENCODE_ZEN_API_BASE"
417
+ }
418
+ }
419
+ }
420
+ }
421
+ ```
422
+
423
+ Selecting a variant sends that value verbatim as `reasoning_effort` (with
424
+ `allowed_openai_params` so LiteLLM forwards it for OpenAI-compatible models).
425
+ The `Provider default` option sends no reasoning effort at all.
426
+
427
+ An explicitly empty `variants` map disables reasoning effort for that model:
428
+ no `reasoning_effort` is sent (the agent guards against it) and the chat
429
+ **Thinking effort** dropdown is disabled in the UI. This suits models that
430
+ reject the parameter, such as MiMo, which expects its own `thinking` toggle
431
+ instead. `variants: {}` cannot be combined with a fixed `reasoningEffort` on
432
+ the same model.
433
+
434
+ You can also pin a fixed level with `reasoningEffort` on the model, or a
435
+ profile-wide default with `defaultReasoningEffort` (OpenAI-standard values
436
+ only). Precedence: per-chat dropdown selection > model `reasoningEffort` >
437
+ profile `defaultReasoningEffort` > provider default. The **Thinking effort**
438
+ dropdown is rebuilt whenever the **Model** dropdown changes, so its options
439
+ (and disabled state) always reflect the selected model.
440
+
229
441
  ## Verifying a profile
230
442
 
231
443
  `catence-console doctor` reports, for each profile, which environment variables
@@ -235,3 +447,34 @@ prints credential values.
235
447
  ```sh
236
448
  catence-console doctor --home "$HOME/.catence" --mcp-url http://127.0.0.1:8787/mcp
237
449
  ```
450
+
451
+ On Docker, the deploy scaffold includes a helper that runs it inside the
452
+ running container (`exec`, not `run` — the container's loopback hosts the live
453
+ MCP server and the `.env` provider keys are in its environment):
454
+
455
+ ```sh
456
+ ./catence-deploy/doctor.sh
457
+ ```
458
+
459
+ ## Docker: generating the Console login hash
460
+
461
+ `deploy-console.sh` writes a small helper into the deployment directory. Run it
462
+ once after the script to generate the bcrypt hash for
463
+ `CATENCE_CONSOLE_PASSWORD_HASH` — it prompts for the password on the terminal,
464
+ never echoes it, and uses the compose project so it works no matter what image
465
+ tag the script built:
466
+
467
+ ```sh
468
+ ./catence-deploy/hash-password.sh
469
+ ```
470
+
471
+ Paste the output into `CATENCE_CONSOLE_PASSWORD_HASH` in `catence-deploy/.env`,
472
+ then start the stack (`./deploy-console.sh beta` again, or
473
+ `docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env up -d`).
474
+
475
+ If you prefer, re-run the deploy script with `--generate-secrets` instead and
476
+ answer the interactive password prompt; it saves the hash to `.env` for you.
477
+ Without the helper (for example an older deployment), the equivalent command
478
+ is `docker run --rm -it --entrypoint /opt/catence-console/bin/catence-console
479
+ <image-tag> auth hash-password`, where `<image-tag>` is the tag printed by the
480
+ script.
@@ -142,35 +142,12 @@ differs from the activity summary, report both values and the difference.
142
142
 
143
143
  ## Proposed dedicated MCP endpoints
144
144
 
145
- These endpoints are drafts, not registered tools yet. Add them incrementally
146
- only after their shared service methods and fixture tests exist.
147
-
148
- ## Implemented fitness and hydration endpoints
149
-
150
- - `describe_dataset(dataset)` returns one cataloged schema and its coverage. `training_metric_observations` also reports its observed sports, metric names, units, and source types.
151
- - `get_ftp_history`, `get_vo2max_history`, `power_curve_trend`, and `power_coverage_report` read normalized, source-aware fitness facts. Power tools require an explicit sport or sport family, use `power_bests`/`power_best_facts` for FIT-derived duration values, and do not treat sparse `avg_power` summaries as coverage. `get_vo2max_history` does not default to cycling: omission returns available source labels and requires an explicit choice. For Garmin running VO₂max, request `sport: "running"`; the tool reads the raw `generic` source series and preserves that label on returned observations.
152
- - `find_activities` is implemented for compact, canonical, paginated activity/race discovery by sport, distance, name, and date. Its race flags are transparent heuristics, not provider-confirmed race metadata.
153
- - `get_activity_segments` is the required segment/climb path. It performs the
154
- targeted Strava hydration before returning the persisted effort and segment
155
- facts; an empty result must be interpreted alongside its hydration status.
156
- - `latest_cycling_activities` returns Garmin source records to avoid silently double-counting linked Intervals summaries; optional multisport parents are explicitly flagged.
157
- - `cycling_progress_report` composes the preceding read-only outputs with monthly canonical volume/load. It is descriptive, not a physiological model.
158
- - `hydrate_recent_strava_activities` accepts an explicit list or a bounded date/sport window and awaits each write in sequence. Its unmatched output includes the exact Strava search window and safe-match rejection diagnostics.
159
-
160
- ### Power investigation procedure
161
-
162
- For a question about running or cycling power, do not begin with
163
- `canonical_activities.avg_power`: it is an activity-summary field and can be
164
- null even when the FIT stream has power. Instead:
165
-
166
- 1. Call `describe_dataset` for `power_bests` and use `power_coverage_report`
167
- with an explicit `sport` or `sportFamily` to establish coverage and every
168
- available duration.
169
- 2. Use `power_curve_trend` with the same selector and
170
- `sourceQuality: "garmin_fit_derived"` for comparable monthly curve bests.
171
- 3. Inspect `activity_samples` only for a selected `activity_source_id` when a
172
- specific extreme needs validation. Raw sample reads are paginated and are
173
- not evidence that the global power dataset is absent.
145
+ These endpoints are **drafts, not registered tools yet**. The current server
146
+ (`src/interfaces/mcp/server.ts`) does not expose them, so an agent cannot call
147
+ them today; they are specified here so semantics and acceptance criteria are
148
+ agreed before implementation. Add them incrementally only after their shared
149
+ service methods and fixture tests exist, then move them to the implemented
150
+ list below.
174
151
 
175
152
  ### `list_recent_activities`
176
153
 
@@ -330,9 +307,39 @@ manifest-only Parquet access, and null handling. It returns no data—not an
330
307
  error—when the source has no registered stream, with a caveat explaining that
331
308
  sample availability is provider/activity-specific.
332
309
 
310
+ ## Implemented fitness and hydration endpoints
311
+
312
+ - `describe_dataset(dataset)` returns one cataloged schema and its coverage. `training_metric_observations` also reports its observed sports, metric names, units, and source types.
313
+ - `get_ftp_history`, `get_vo2max_history`, `power_curve_trend`, and `power_coverage_report` read normalized, source-aware fitness facts. Power tools require an explicit sport or sport family, use `power_bests`/`power_best_facts` for FIT-derived duration values, and do not treat sparse `avg_power` summaries as coverage. `get_vo2max_history` does not default to cycling: omission returns available source labels and requires an explicit choice. For Garmin running VO₂max, request `sport: "running"`; the tool reads the raw `generic` source series and preserves that label on returned observations.
314
+ - `find_activities` is implemented for compact, canonical, paginated activity/race discovery by sport, distance, name, and date. Its race flags are transparent heuristics, not provider-confirmed race metadata.
315
+ - `get_activity_segments` is the required segment/climb path. It performs the
316
+ targeted Strava hydration before returning the persisted effort and segment
317
+ facts; an empty result must be interpreted alongside its hydration status.
318
+ - `latest_cycling_activities` returns Garmin source records to avoid silently double-counting linked Intervals summaries; optional multisport parents are explicitly flagged.
319
+ - `cycling_progress_report` composes the preceding read-only outputs with monthly canonical volume/load. It is descriptive, not a physiological model.
320
+ - `hydrate_recent_strava_activities` accepts an explicit list or a bounded date/sport window and awaits each write in sequence. Its unmatched output includes the exact Strava search window and safe-match rejection diagnostics.
321
+
322
+ ### Power investigation procedure
323
+
324
+ For a question about running or cycling power, do not begin with
325
+ `canonical_activities.avg_power`: it is an activity-summary field and can be
326
+ null even when the FIT stream has power. Instead:
327
+
328
+ 1. Call `describe_dataset` for `power_bests` and use `power_coverage_report`
329
+ with an explicit `sport` or `sportFamily` to establish coverage and every
330
+ available duration.
331
+ 2. Use `power_curve_trend` with the same selector and
332
+ `sourceQuality: "garmin_fit_derived"` for comparable monthly curve bests.
333
+ 3. Inspect `activity_samples` only for a selected `activity_source_id` when a
334
+ specific extreme needs validation. Raw sample reads are paginated and are
335
+ not evidence that the global power dataset is absent.
336
+
333
337
  ## Intended agent behaviour
334
338
 
335
- For “how did this go in the context of my recent swims?” an agent should:
339
+ This workflow is the target behaviour once the proposed endpoints above are
340
+ implemented and registered; today the agent must approximate it with
341
+ `find_activities`, `get_activity_segments`, and `read_series`. For “how did
342
+ this go in the context of my recent swims?” an agent should:
336
343
 
337
344
  1. Use `list_recent_activities` filtered to swimming to identify the selected
338
345
  source and comparable source records.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0-beta.2",
3
+ "version": "0.2.0-beta.21",
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": {
@@ -28,6 +28,8 @@
28
28
  "README.md",
29
29
  "config.example.json",
30
30
  "docs",
31
+ "scripts/discover-opencode-go.mjs",
32
+ "scripts/discover-opencode-zen.mjs",
31
33
  "pyproject.toml",
32
34
  "uv.lock",
33
35
  "python/__init__.py",
@@ -55,6 +57,8 @@
55
57
  "mcp": "tsx src/interfaces/mcp/main.ts",
56
58
  "console": "uv run --project console catence-console serve",
57
59
  "console:doctor": "uv run --project console catence-console doctor",
60
+ "discover:opencode-go": "node scripts/discover-opencode-go.mjs",
61
+ "discover:opencode-zen": "node scripts/discover-opencode-zen.mjs",
58
62
  "package:check": "npm pack --dry-run",
59
63
  "release:check": "node scripts/release-artifacts.mjs check",
60
64
  "mcpb:validate": "npm exec --yes --package @anthropic-ai/mcpb -- mcpb validate mcpb/manifest.json",
package/pyproject.toml CHANGED
@@ -15,7 +15,7 @@ dependencies = [
15
15
 
16
16
  [tool.uv.sources]
17
17
  # fork commit
18
- garminconnect = { git = "https://github.com/rifusaki/python-garminconnect.git", rev = "732bf14602bce2ca22cb569ba6583c3229bf904d" }
18
+ garminconnect = { git = "https://github.com/rifusaki/python-garminconnect.git", rev = "cd4e3d9ef81a93681950138e08f57af226fd5735" }
19
19
 
20
20
  [tool.uv]
21
21
  package = false
@@ -4,11 +4,14 @@ import argparse
4
4
  import getpass
5
5
  import json
6
6
  import os
7
+ import signal
8
+ import sys
7
9
  from datetime import date
8
10
  from pathlib import Path
9
11
 
12
+ from .progress import ProgressReporter
10
13
  from .staging import StagingWriter
11
- from .worker import GarminStagingWorker
14
+ from .worker import GarminStagingWorker, WorkerInterrupted
12
15
 
13
16
 
14
17
  def parse_args() -> argparse.Namespace:
@@ -26,6 +29,17 @@ def parse_args() -> argparse.Namespace:
26
29
  parser.add_argument("--historical-only", action="store_true", help="Skip current account and collection endpoints.")
27
30
  parser.add_argument("--skip-daily", action="store_true", help="Do not fetch date-scoped daily data.")
28
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, help="Backfill lactate-threshold history from this date instead of the daily window.",
36
+ )
37
+ parser.add_argument(
38
+ "--events-from",
39
+ dest="events_from",
40
+ type=date.fromisoformat,
41
+ help="Fetch calendar events from this date instead of the default short lookback.",
42
+ )
29
43
  return parser.parse_args()
30
44
 
31
45
 
@@ -43,19 +57,39 @@ def main() -> None:
43
57
  token_store.parent.mkdir(parents=True, exist_ok=True)
44
58
  client = Garmin(email, password, prompt_mfa=lambda: input("Garmin MFA code: "))
45
59
  client.login(str(token_store))
60
+ reporter = ProgressReporter(args.run_id)
61
+ reporter.set_stage("login")
62
+ reporter.finish("login")
46
63
  known_hashes: dict[str, str] = {}
47
64
  if args.known_activities and args.known_activities.exists():
48
65
  loaded = json.loads(args.known_activities.read_text(encoding="utf-8"))
49
66
  if isinstance(loaded, dict):
50
67
  known_hashes = {str(key): str(value) for key, value in loaded.items()}
51
- GarminStagingWorker(client, writer, args.data_dir, known_hashes).sync(
52
- daily_from_date=None if args.skip_daily else args.daily_from_date or args.from_date,
53
- activity_from_date=None if args.skip_activities else args.activity_from_date or args.from_date,
54
- to_date=args.to_date,
55
- daily_to_date=args.daily_to_date,
56
- activity_to_date=args.activity_to_date,
57
- include_non_historical=not args.historical_only,
58
- )
68
+ worker = GarminStagingWorker(client, writer, args.data_dir, known_hashes, progress=reporter)
69
+ interrupt_signal: int | None = None
70
+
71
+ def _request_interrupt(signum: int, _frame: object) -> None:
72
+ nonlocal interrupt_signal
73
+ interrupt_signal = signum
74
+ worker.request_interrupt()
75
+
76
+ signal.signal(signal.SIGINT, _request_interrupt)
77
+ signal.signal(signal.SIGTERM, _request_interrupt)
78
+ try:
79
+ worker.sync(
80
+ daily_from_date=None if args.skip_daily else args.daily_from_date or args.from_date,
81
+ activity_from_date=None if args.skip_activities else args.activity_from_date or args.from_date,
82
+ to_date=args.to_date,
83
+ daily_to_date=args.daily_to_date,
84
+ activity_to_date=args.activity_to_date,
85
+ include_non_historical=not args.historical_only,
86
+ lactate_threshold_history_from=args.lt_history_from,
87
+ events_from=args.events_from.isoformat() if args.events_from else None,
88
+ )
89
+ except WorkerInterrupted:
90
+ reporter.finish("interrupted")
91
+ raise SystemExit(130 if interrupt_signal == signal.SIGINT else 143)
92
+ reporter.finish("completed")
59
93
 
60
94
 
61
95
  if __name__ == "__main__":
@@ -0,0 +1,85 @@
1
+ """Progress reporting for Garmin sync runs.
2
+
3
+ Emits single-line JSON progress records (kind: 'progress') on stdout so the
4
+ Node management layer can persist heartbeats without parsing the staging
5
+ JSONL consumed by the importer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Callable
14
+
15
+
16
+ class ProgressReporter:
17
+ """Throttled progress publisher writing NDJSON records to stdout."""
18
+
19
+ def __init__(
20
+ self,
21
+ run_id: str,
22
+ provider: str = "garmin",
23
+ emit: Callable[[str], None] = lambda line: print(line, flush=True),
24
+ interval_seconds: float = 2.0,
25
+ ) -> None:
26
+ self.run_id = run_id
27
+ self.provider = provider
28
+ self.emit = emit
29
+ self.interval_seconds = interval_seconds
30
+ self._started = time.monotonic()
31
+ self._last_emitted = 0.0
32
+ self.stage = "starting"
33
+ self.current_step: str | None = None
34
+ self.completed_units = 0
35
+ self.total_units: int | None = None
36
+
37
+ def set_stage(self, stage: str) -> None:
38
+ """Switch phases, resetting phase-local counters and forcing a publish."""
39
+ if stage != self.stage:
40
+ self.stage = stage
41
+ self.current_step = None
42
+ self.completed_units = 0
43
+ self.total_units = None
44
+ self.publish(force=True)
45
+
46
+ def advance(self, completed: int | None = None, total: int | None = None, step: str | None = None) -> None:
47
+ if completed is not None:
48
+ self.completed_units = completed
49
+ if total is not None:
50
+ self.total_units = total
51
+ if step is not None:
52
+ self.current_step = step
53
+ self.publish()
54
+
55
+ def publish(self, force: bool = False) -> None:
56
+ now = time.monotonic()
57
+ if not force and now - self._last_emitted < self.interval_seconds:
58
+ return
59
+ self._last_emitted = now
60
+ elapsed = now - self._started
61
+ percent = 0.0
62
+ eta: float | None = None
63
+ if self.total_units and self.total_units > 0:
64
+ percent = min(100.0, max(0.0, self.completed_units / self.total_units * 100.0))
65
+ if self.completed_units > 0 and self.completed_units < self.total_units:
66
+ rate = elapsed / self.completed_units
67
+ eta = rate * (self.total_units - self.completed_units)
68
+ record = {
69
+ "kind": "progress",
70
+ "runId": self.run_id,
71
+ "provider": self.provider,
72
+ "stage": self.stage,
73
+ "currentStep": self.current_step,
74
+ "completedUnits": self.completed_units,
75
+ "totalUnits": self.total_units,
76
+ "percentComplete": round(percent, 2),
77
+ "elapsedSeconds": round(elapsed, 1),
78
+ "estimatedRemainingSeconds": round(eta, 1) if eta is not None else None,
79
+ "heartbeatAt": datetime.now(timezone.utc).isoformat(),
80
+ }
81
+ self.emit(json.dumps(record, separators=(",", ":")))
82
+
83
+ def finish(self, stage: str = "completed") -> None:
84
+ self.stage = stage
85
+ self.publish(force=True)
@@ -32,6 +32,7 @@ GARMIN_READ_METHODS = {
32
32
  "get_activity_gear", "download_activity",
33
33
  ),
34
34
  "workouts": ("get_workouts", "get_scheduled_workouts"),
35
+ "events": ("get_events",),
35
36
  "goals": ("get_goals",),
36
37
  "gear": ("get_gear",),
37
38
  "golf": ("get_golf_summary",),
@@ -183,6 +183,44 @@ def fit_archive_to_samples(activity_source_id: str, archive: Path) -> list[dict[
183
183
  return []
184
184
 
185
185
 
186
+ def fit_archive_to_swim_lengths(activity_source_id: str, archive: Path) -> list[dict[str, Any]]:
187
+ """Best-effort per-length parsing from the FIT 'length' messages; malformed/unavailable archives yield no rows."""
188
+ try:
189
+ import fitdecode
190
+ with zipfile.ZipFile(archive) as zip_file:
191
+ fit_name = next((name for name in zip_file.namelist() if name.lower().endswith(".fit")), None)
192
+ if not fit_name:
193
+ return []
194
+ with zip_file.open(fit_name) as stream, fitdecode.FitReader(stream) as reader:
195
+ rows: list[dict[str, Any]] = []
196
+ for frame in reader:
197
+ if not isinstance(frame, fitdecode.records.FitDataMessage) or frame.name != "length":
198
+ continue
199
+ fields = {field.name: field.value for field in frame.fields}
200
+ length_type = str(fields.get("length_type") or "").lower()
201
+ start_time = fields.get("start_time") or fields.get("timestamp")
202
+ row = {
203
+ "lengthIndex": _number(fields.get("message_index")),
204
+ "lapIndex": None,
205
+ "startTime": start_time.isoformat().replace("+00:00", "Z") if hasattr(start_time, "isoformat") else None,
206
+ "durationS": _number(fields.get("total_elapsed_time") or fields.get("total_timer_time")),
207
+ "activeDurationS": _number(fields.get("total_timer_time")),
208
+ "distanceM": _number(fields.get("distance")),
209
+ "strokeCount": _number(fields.get("total_strokes")),
210
+ "strokeRate": _number(fields.get("avg_swimming_cadence") or fields.get("avg_cadence")),
211
+ "swolf": _number(fields.get("avg_swolf")),
212
+ "avgHr": _number(fields.get("avg_heart_rate") or fields.get("average_heart_rate")),
213
+ "maxHr": _number(fields.get("max_heart_rate")),
214
+ "isRest": length_type in {"rest", "idle", "pause"},
215
+ "label": str(fields.get("swim_stroke") or "").lower() or None,
216
+ "raw": {name: (value.isoformat().replace("+00:00", "Z") if hasattr(value, "isoformat") else value) for name, value in fields.items()},
217
+ }
218
+ rows.append(row)
219
+ return rows
220
+ except (ImportError, OSError, ValueError, zipfile.BadZipFile):
221
+ return []
222
+
223
+
186
224
  def write_parquet(data_dir: Path, activity_id: str, start_date: str, samples: Iterable[dict[str, Any]]) -> tuple[Path, str, int]:
187
225
  records = list(samples)
188
226
  year, month = (start_date[:4] or "unknown"), (start_date[5:7] or "unknown")