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
package/README.md CHANGED
@@ -7,9 +7,9 @@ Catence is a local-first fitness MCP server for Garmin, Intervals.icu, and targe
7
7
  ## What is included
8
8
 
9
9
  - Garmin, Intervals.icu, and Strava ingestion with source-aware normalization.
10
- - Read-only MCP tools, plus explicitly named, lock-guarded Strava hydration tools.
10
+ - Read-only MCP tools, plus explicitly named, lock-guarded write tools for Strava hydration and detached syncs.
11
11
  - One shared Catence agent can serve several isolated athlete stores. Every personal-data MCP call names an `athleteId`; Catence never silently combines athletes.
12
- - A password-protected Chainlit Console with an authenticated dashboard.
12
+ - A password-protected Chainlit Console with an authenticated dashboard, a data-sync button with live progress, and in-app model management.
13
13
  - A generated demo catalog for safe evaluation in Glama, desktop MCP clients, or local development.
14
14
 
15
15
  Catence helps explore recovery, training load, trends, activity detail, swimming/cycling/running progress, segments, gear, and data quality. It reports the evidence and coverage it can see; it does not diagnose or prescribe training.
@@ -24,12 +24,14 @@ Catence helps explore recovery, training load, trends, activity detail, swimming
24
24
 
25
25
  Install Catence and create the first athlete. The default catalog home is `~/.catence`; set `CATENCE_HOME` or pass `--home` to use a different location.
26
26
 
27
+ Every `catence-data` command requires `--athlete <id>`; there is no default athlete. Pair it with `--home <dir>` to select the catalog when not using the default home.
28
+
27
29
  ```sh
28
- npm install --global catence@0.2.0
30
+ npm install --global catence@beta # or catence@latest for stable
29
31
  catence-data setup --athlete alex --label "Alex"
30
32
  ```
31
33
 
32
- Store each provider value through stdin so it does not enter shell history. Values are written to the selected athlete’s owner-only local secret file.
34
+ Store each provider value through stdin so it does not enter shell history. Values are written to the selected athlete's owner-only local secret file.
33
35
 
34
36
  ```sh
35
37
  printf %s 'alex@example.com' | catence-data --athlete alex secret set --provider garmin --field email --value-stdin
@@ -63,49 +65,42 @@ catence-data --athlete sam sync --provider garmin
63
65
  catence-data athlete list
64
66
  ```
65
67
 
66
- The catalog layout is deliberately simple:
67
-
68
- ```text
69
- ~/.catence/
70
- catalog.json
71
- config.json # shared Console configuration; no secrets
72
- console/chat-history.sqlite3
73
- athletes/
74
- alex/ # isolated DuckDB, raw data, and tokens
75
- secrets/providers.json # mode 0600
76
- sam/
77
- ...
78
- ```
79
-
80
- Garmin, Intervals, and Strava client credentials are isolated per athlete. Strava OAuth tokens remain in that athlete’s own store. The old 0.1 single-store directory cannot be migrated safely: create a fresh home and re-sync each athlete.
68
+ Garmin, Intervals, and Strava client credentials are isolated per athlete. Strava OAuth tokens remain in that athlete's own store. The old 0.1 single-store directory cannot be migrated safely: create a fresh home and re-sync each athlete.
81
69
 
82
70
  ### Common operations
83
71
 
84
72
  ```sh
85
- # Incremental or explicit data work for one athlete
86
73
  catence-data --athlete alex status
87
74
  catence-data --athlete alex sync --provider intervals
88
75
  catence-data --athlete alex sync --provider garmin --from 2025-07-29
89
76
  catence-data --athlete alex backfill --provider garmin --from 2020-01-01 --refresh
90
77
  catence-data --athlete alex retry --run <run-id>
91
-
92
- # Strava OAuth uses clientId and clientSecret previously stored for this athlete
78
+ catence-data --athlete alex progress --watch
93
79
  catence-data --athlete alex auth strava --callback
94
80
  catence-data --athlete alex disconnect strava
95
-
96
- # Choose a non-default catalog location
97
- catence-data --home /srv/catence setup --athlete alex --label "Alex"
98
- catence --home /srv/catence
81
+ catence-data update --check
82
+ catence-data update
99
83
  ```
100
84
 
101
- Register `http://127.0.0.1:8765/strava/callback` with the Strava application for the callback flow. The manual `auth strava --code <authorization-code>` flow remains available for headless environments.
85
+ ## Documentation
86
+
87
+ | Document | Covers |
88
+ | --- | --- |
89
+ | [Deployment: local MCP](docs/deployment/local-mcp.md) | Install, catalog, secrets, sync, background progress, stdio/HTTP MCP, demo, common operations |
90
+ | [Deployment: local Console](docs/deployment/local-console.md) | Local web chat setup, model config, doctor, updates, troubleshooting |
91
+ | [Deployment: Docker](docs/deployment/docker.md) | One-container stack: deploy script, `.env`, sync.sh, MCP exposure (SSH/Tailscale), Cloudflare Tunnel |
92
+ | [Configuration](docs/configuration.md) | Complete `config.json` schema: rate limits, Strava budget, Console profiles, environment variables |
93
+ | [LLM providers](docs/llm-providers.md) | Model providers (OpenAI, Anthropic, OpenAI-compatible/Azure, Opencode Go/Zen), reasoning effort |
94
+ | [Architecture](docs/architecture.md) | Design rationale, layers, data flow, serving |
95
+ | [MCP activity retrieval](docs/mcp-activity-retrieval.md) | Implementation contract for answering questions, current tools, planned endpoints |
96
+ | [Distribution](docs/distribution.md) | Release artifacts: npm, PyPI, APM, MCPB |
102
97
 
103
98
  ## Safe demo and Glama
104
99
 
105
100
  Run a clearly marked generated dataset without provider accounts:
106
101
 
107
102
  ```sh
108
- npx --yes catence@0.2.0 demo
103
+ npx --yes catence@beta demo
109
104
  ```
110
105
 
111
106
  It creates or reuses `~/.catence-demo` with one `demo` athlete. The generated data has explicit caveats in every tool response and never contains personal measurements. The Glama registry entry uses this command for **Try in Browser**, so the hosted sandbox has no access to local credentials or personal data.
@@ -137,58 +132,11 @@ Optional local Streamable HTTP MCP and dashboard APIs:
137
132
  catence serve --host 127.0.0.1 --port 8787
138
133
  ```
139
134
 
140
- `GET /api/v1/athletes` returns IDs and labels only. `GET /api/v1/dashboard` requires `athleteId`, for example `http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28`. Browser origins must be listed with `--allow-origin`; the packaged Console instead proxies the dashboard through its authenticated same-origin route.
141
-
142
- ## Catence Console
143
-
144
- The Console is a local web chat that starts a matching Catence HTTP server on loopback. It uses Chainlit password login and fails closed unless all three authentication variables are supplied.
145
-
146
- ```sh
147
- export CATENCE_HOME="$HOME/.catence"
148
- export CATENCE_CONSOLE_USERNAME='coach'
149
- export CATENCE_CONSOLE_PASSWORD_HASH="$(catence-console auth hash-password)"
150
- export CHAINLIT_AUTH_SECRET="$(openssl rand -hex 32)"
151
- export OPENAI_API_KEY='…'
152
-
153
- uvx catence-console@0.2.0 serve
154
- ```
155
-
156
- On the first authenticated visit, the Console wizard asks for an OpenAI-compatible, OpenAI, or Anthropic model and writes only the non-secret model configuration to `~/.catence/config.json`. The `openai-compatible` option covers Azure and Opencode; see [`docs/llm-providers.md`](docs/llm-providers.md). Model-provider keys remain process environment variables. The Console settings include an athlete selector; the server overwrites model-supplied athlete scope with that selected value on every personal-data call.
157
-
158
- To preflight the Console and a running runtime:
159
-
160
- ```sh
161
- uvx catence-console@0.2.0 doctor --home "$HOME/.catence" --mcp-url http://127.0.0.1:8787/mcp
162
- ```
163
-
164
- Console chats and saved tool context are local at `~/.catence/console/chat-history.sqlite3`.
165
-
166
- ### Docker and Cloudflare Tunnel
167
-
168
- The Console image contains the Node runtime, Catence, and Console. Its MCP server remains loopback-only inside the container; only the Console port is exposed.
169
-
170
- ```sh
171
- docker build -f console/Dockerfile -t catence-console .
172
- docker run --rm -p 127.0.0.1:8000:8000 -v catence-data:/data \
173
- -e CATENCE_CONSOLE_USERNAME='coach' \
174
- -e CATENCE_CONSOLE_PASSWORD_HASH='replace-with-bcrypt-hash' \
175
- -e CHAINLIT_AUTH_SECRET='replace-with-a-long-random-secret' \
176
- -e OPENAI_API_KEY='…' \
177
- catence-console
178
- ```
179
-
180
- Initialize the mounted volume before its first production sync, for example:
181
-
182
- ```sh
183
- docker run --rm -it -v catence-data:/data --entrypoint catence-data catence-console \
184
- setup --athlete alex --label "Alex"
185
- ```
186
-
187
- Use the same `--entrypoint catence-data` pattern for `secret set` and `sync`. Route a Cloudflare Tunnel to `http://127.0.0.1:8000`; do not run a tunnel client in this image. Use HTTPS at the tunnel and retain the Console login—the dashboard is fetched through the authenticated Console origin rather than directly from port 8787.
135
+ `GET /api/v1/athletes` returns IDs and labels only. `GET /api/v1/dashboard` requires `athleteId`, for example `http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28`. Browser origins must be listed with `--allow-origin`; the packaged Console instead proxies the dashboard through its authenticated same-origin route. The MCP server has no authentication of its own — keep it on loopback or restrict it at the network layer (see [Docker MCP exposure](docs/deployment/docker.md#connecting-to-the-docker-based-mcp-from-outside) for the remote-access patterns).
188
136
 
189
137
  ## Configuration
190
138
 
191
- [`config.example.json`](config.example.json) documents rate limits, Strava budgets, and Console model profiles. It intentionally contains environment-variable names rather than credential values. Keep per-athlete provider values in `catence-data secret set`, not in `.env` or `config.json`.
139
+ [`config.example.json`](config.example.json) documents rate limits, Strava budgets, and Console model profiles. It intentionally contains environment-variable names rather than credential values. Keep per-athlete provider values in `catence-data secret set`, not in `.env` or `config.json`. The full `config.json` schema — including per-model reasoning effort — is documented in [`docs/configuration.md`](docs/configuration.md) and [`docs/llm-providers.md`](docs/llm-providers.md).
192
140
 
193
141
  For source development:
194
142
 
@@ -201,9 +149,7 @@ UV_CACHE_DIR=$PWD/.cache/uv uv run --project console --group dev python -m pytes
201
149
 
202
150
  ## Beta releases
203
151
 
204
- Registry betas use distinct npm and Python prerelease formats: for example,
205
- `catence@0.2.0-beta.1` pairs with `catence-console==0.2.0b1` and
206
- `catence-chainlit==0.2.0b1`. The protected `beta` branch verifies candidates;
152
+ Registry betas use distinct npm, console, and UI-runtime prerelease formats. Each `catence@0.2.0-beta.N` npm release maps to a `catence-console==0.2.0bN` wheel, but the console depends on a range of `catence-chainlit` (the UI runtime), so the `catence-chainlit` version does not need to match the npm or console version. For example, `catence@0.2.0-beta.1` pairs with `catence-console==0.2.0b1` and can use any compatible `catence-chainlit` (such as `0.2.0b1`). The protected `beta` branch verifies candidates;
207
153
  a `v0.2.0-beta.1` tag publishes to npm's `beta` channel and creates a GitHub
208
154
  prerelease. Follow [the beta release checklist](release/beta-checklist.md) to
209
155
  publish the UI wheel first, deploy the registry artifacts with Compose, and
@@ -212,6 +158,6 @@ data.
212
158
 
213
159
  ## Data and caveats
214
160
 
215
- Garmin is the primary source for activity and wellness data; Intervals.icu supplements training analysis; Strava is used for targeted segments, efforts, and gear. Missing fields mean a provider did not supply a value. The source credentials and source APIs determine the data available. Catence does not use the official Garmin API; Garmin sync relies on the selected athlete’s email/password credentials.
161
+ Garmin is the primary source for activity and wellness data; Intervals.icu supplements training analysis; Strava is used for targeted segments, efforts, and gear. Missing fields mean a provider did not supply a value. The source credentials and source APIs determine the data available. Catence does not use the official Garmin API; Garmin sync relies on the selected athlete's email/password credentials.
216
162
 
217
- See [distribution notes](docs/distribution.md) for release artifacts, including APM and MCPB demo bundles.
163
+ See [distribution notes](docs/distribution.md) for release artifacts, including APM and MCPB demo bundles.
@@ -44,6 +44,31 @@
44
44
  "label": "Anthropic",
45
45
  "model": "anthropic/claude-sonnet-4-5",
46
46
  "apiKeyEnv": "ANTHROPIC_API_KEY"
47
+ },
48
+ "opencode-zen": {
49
+ "label": "OpenCode Zen",
50
+ "defaultModel": "deepseek-v4-flash-free",
51
+ "models": {
52
+ "deepseek-v4-flash-free": {
53
+ "label": "DeepSeek V4 Flash Free",
54
+ "model": "openai/deepseek-v4-flash-free",
55
+ "variants": { "High": "high", "Max": "max" }
56
+ },
57
+ "nemotron-3-ultra-free": { "label": "Nemotron 3 Ultra Free", "model": "openai/nemotron-3-ultra-free" },
58
+ "big-pickle": { "label": "Big Pickle", "model": "openai/big-pickle" }
59
+ },
60
+ "apiKeyEnv": "OPENCODE_API_KEY",
61
+ "apiBaseEnv": "OPENCODE_ZEN_API_BASE"
62
+ },
63
+ "opencode-zen-responses": {
64
+ "label": "OpenCode Zen (Responses)",
65
+ "defaultModel": "gpt-5.6-luna",
66
+ "models": {
67
+ "gpt-5.6-luna": { "label": "GPT 5.6 Luna", "model": "openai/responses/gpt-5.6-luna" },
68
+ "gpt-5.6-terra": { "label": "GPT 5.6 Terra", "model": "openai/responses/gpt-5.6-terra" }
69
+ },
70
+ "apiKeyEnv": "OPENCODE_API_KEY",
71
+ "apiBaseEnv": "OPENCODE_ZEN_RESPONSES_API_BASE"
47
72
  }
48
73
  }
49
74
  }
@@ -1,3 +1,4 @@
1
+ export * from './progress.js';
1
2
  export * from './runtime.js';
2
3
  export * from './release.js';
3
4
  export * from './staging.js';
@@ -1,3 +1,4 @@
1
+ export * from './progress.js';
1
2
  export * from './runtime.js';
2
3
  export * from './release.js';
3
4
  export * from './staging.js';
@@ -0,0 +1,23 @@
1
+ export declare const STALE_RUN_TIMEOUT_MS: number;
2
+ export declare const SYNC_STAGES: readonly ["starting", "login", "singletons", "daily", "range", "ftp_history", "max_metrics", "hrv_history", "scores", "activities", "collections", "intervals", "importing", "completed", "interrupted", "failed", "timed_out"];
3
+ export type SyncStage = (typeof SYNC_STAGES)[number];
4
+ export interface SyncProgressState {
5
+ runId: string;
6
+ provider: string;
7
+ stage: SyncStage;
8
+ currentStep: string | null;
9
+ completedUnits: number;
10
+ totalUnits: number | null;
11
+ percentComplete: number;
12
+ elapsedSeconds: number;
13
+ estimatedRemainingSeconds: number | null;
14
+ heartbeatAt: string;
15
+ }
16
+ export interface SyncProgressSnapshot {
17
+ running: SyncProgressState[];
18
+ recent: SyncProgressState[];
19
+ }
20
+ export interface InterruptedRuns {
21
+ runIds: string[];
22
+ }
23
+ export declare function normalizeProgress(value: unknown): SyncProgressState | null;
@@ -0,0 +1,49 @@
1
+ export const STALE_RUN_TIMEOUT_MS = 15 * 60 * 1000;
2
+ export const SYNC_STAGES = [
3
+ 'starting',
4
+ 'login',
5
+ 'singletons',
6
+ 'daily',
7
+ 'range',
8
+ 'ftp_history',
9
+ 'max_metrics',
10
+ 'hrv_history',
11
+ 'scores',
12
+ 'activities',
13
+ 'collections',
14
+ 'intervals',
15
+ 'importing',
16
+ 'completed',
17
+ 'interrupted',
18
+ 'failed',
19
+ 'timed_out',
20
+ ];
21
+ export function normalizeProgress(value) {
22
+ if (typeof value !== 'object' || value === null) {
23
+ return null;
24
+ }
25
+ const record = value;
26
+ const runId = typeof record.runId === 'string' ? record.runId : '';
27
+ const provider = typeof record.provider === 'string' ? record.provider : '';
28
+ if (!runId || !provider) {
29
+ return null;
30
+ }
31
+ const stage = SYNC_STAGES.includes(record.stage)
32
+ ? record.stage
33
+ : 'starting';
34
+ const numberOr = (value, fallback) => typeof value === 'number' && Number.isFinite(value) ? value : fallback;
35
+ const roundOrNull = (value) => typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.round(value)) : null;
36
+ return {
37
+ runId,
38
+ provider,
39
+ stage,
40
+ currentStep: typeof record.currentStep === 'string' ? record.currentStep : null,
41
+ completedUnits: Math.max(0, Math.round(numberOr(record.completedUnits, 0))),
42
+ totalUnits: roundOrNull(record.totalUnits),
43
+ percentComplete: Math.min(100, Math.max(0, numberOr(record.percentComplete, 0))),
44
+ elapsedSeconds: Math.max(0, numberOr(record.elapsedSeconds, 0)),
45
+ estimatedRemainingSeconds: roundOrNull(record.estimatedRemainingSeconds),
46
+ heartbeatAt: typeof record.heartbeatAt === 'string' ? record.heartbeatAt : new Date().toISOString(),
47
+ };
48
+ }
49
+ //# sourceMappingURL=progress.js.map
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.2";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.21";
7
7
  export declare const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export type CatenceRuntimeCapabilities = {
9
9
  mcp: true;
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.2';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.21';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -0,0 +1,16 @@
1
+ import type { CatencePaths } from '../contracts/runtime.js';
2
+ export type SyncLogger = {
3
+ info: (message: string, context?: Record<string, unknown>) => void;
4
+ warn: (message: string, context?: Record<string, unknown>) => void;
5
+ error: (message: string, context?: Record<string, unknown>) => void;
6
+ debug: (message: string, context?: Record<string, unknown>) => void;
7
+ };
8
+ /**
9
+ * Resolve the sync log file path.
10
+ *
11
+ * `CATENCE_LOG` may be an absolute path or a path relative to the catalog home.
12
+ * When unset (the default), logs go to stderr only so the console stays clean
13
+ * and stdout remains machine-readable for callers like sync.sh.
14
+ */
15
+ export declare function resolveLogFile(paths: CatencePaths, environment?: NodeJS.ProcessEnv): string | null;
16
+ export declare function createSyncLogger(paths: CatencePaths, environment?: NodeJS.ProcessEnv): SyncLogger;
@@ -0,0 +1,68 @@
1
+ import { appendFile, mkdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const LEVEL_WEIGHT = { debug: 10, info: 20, warn: 30, error: 40 };
4
+ function configuredLevel(environment = process.env) {
5
+ const raw = (environment.CATENCE_LOG_LEVEL ?? 'info').toLowerCase();
6
+ if (raw === 'debug')
7
+ return LEVEL_WEIGHT.debug;
8
+ if (raw === 'warn')
9
+ return LEVEL_WEIGHT.warn;
10
+ if (raw === 'error')
11
+ return LEVEL_WEIGHT.error;
12
+ return LEVEL_WEIGHT.info;
13
+ }
14
+ /**
15
+ * Resolve the sync log file path.
16
+ *
17
+ * `CATENCE_LOG` may be an absolute path or a path relative to the catalog home.
18
+ * When unset (the default), logs go to stderr only so the console stays clean
19
+ * and stdout remains machine-readable for callers like sync.sh.
20
+ */
21
+ export function resolveLogFile(paths, environment = process.env) {
22
+ const configured = environment.CATENCE_LOG;
23
+ if (!configured)
24
+ return null;
25
+ return path.isAbsolute(configured) ? configured : path.join(paths.root, configured);
26
+ }
27
+ export function createSyncLogger(paths, environment = process.env) {
28
+ const threshold = configuredLevel(environment);
29
+ const logFile = resolveLogFile(paths, environment);
30
+ const write = (level, message, context) => {
31
+ if (LEVEL_WEIGHT[level] < threshold)
32
+ return;
33
+ const line = formatLogLine(level, message, context);
34
+ process.stderr.write(`${line}\n`);
35
+ if (logFile) {
36
+ // Fire-and-forget: never block a sync run on log persistence.
37
+ appendLogLine(logFile, `${line}\n`).catch(() => { });
38
+ }
39
+ };
40
+ return {
41
+ info: (message, context) => write('info', message, context),
42
+ warn: (message, context) => write('warn', message, context),
43
+ error: (message, context) => write('error', message, context),
44
+ debug: (message, context) => write('debug', message, context),
45
+ };
46
+ }
47
+ function formatLogLine(level, message, context) {
48
+ const timestamp = new Date().toISOString();
49
+ const tag = level.toUpperCase().padEnd(5);
50
+ let line = `[${timestamp}] [${tag}] ${message}`;
51
+ if (context && Object.keys(context).length > 0) {
52
+ line += ` ${serializeContext(context)}`;
53
+ }
54
+ return line;
55
+ }
56
+ function serializeContext(context) {
57
+ try {
58
+ return JSON.stringify(context);
59
+ }
60
+ catch {
61
+ return '';
62
+ }
63
+ }
64
+ let appendLogLine = async (file, line) => {
65
+ await mkdir(path.dirname(file), { recursive: true });
66
+ await appendFile(file, line, 'utf8');
67
+ };
68
+ //# sourceMappingURL=logging.js.map
@@ -1,4 +1,5 @@
1
1
  import type { CatencePaths } from '../../contracts/runtime.js';
2
+ import { type SyncProgressSnapshot } from '../../contracts/progress.js';
2
3
  import type { QueryValues, ReadDataStore } from '../../contracts/storage.js';
3
4
  import { type DatasetDefinition } from './catalog.js';
4
5
  export declare class ReadOnlyRepository {
@@ -10,6 +11,7 @@ export declare class ReadOnlyRepository {
10
11
  datasetRelation(datasetName: string): Promise<string>;
11
12
  activitySampleRelation(): Promise<string>;
12
13
  status(): Promise<Record<string, unknown>>;
14
+ progress(): Promise<SyncProgressSnapshot>;
13
15
  coverage(): Promise<Record<string, unknown>>;
14
16
  activity(activityId: string): Promise<Record<string, unknown> | null>;
15
17
  summary(startDate: string, endDate: string): Promise<Record<string, unknown>>;
@@ -1,5 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { SYNC_STAGES } from '../../contracts/progress.js';
4
+ import { mergeProgress, readRunningProgress } from '../../elt/storage/progress-sidecar.js';
3
5
  import { sqlString } from './sql.js';
4
6
  import { DATASET_CATALOG, getDataset } from './catalog.js';
5
7
  export class ReadOnlyRepository {
@@ -137,6 +139,38 @@ export class ReadOnlyRepository {
137
139
  incrementalCursors: cursors,
138
140
  };
139
141
  }
142
+ async progress() {
143
+ // Same sequential-read discipline as status(): the shared native
144
+ // connection must not run these small reads concurrently.
145
+ const running = await this.rows(`
146
+ SELECT runs.run_id, runs.provider, coalesce(progress.stage, 'starting') AS stage,
147
+ progress.current_step,
148
+ coalesce(progress.completed_units, 0) AS completed_units,
149
+ progress.total_units,
150
+ coalesce(progress.percent, 0) AS percent,
151
+ coalesce(progress.elapsed_seconds, 0) AS elapsed_seconds,
152
+ progress.estimated_remaining_seconds,
153
+ cast(coalesce(progress.heartbeat_at, runs.started_at) AS VARCHAR) AS heartbeat_at
154
+ FROM sync_runs AS runs
155
+ LEFT JOIN sync_run_progress AS progress USING (run_id)
156
+ WHERE runs.status = 'running'
157
+ ORDER BY runs.started_at DESC
158
+ `);
159
+ const recent = await this.rows(`
160
+ SELECT run_id, provider, stage, current_step, completed_units, total_units, percent,
161
+ elapsed_seconds, estimated_remaining_seconds,
162
+ cast(heartbeat_at AS VARCHAR) AS heartbeat_at
163
+ FROM sync_run_progress
164
+ ORDER BY heartbeat_at DESC
165
+ LIMIT 10
166
+ `);
167
+ // Merge with sidecar heartbeat files so an active Garmin run stays visible
168
+ // even while the same process holds the single-writer database lock. The
169
+ // filtered reader guarantees a terminal or stale heartbeat can never read
170
+ // as an active run again, so a finished sync cannot block new ones.
171
+ const runningSidecars = await readRunningProgress(this.paths);
172
+ return mergeProgress(running.map(toSyncProgressState), recent.map(toSyncProgressState), runningSidecars);
173
+ }
140
174
  async coverage() {
141
175
  const rows = await this.rows(`
142
176
  SELECT 'activities' AS dataset, min(cast(started_at_utc AS DATE)) AS start_date, max(cast(started_at_utc AS DATE)) AS end_date, count(*)::INTEGER AS row_count FROM activities
@@ -166,6 +200,20 @@ export class ReadOnlyRepository {
166
200
  return Object.values(DATASET_CATALOG);
167
201
  }
168
202
  }
203
+ function toSyncProgressState(row) {
204
+ return {
205
+ runId: row.run_id,
206
+ provider: row.provider,
207
+ stage: SYNC_STAGES.includes(row.stage) ? row.stage : 'starting',
208
+ currentStep: row.current_step,
209
+ completedUnits: Math.max(0, Number(row.completed_units) || 0),
210
+ totalUnits: row.total_units === null || row.total_units === undefined ? null : Math.max(0, Number(row.total_units) || 0),
211
+ percentComplete: Math.min(100, Math.max(0, Number(row.percent) || 0)),
212
+ elapsedSeconds: Math.max(0, Number(row.elapsed_seconds) || 0),
213
+ estimatedRemainingSeconds: row.estimated_remaining_seconds === null || row.estimated_remaining_seconds === undefined ? null : Math.max(0, Number(row.estimated_remaining_seconds) || 0),
214
+ heartbeatAt: row.heartbeat_at,
215
+ };
216
+ }
169
217
  /** Converts DuckDB's Date/BigInt values into MCP-safe JSON values. */
170
218
  export function jsonSafe(value) {
171
219
  if (typeof value === 'bigint')
@@ -40,9 +40,8 @@ export function athleteStorePaths(catalogPaths, athleteId) {
40
40
  function legacyStoreMessage(paths) {
41
41
  return `Catence 0.2 uses a catalog at ${paths.root}. The existing directory looks like a 0.1 data store and cannot be migrated automatically. Create a fresh catalog and re-sync each athlete.`;
42
42
  }
43
- async function hasEntries(directory) {
44
- return (await readdir(directory).catch((error) => error.code === 'ENOENT' ? [] : Promise.reject(error))).length > 0;
45
- }
43
+ /** Home-directory entries the Console creates on its own and must not block catalog initialization. */
44
+ const CONSOLE_ARTIFACTS = new Set(['config.json', 'console', '.files', '.chainlit', 'public']);
46
45
  export async function loadCatalog(paths = resolveCatalogPaths()) {
47
46
  const contents = await readFile(paths.catalog, 'utf8').catch((error) => {
48
47
  if (error.code !== 'ENOENT')
@@ -68,10 +67,21 @@ async function writeCatalog(paths, catalog) {
68
67
  export async function initializeCatalog(paths = resolveCatalogPaths(), athlete) {
69
68
  if (existsSync(paths.catalog))
70
69
  return loadCatalog(paths);
71
- if (await hasEntries(paths.root)) {
72
- if (existsSync(path.join(paths.root, 'catence.duckdb')))
73
- throw new Error(legacyStoreMessage(paths));
74
- throw new Error(`Refusing to initialize ${paths.root}: choose an empty --home directory or remove its unrelated contents.`);
70
+ let entries;
71
+ try {
72
+ entries = await readdir(paths.root);
73
+ }
74
+ catch (error) {
75
+ if (error.code === 'ENOENT')
76
+ entries = [];
77
+ else
78
+ throw error;
79
+ }
80
+ if (entries.includes('catence.duckdb'))
81
+ throw new Error(legacyStoreMessage(paths));
82
+ const unrelated = entries.filter((entry) => !CONSOLE_ARTIFACTS.has(entry));
83
+ if (unrelated.length > 0) {
84
+ throw new Error(`Refusing to initialize ${paths.root}: found ${unrelated.join(', ')}. Remove these or choose an empty --home directory.`);
75
85
  }
76
86
  const id = athleteIdSchema.parse(athlete.id);
77
87
  const record = { id, label: athlete.label.trim(), createdAt: new Date().toISOString() };
@@ -12,7 +12,7 @@ declare const rateLimitSchema: z.ZodObject<{
12
12
  requests: z.ZodNumber;
13
13
  windowSeconds: z.ZodNumber;
14
14
  }, z.core.$strict>;
15
- declare const catenceConfigSchema: z.ZodObject<{
15
+ export declare const catenceConfigSchema: z.ZodObject<{
16
16
  mcp: z.ZodOptional<z.ZodObject<{
17
17
  rateLimits: z.ZodOptional<z.ZodObject<{
18
18
  server: z.ZodOptional<z.ZodNullable<z.ZodObject<{
@@ -50,6 +50,8 @@ declare const catenceConfigSchema: z.ZodObject<{
50
50
  models: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
51
51
  label: z.ZodOptional<z.ZodString>;
52
52
  model: z.ZodString;
53
+ reasoningEffort: z.ZodOptional<z.ZodString>;
54
+ variants: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
53
55
  }, z.core.$strict>>>;
54
56
  defaultModel: z.ZodOptional<z.ZodString>;
55
57
  defaultReasoningEffort: z.ZodOptional<z.ZodEnum<{
@@ -32,9 +32,13 @@ export function requireIntervalsConfig(environment = process.env) {
32
32
  const rateLimitSchema = z.object({ requests: z.number().int().positive(), windowSeconds: z.number().int().positive() }).strict();
33
33
  const nullableRateLimitSchema = rateLimitSchema.nullable();
34
34
  const environmentVariableSchema = z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'must be an uppercase environment-variable name');
35
+ // Keep this aligned with the Console's Python validator, which accepts a
36
+ // per-model reasoningEffort and free-form reasoning-effort variants.
35
37
  const consoleModelSchema = z.object({
36
38
  label: z.string().min(1).optional(),
37
39
  model: z.string().min(1),
40
+ reasoningEffort: z.string().min(1).optional(),
41
+ variants: z.record(z.string(), z.string()).optional(),
38
42
  }).strict();
39
43
  const consoleProfileSchema = z.object({
40
44
  label: z.string().min(1).optional(),
@@ -60,7 +64,7 @@ const consoleLimitsSchema = z.object({
60
64
  toolRounds: z.number().int().min(1).max(32).optional(),
61
65
  toolResultCharacters: z.number().int().min(1_000).max(250_000).optional(),
62
66
  }).strict();
63
- const catenceConfigSchema = z.object({
67
+ export const catenceConfigSchema = z.object({
64
68
  mcp: z.object({
65
69
  rateLimits: z.object({
66
70
  server: nullableRateLimitSchema.optional(),
@@ -0,0 +1,58 @@
1
+ export declare const OPENCODE_GO_DEFAULT_BASE_URL = "https://opencode.ai/zen/go/v1";
2
+ export declare const OPENCODE_GO_API_KEY_ENV = "OPENCODE_GO_API_KEY";
3
+ export declare const OPENCODE_GO_API_BASE_ENV = "OPENCODE_GO_API_BASE";
4
+ export declare const OPENCODE_GO_MESSAGES_API_BASE_ENV = "OPENCODE_GO_MESSAGES_API_BASE";
5
+ export type OpenCodeGoRoute = 'chat' | 'responses' | 'messages';
6
+ /** Live model ids plus trusted routing metadata ("live discovery + trusted metadata"). */
7
+ export declare function classifyOpenCodeGoModel(id: string): {
8
+ route: OpenCodeGoRoute;
9
+ guessed: boolean;
10
+ };
11
+ export type OpenCodeGoConsoleProfiles = {
12
+ profiles: Record<string, Record<string, unknown>>;
13
+ defaultProfile: string;
14
+ counts: {
15
+ chat: number;
16
+ responses: number;
17
+ messages: number;
18
+ };
19
+ guessedRoutes: string[];
20
+ };
21
+ /**
22
+ * Build the two ready-made Console profiles from live model ids.
23
+ *
24
+ * The public `GET /models` endpoint returns only ids, not which API each model
25
+ * needs, so routing comes from the trusted table intersected with the live
26
+ * list; prefix-guessed routes are reported so callers can warn.
27
+ */
28
+ export declare function buildOpenCodeGoConsoleProfiles(modelIds: readonly string[], baseUrl?: string): OpenCodeGoConsoleProfiles;
29
+ export declare function fetchOpenCodeGoModelIds(baseUrl?: string): Promise<string[]>;
30
+ export type OpenCodeGoMergeResult = {
31
+ configPath: string;
32
+ mergedProfileIds: string[];
33
+ defaultProfile: string;
34
+ counts: {
35
+ chat: number;
36
+ responses: number;
37
+ messages: number;
38
+ };
39
+ guessedRoutes: string[];
40
+ /** Model ids removed from the managed profiles because they left the live catalog. */
41
+ prunedModelIds?: string[];
42
+ };
43
+ /**
44
+ * Fetch the live OpenCode Go catalog and merge the two Console profiles into
45
+ * `configPath`, preserving every other section plus existing console fields
46
+ * (`limits`, other profiles) and the existing `defaultProfile` unless
47
+ * `setDefault` is passed. Model routing (`model`) is refreshed from the live
48
+ * catalog, per-model customizations (custom label, `reasoningEffort`,
49
+ * `variants`) survive for models still in the catalog, and models that
50
+ * disappeared upstream are pruned so the Console never offers stale routes.
51
+ * The written file is re-validated with the same strict schema the runtime
52
+ * applies on load, then replaced atomically.
53
+ */
54
+ export declare function mergeOpenCodeGoConsoleProfiles(options: {
55
+ configPath: string;
56
+ baseUrl?: string;
57
+ setDefault?: boolean;
58
+ }): Promise<OpenCodeGoMergeResult>;