catence 0.1.0 → 0.2.0-beta.10

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 (51) hide show
  1. package/README.md +239 -269
  2. package/config.example.json +32 -20
  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 +48 -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 +42 -0
  13. package/dist/core/runtime/catalog.d.ts +41 -0
  14. package/dist/core/runtime/catalog.js +121 -0
  15. package/dist/core/runtime/configuration.d.ts +1 -1
  16. package/dist/core/runtime/configuration.js +3 -3
  17. package/dist/core/runtime/secrets.d.ts +24 -0
  18. package/dist/core/runtime/secrets.js +68 -0
  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/management.d.ts +2 -0
  22. package/dist/elt/application/management.js +197 -18
  23. package/dist/elt/ingestion/importer.d.ts +3 -2
  24. package/dist/elt/ingestion/importer.js +4 -3
  25. package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
  26. package/dist/elt/ingestion/providers/intervals.js +4 -1
  27. package/dist/elt/ingestion/providers/strava/service.js +9 -2
  28. package/dist/elt/storage/database.d.ts +7 -2
  29. package/dist/elt/storage/database.js +81 -4
  30. package/dist/elt/storage/migrations.js +25 -1
  31. package/dist/interfaces/cli/commands.js +201 -46
  32. package/dist/interfaces/http/cli-options.d.ts +2 -2
  33. package/dist/interfaces/http/cli-options.js +10 -10
  34. package/dist/interfaces/http/server.d.ts +4 -1
  35. package/dist/interfaces/http/server.js +31 -4
  36. package/dist/interfaces/mcp/cli-options.d.ts +2 -2
  37. package/dist/interfaces/mcp/cli-options.js +14 -14
  38. package/dist/interfaces/mcp/main.js +12 -5
  39. package/dist/interfaces/mcp/server.d.ts +2 -2
  40. package/dist/interfaces/mcp/server.js +156 -98
  41. package/dist/runtime/index.d.ts +8 -1
  42. package/dist/runtime/index.js +4 -1
  43. package/docs/deployment.md +338 -0
  44. package/docs/docker-deployment.md +232 -0
  45. package/docs/llm-providers.md +467 -0
  46. package/package.json +6 -2
  47. package/python/catence/providers/garmin/cli.py +30 -9
  48. package/python/catence/providers/garmin/progress.py +85 -0
  49. package/python/catence/providers/garmin/worker.py +132 -5
  50. package/scripts/discover-opencode-go.mjs +210 -0
  51. package/scripts/discover-opencode-zen.mjs +218 -0
@@ -0,0 +1,467 @@
1
+ # Console LLM providers
2
+
3
+ Catence Console talks to a model through [LiteLLM](https://docs.litellm.ai/).
4
+ There are exactly three provider shapes: **OpenAI**, **Anthropic**, and a
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.
10
+
11
+ ## How configuration is split
12
+
13
+ Catence never stores a credential value in `config.json`. A Console profile
14
+ records only a LiteLLM model name and the *names* of the environment variables
15
+ that hold the key, base URL, and (optionally) API version. The values
16
+ themselves stay in the Console process environment.
17
+
18
+ ```jsonc
19
+ // ~/.catence/config.json (or /data/config.json inside the Docker image)
20
+ {
21
+ "console": {
22
+ "defaultProfile": "openai",
23
+ "profiles": {
24
+ "openai": {
25
+ "label": "OpenAI",
26
+ "model": "openai/gpt-5-mini",
27
+ "apiKeyEnv": "OPENAI_API_KEY"
28
+ },
29
+ "anthropic": {
30
+ "label": "Anthropic",
31
+ "model": "anthropic/claude-sonnet-4-5",
32
+ "apiKeyEnv": "ANTHROPIC_API_KEY"
33
+ },
34
+ "openai-compatible": {
35
+ "label": "OpenAI-compatible",
36
+ "model": "openai/your-model-name",
37
+ "apiKeyEnv": "OPENAI_API_KEY",
38
+ "apiBaseEnv": "OPENAI_API_BASE"
39
+ }
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ The supported profile fields are `label`, `model` (or `models` + `defaultModel`
46
+ for multiple deployments), `defaultReasoningEffort`, `apiKeyEnv`, `apiBaseEnv`,
47
+ and `apiVersionEnv`. Every `*Env` value must be an uppercase environment
48
+ variable name; a raw secret there is rejected.
49
+
50
+ | Profile field | Environment variable it reads |
51
+ | --- | --- |
52
+ | `apiKeyEnv` | API key |
53
+ | `apiBaseEnv` | Base URL (OpenAI-compatible endpoints) |
54
+ | `apiVersionEnv` | Optional API version, only for endpoints that require one |
55
+
56
+ ## OpenAI
57
+
58
+ Point the profile at the public OpenAI API. Only a key is required.
59
+
60
+ ```sh
61
+ export OPENAI_API_KEY='sk-…'
62
+ ```
63
+
64
+ ```jsonc
65
+ {
66
+ "console": {
67
+ "defaultProfile": "openai",
68
+ "profiles": {
69
+ "openai": {
70
+ "label": "OpenAI",
71
+ "model": "openai/gpt-5-mini",
72
+ "apiKeyEnv": "OPENAI_API_KEY"
73
+ }
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ ## Anthropic
80
+
81
+ Only a key is required.
82
+
83
+ ```sh
84
+ export ANTHROPIC_API_KEY='sk-ant-…'
85
+ ```
86
+
87
+ ```jsonc
88
+ {
89
+ "console": {
90
+ "defaultProfile": "anthropic",
91
+ "profiles": {
92
+ "anthropic": {
93
+ "label": "Anthropic",
94
+ "model": "anthropic/claude-sonnet-4-5",
95
+ "apiKeyEnv": "ANTHROPIC_API_KEY"
96
+ }
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ ## OpenAI-compatible
103
+
104
+ Use this one profile for any endpoint that speaks the OpenAI Chat Completions
105
+ API: Azure, Opencode, Ollama, LM Studio, a LiteLLM proxy, and so on. Set the
106
+ key and the base URL; the model name is whatever the endpoint expects.
107
+
108
+ ```sh
109
+ export OPENAI_API_KEY='…'
110
+ export OPENAI_API_BASE='https://host.example/v1'
111
+ ```
112
+
113
+ ```jsonc
114
+ {
115
+ "console": {
116
+ "defaultProfile": "openai-compatible",
117
+ "profiles": {
118
+ "openai-compatible": {
119
+ "label": "OpenAI-compatible",
120
+ "model": "openai/your-model-name",
121
+ "apiKeyEnv": "OPENAI_API_KEY",
122
+ "apiBaseEnv": "OPENAI_API_BASE"
123
+ }
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ LiteLLM appends `/chat/completions` to `OPENAI_API_BASE`, so point it at the
130
+ `…/v1` root of the endpoint, not the full completions path.
131
+
132
+ ### Azure
133
+
134
+ Azure OpenAI's unified v1 API is OpenAI-compatible, so it uses the same
135
+ `openai-compatible` profile. Use the **Azure deployment name** as the model.
136
+
137
+ ```sh
138
+ export OPENAI_API_KEY='your-azure-openai-api-key'
139
+ export OPENAI_API_BASE='https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1/'
140
+ ```
141
+
142
+ ```jsonc
143
+ {
144
+ "console": {
145
+ "defaultProfile": "openai-compatible",
146
+ "profiles": {
147
+ "openai-compatible": {
148
+ "label": "Azure OpenAI",
149
+ "model": "openai/gpt-4.1-nano", // your deployment name
150
+ "apiKeyEnv": "OPENAI_API_KEY",
151
+ "apiBaseEnv": "OPENAI_API_BASE"
152
+ }
153
+ }
154
+ }
155
+ }
156
+ ```
157
+
158
+ Notes:
159
+
160
+ - `OPENAI_API_BASE` also accepts the Foundry models form
161
+ `https://YOUR-RESOURCE-NAME.services.ai.azure.com/openai/v1/`.
162
+ - With the v1 API the `api-version` query parameter is no longer required, so
163
+ `apiVersionEnv` stays unset. If an older endpoint still needs one, add
164
+ `"apiVersionEnv": "OPENAI_API_VERSION"` and export
165
+ `OPENAI_API_VERSION='2024-06-01'` (or your API version).
166
+ - `OPENAI_API_KEY` holds the Azure OpenAI API key. If you use Microsoft Entra
167
+ ID instead, a keyless token provider can be passed in the same variable.
168
+
169
+ ### Opencode
170
+
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.
200
+
201
+ ```sh
202
+ export OPENCODE_GO_API_KEY='…' # any non-empty value satisfies the key check
203
+ export OPENCODE_GO_API_BASE='https://opencode.ai/zen/go/v1'
204
+ export OPENCODE_GO_MESSAGES_API_BASE='https://opencode.ai/zen/go'
205
+ ```
206
+
207
+ ```jsonc
208
+ {
209
+ "console": {
210
+ "defaultProfile": "opencode-go",
211
+ "profiles": {
212
+ "opencode-go": {
213
+ "label": "OpenCode Go",
214
+ "defaultModel": "flash",
215
+ "models": {
216
+ "flash": { "label": "DeepSeek V4 flash", "model": "openai/deepseek-v4-flash" },
217
+ "grok": { "label": "Grok 4.5", "model": "openai/responses/grok-4.5" }
218
+ },
219
+ "apiKeyEnv": "OPENCODE_GO_API_KEY",
220
+ "apiBaseEnv": "OPENCODE_GO_API_BASE"
221
+ },
222
+ "opencode-go-messages": {
223
+ "label": "OpenCode Go messages",
224
+ "defaultModel": "minimax",
225
+ "models": {
226
+ "minimax": { "label": "MiniMax M2.5", "model": "anthropic/minimax-m2.5" }
227
+ },
228
+ "apiKeyEnv": "OPENCODE_GO_API_KEY",
229
+ "apiBaseEnv": "OPENCODE_GO_MESSAGES_API_BASE"
230
+ }
231
+ }
232
+ }
233
+ }
234
+ ```
235
+
236
+ Verify with `catence-console doctor`. The `responses` models
237
+ (`openai/responses/…`) go through LiteLLM's responses bridge and are the least
238
+ battle-tested path; smoke-test one chat turn before relying on them.
239
+
240
+ If you instead want to reuse the providers already configured in your local
241
+ Opencode CLI, run an OpenAI-compatible gateway such as the
242
+ [`opencode-llm-proxy`](https://github.com/KochC/opencode-llm-proxy) plugin, which
243
+ serves Chat Completions at `http://127.0.0.1:4010/v1` and lists every configured
244
+ model as `provider/model`.
245
+
246
+ ```sh
247
+ export OPENAI_API_KEY='unused' # or your OPENCODE_LLM_PROXY_TOKEN
248
+ export OPENAI_API_BASE='http://127.0.0.1:4010/v1'
249
+ ```
250
+
251
+ ```jsonc
252
+ {
253
+ "console": {
254
+ "defaultProfile": "openai-compatible",
255
+ "profiles": {
256
+ "openai-compatible": {
257
+ "label": "Opencode",
258
+ "model": "openai/github-copilot/claude-sonnet-4.6", // provider/model
259
+ "apiKeyEnv": "OPENAI_API_KEY",
260
+ "apiBaseEnv": "OPENAI_API_BASE"
261
+ }
262
+ }
263
+ }
264
+ }
265
+ ```
266
+
267
+ Notes:
268
+
269
+ - Use the fully qualified `provider/model` id from the gateway's
270
+ `GET /v1/models` listing.
271
+ - If the gateway requires a bearer token, set `OPENAI_API_KEY` to the
272
+ `OPENCODE_LLM_PROXY_TOKEN`; otherwise any non-empty value satisfies the
273
+ profile's required-environment check.
274
+
275
+ ### Opencode Zen
276
+
277
+ [OpenCode Zen](https://opencode.ai/zen) is a hosted model API with a curated
278
+ selection of coding and reasoning models. It exposes an OpenAI-compatible
279
+ endpoint at `https://opencode.ai/zen/v1` with a public `GET /v1/models`
280
+ listing. Several models are available for free (no billing details required);
281
+ paid models require an API key from [opencode.ai/auth](https://opencode.ai/auth).
282
+
283
+ The repository includes a discovery script that fetches the live model list and
284
+ merges ready-made Console profiles into `config.json`. From a Catence checkout:
285
+
286
+ ```sh
287
+ npm run discover:opencode-zen -- --write ~/.catence/config.json
288
+ ```
289
+
290
+ Add `--free-only` to include only the free models.
291
+
292
+ The script adds two profiles, because OpenCode Zen routes model families through
293
+ different API shapes:
294
+
295
+ - `opencode-zen` — chat/completions models (most models, including all free
296
+ models), base `https://opencode.ai/zen/v1` (LiteLLM `openai/<id>` names).
297
+ - `opencode-zen-responses` — responses API models (GPT-5.x series), base
298
+ `https://opencode.ai/zen/v1` (LiteLLM `openai/responses/<id>` names).
299
+
300
+ | Family | Profile prefix | Example |
301
+ | --- | --- | --- |
302
+ | Chat | `openai/<id>` | `openai/deepseek-v4-flash-free` |
303
+ | Responses | `openai/responses/<id>` | `openai/responses/gpt-5.6-luna` |
304
+
305
+ Free models carry a `-free` suffix (e.g., `deepseek-v4-flash-free`,
306
+ `nemotron-3-ultra-free`, `big-pickle`). The discovery script recognises them
307
+ automatically; `--free-only` filters the list to free models only.
308
+
309
+ ```sh
310
+ export OPENCODE_API_KEY='…' # required for paid models; any non-empty value for free
311
+ export OPENCODE_ZEN_API_BASE='https://opencode.ai/zen/v1'
312
+ export OPENCODE_ZEN_RESPONSES_API_BASE='https://opencode.ai/zen/v1'
313
+ ```
314
+
315
+ ```jsonc
316
+ {
317
+ "console": {
318
+ "defaultProfile": "opencode-zen",
319
+ "profiles": {
320
+ "opencode-zen": {
321
+ "label": "OpenCode Zen",
322
+ "defaultModel": "deepseek-v4-flash-free",
323
+ "models": {
324
+ "deepseek-v4-flash-free": { "label": "DeepSeek V4 Flash Free", "model": "openai/deepseek-v4-flash-free" },
325
+ "nemotron-3-ultra-free": { "label": "Nemotron 3 Ultra Free", "model": "openai/nemotron-3-ultra-free" },
326
+ "big-pickle": { "label": "Big Pickle", "model": "openai/big-pickle" }
327
+ },
328
+ "apiKeyEnv": "OPENCODE_API_KEY",
329
+ "apiBaseEnv": "OPENCODE_ZEN_API_BASE"
330
+ },
331
+ "opencode-zen-responses": {
332
+ "label": "OpenCode Zen (Responses)",
333
+ "defaultModel": "gpt-5.6-luna",
334
+ "models": {
335
+ "gpt-5.6-luna": { "label": "GPT 5.6 Luna", "model": "openai/responses/gpt-5.6-luna" },
336
+ "gpt-5.6-terra": { "label": "GPT 5.6 Terra", "model": "openai/responses/gpt-5.6-terra" }
337
+ },
338
+ "apiKeyEnv": "OPENCODE_API_KEY",
339
+ "apiBaseEnv": "OPENCODE_ZEN_RESPONSES_API_BASE"
340
+ }
341
+ }
342
+ }
343
+ }
344
+ ```
345
+
346
+ Verify with `catence-console doctor`. The `responses` models
347
+ (`openai/responses/…`) go through LiteLLM's responses bridge and are the least
348
+ battle-tested path; smoke-test one chat turn before relying on them.
349
+
350
+ The same `OPENCODE_API_KEY` works for both Opencode Go and Opencode Zen.
351
+
352
+ ## Multiple deployments per provider
353
+
354
+ A profile can list several models under one set of credentials; the Console
355
+ settings then offer a per-deployment choice.
356
+
357
+ ```jsonc
358
+ {
359
+ "console": {
360
+ "profiles": {
361
+ "openai-compatible": {
362
+ "label": "Azure OpenAI",
363
+ "defaultModel": "nano",
364
+ "models": {
365
+ "nano": { "label": "GPT-4.1 nano", "model": "openai/gpt-4.1-nano" },
366
+ "mini": { "label": "GPT-5 mini", "model": "openai/gpt-5-mini" }
367
+ },
368
+ "apiKeyEnv": "OPENAI_API_KEY",
369
+ "apiBaseEnv": "OPENAI_API_BASE"
370
+ }
371
+ }
372
+ }
373
+ }
374
+ ```
375
+
376
+ ## Reasoning effort
377
+
378
+ Models expose a per-chat **Thinking effort** dropdown in the Console settings.
379
+ By default it offers the OpenAI-standard levels — `minimal`, `low`, `medium`,
380
+ `high`, `xhigh` — which suit OpenAI and most OpenAI-compatible endpoints.
381
+
382
+ Some models (for example DeepSeek V4 Flash) use their own reasoning-effort
383
+ scheme rather than the OpenAI-standard list. For those, declare `variants` on
384
+ the model: a mapping of dropdown label to the exact value sent to the provider.
385
+ The chat dropdown then shows `Provider default` plus each label instead of the
386
+ OpenAI-standard list.
387
+
388
+ ```jsonc
389
+ {
390
+ "console": {
391
+ "profiles": {
392
+ "opencode-zen": {
393
+ "label": "OpenCode Zen",
394
+ "defaultModel": "deepseek-v4-flash-free",
395
+ "models": {
396
+ "deepseek-v4-flash-free": {
397
+ "label": "DeepSeek V4 Flash Free",
398
+ "model": "openai/deepseek-v4-flash-free",
399
+ "variants": { "High": "high", "Max": "max" }
400
+ }
401
+ },
402
+ "apiKeyEnv": "OPENCODE_API_KEY",
403
+ "apiBaseEnv": "OPENCODE_ZEN_API_BASE"
404
+ }
405
+ }
406
+ }
407
+ }
408
+ ```
409
+
410
+ Selecting a variant sends that value verbatim as `reasoning_effort` (with
411
+ `allowed_openai_params` so LiteLLM forwards it for OpenAI-compatible models).
412
+ The `Provider default` option sends no reasoning effort at all.
413
+
414
+ An explicitly empty `variants` map disables reasoning effort for that model:
415
+ no `reasoning_effort` is sent (the agent guards against it) and the chat
416
+ **Thinking effort** dropdown is disabled in the UI. This suits models that
417
+ reject the parameter, such as MiMo, which expects its own `thinking` toggle
418
+ instead. `variants: {}` cannot be combined with a fixed `reasoningEffort` on
419
+ the same model.
420
+
421
+ You can also pin a fixed level with `reasoningEffort` on the model, or a
422
+ profile-wide default with `defaultReasoningEffort` (OpenAI-standard values
423
+ only). Precedence: per-chat dropdown selection > model `reasoningEffort` >
424
+ profile `defaultReasoningEffort` > provider default. The **Thinking effort**
425
+ dropdown is rebuilt whenever the **Model** dropdown changes, so its options
426
+ (and disabled state) always reflect the selected model.
427
+
428
+ ## Verifying a profile
429
+
430
+ `catence-console doctor` reports, for each profile, which environment variables
431
+ are still missing and whether the local Catence runtime is reachable. It never
432
+ prints credential values.
433
+
434
+ ```sh
435
+ catence-console doctor --home "$HOME/.catence" --mcp-url http://127.0.0.1:8787/mcp
436
+ ```
437
+
438
+ On Docker, the deploy scaffold includes a helper that runs it inside the
439
+ running container (`exec`, not `run` — the container's loopback hosts the live
440
+ MCP server and the `.env` provider keys are in its environment):
441
+
442
+ ```sh
443
+ ./catence-deploy/doctor.sh
444
+ ```
445
+
446
+ ## Docker: generating the Console login hash
447
+
448
+ `deploy-console.sh` writes a small helper into the deployment directory. Run it
449
+ once after the script to generate the bcrypt hash for
450
+ `CATENCE_CONSOLE_PASSWORD_HASH` — it prompts for the password on the terminal,
451
+ never echoes it, and uses the compose project so it works no matter what image
452
+ tag the script built:
453
+
454
+ ```sh
455
+ ./catence-deploy/hash-password.sh
456
+ ```
457
+
458
+ Paste the output into `CATENCE_CONSOLE_PASSWORD_HASH` in `catence-deploy/.env`,
459
+ then start the stack (`./deploy-console.sh beta` again, or
460
+ `docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env up -d`).
461
+
462
+ If you prefer, re-run the deploy script with `--generate-secrets` instead and
463
+ answer the interactive password prompt; it saves the hash to `.env` for you.
464
+ Without the helper (for example an older deployment), the equivalent command
465
+ is `docker run --rm -it --entrypoint /opt/catence-console/bin/catence-console
466
+ <image-tag> auth hash-password`, where `<image-tag>` is the tag printed by the
467
+ script.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-beta.10",
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",
@@ -80,6 +84,6 @@
80
84
  "overrides": {
81
85
  "fast-uri": "3.1.5",
82
86
  "hono": "4.12.34",
83
- "nanoid": "3.3.17"
87
+ "nanoid": "3.3.18"
84
88
  }
85
89
  }
@@ -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:
@@ -43,19 +46,37 @@ def main() -> None:
43
46
  token_store.parent.mkdir(parents=True, exist_ok=True)
44
47
  client = Garmin(email, password, prompt_mfa=lambda: input("Garmin MFA code: "))
45
48
  client.login(str(token_store))
49
+ reporter = ProgressReporter(args.run_id)
50
+ reporter.set_stage("login")
51
+ reporter.finish("login")
46
52
  known_hashes: dict[str, str] = {}
47
53
  if args.known_activities and args.known_activities.exists():
48
54
  loaded = json.loads(args.known_activities.read_text(encoding="utf-8"))
49
55
  if isinstance(loaded, dict):
50
56
  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
- )
57
+ worker = GarminStagingWorker(client, writer, args.data_dir, known_hashes, progress=reporter)
58
+ interrupt_signal: int | None = None
59
+
60
+ def _request_interrupt(signum: int, _frame: object) -> None:
61
+ nonlocal interrupt_signal
62
+ interrupt_signal = signum
63
+ worker.request_interrupt()
64
+
65
+ signal.signal(signal.SIGINT, _request_interrupt)
66
+ signal.signal(signal.SIGTERM, _request_interrupt)
67
+ try:
68
+ worker.sync(
69
+ daily_from_date=None if args.skip_daily else args.daily_from_date or args.from_date,
70
+ activity_from_date=None if args.skip_activities else args.activity_from_date or args.from_date,
71
+ to_date=args.to_date,
72
+ daily_to_date=args.daily_to_date,
73
+ activity_to_date=args.activity_to_date,
74
+ include_non_historical=not args.historical_only,
75
+ )
76
+ except WorkerInterrupted:
77
+ reporter.finish("interrupted")
78
+ raise SystemExit(130 if interrupt_signal == signal.SIGINT else 143)
79
+ reporter.finish("completed")
59
80
 
60
81
 
61
82
  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)