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.
- package/README.md +28 -82
- package/config.example.json +25 -0
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/progress.d.ts +23 -0
- package/dist/contracts/progress.js +49 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +48 -0
- package/dist/core/runtime/catalog.js +17 -7
- package/dist/core/runtime/configuration.d.ts +3 -1
- package/dist/core/runtime/configuration.js +5 -1
- package/dist/core/runtime/opencode-go.d.ts +58 -0
- package/dist/core/runtime/opencode-go.js +246 -0
- package/dist/core/runtime/secrets.js +51 -13
- package/dist/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- package/dist/elt/application/detached-sync.d.ts +50 -0
- package/dist/elt/application/detached-sync.js +99 -0
- package/dist/elt/application/management.d.ts +15 -0
- package/dist/elt/application/management.js +435 -19
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +5 -4
- package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
- package/dist/elt/ingestion/providers/intervals.js +37 -2
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +18 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/normalization/normalizers.js +111 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +184 -6
- package/dist/elt/storage/migrations.js +25 -1
- package/dist/elt/storage/progress-sidecar.d.ts +61 -0
- package/dist/elt/storage/progress-sidecar.js +200 -0
- package/dist/interfaces/cli/commands.js +116 -1
- package/dist/interfaces/http/generation-status.d.ts +19 -0
- package/dist/interfaces/http/generation-status.js +55 -0
- package/dist/interfaces/http/server.d.ts +7 -1
- package/dist/interfaces/http/server.js +184 -1
- package/dist/interfaces/mcp/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +50 -13
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +4 -1
- package/docs/architecture.md +11 -1
- package/docs/configuration.md +243 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +263 -0
- package/docs/deployment/local-mcp.md +329 -0
- package/docs/development.md +337 -0
- package/docs/distribution.md +58 -0
- package/docs/llm-providers.md +249 -6
- package/docs/mcp-activity-retrieval.md +37 -30
- package/package.json +5 -1
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +43 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/registry.py +1 -0
- package/python/catence/providers/garmin/streams.py +38 -0
- package/python/catence/providers/garmin/swim_backfill.py +83 -0
- package/python/catence/providers/garmin/worker.py +372 -11
- package/scripts/discover-opencode-go.mjs +102 -0
- package/scripts/discover-opencode-zen.mjs +218 -0
- package/uv.lock +3 -3
package/docs/llm-providers.md
CHANGED
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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.
|
|
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 = "
|
|
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)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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)
|
|
@@ -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")
|