catence 0.2.0-beta.10 → 0.2.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { normalizeProgress, } from '../../contracts/progress.js';
5
+ export const PROGRESS_SIDECAR_SUFFIX = '.progress.json';
6
+ export const PROGRESS_SIDECAR_STALE_MS = 5 * 60 * 1000;
7
+ export const PROGRESS_KEEPALIVE_MS = 30 * 1000;
8
+ export const PROGRESS_PUBLISH_THROTTLE_MS = 2 * 1000;
9
+ const TERMINAL_STAGES = new Set(['completed', 'failed', 'interrupted', 'timed_out']);
10
+ export function progressSidecarPath(paths, runId) {
11
+ return path.join(paths.staging, 'garmin', `${runId}${PROGRESS_SIDECAR_SUFFIX}`);
12
+ }
13
+ /**
14
+ * Persist one progress heartbeat as a sidecar file next to the Garmin staging
15
+ * manifest. Reading this file needs no DuckDB lock, so `progress` stays live
16
+ * while a sync run holds the single-writer database lock.
17
+ */
18
+ export async function writeProgressSidecar(paths, runId, state) {
19
+ const target = progressSidecarPath(paths, runId);
20
+ await mkdir(path.dirname(target), { recursive: true });
21
+ const temporary = `${target}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.tmp`;
22
+ await writeFile(temporary, JSON.stringify(state));
23
+ await rename(temporary, target);
24
+ }
25
+ export async function removeProgressSidecar(paths, runId) {
26
+ await rm(progressSidecarPath(paths, runId), { force: true });
27
+ }
28
+ /**
29
+ * Scan every persisted sidecar heartbeat for an athlete store. Corrupt or
30
+ * unreadable files are skipped so a torn write can never break progress reads.
31
+ */
32
+ export async function readProgressSidecars(paths) {
33
+ const directory = path.join(paths.staging, 'garmin');
34
+ if (!existsSync(directory))
35
+ return [];
36
+ const files = (await readdir(directory)).filter((file) => file.endsWith(PROGRESS_SIDECAR_SUFFIX));
37
+ const states = [];
38
+ for (const file of files) {
39
+ try {
40
+ const state = normalizeProgress(JSON.parse(await readFile(path.join(directory, file), 'utf8')));
41
+ if (state)
42
+ states.push(state);
43
+ }
44
+ catch {
45
+ // Torn or partial sidecar write; it is not authoritative.
46
+ }
47
+ }
48
+ return states;
49
+ }
50
+ /** Active runs from sidecars: non-terminal stage and a fresh heartbeat. */
51
+ export async function readRunningProgress(paths, staleAfterMs = PROGRESS_SIDECAR_STALE_MS) {
52
+ const now = Date.now();
53
+ const running = [];
54
+ for (const state of await readProgressSidecars(paths)) {
55
+ if (TERMINAL_STAGES.has(state.stage))
56
+ continue;
57
+ const ageMs = now - Date.parse(state.heartbeatAt);
58
+ if (!Number.isFinite(ageMs) || ageMs > staleAfterMs)
59
+ continue;
60
+ running.push(state);
61
+ }
62
+ return running;
63
+ }
64
+ /**
65
+ * Merge database-backed progress with sidecar heartbeats. Sidecars win for a
66
+ * given run (they are freshest); database rows fill runs that never emitted a
67
+ * sidecar (for example Intervals runs). Recent history is deduplicated across
68
+ * both sources and sorted by heartbeat recency.
69
+ */
70
+ export function mergeProgress(dbRunning, dbRecent, sidecars) {
71
+ const running = new Map();
72
+ for (const state of sidecars)
73
+ running.set(state.runId, state);
74
+ for (const state of dbRunning)
75
+ if (!running.has(state.runId))
76
+ running.set(state.runId, state);
77
+ const recent = new Map();
78
+ for (const state of dbRecent)
79
+ recent.set(state.runId, state);
80
+ for (const state of sidecars)
81
+ recent.set(state.runId, state);
82
+ return {
83
+ running: [...running.values()],
84
+ recent: [...recent.values()]
85
+ .sort((a, b) => Date.parse(b.heartbeatAt) - Date.parse(a.heartbeatAt))
86
+ .slice(0, 10),
87
+ };
88
+ }
89
+ function baselineState(runId, provider, stage) {
90
+ return {
91
+ runId,
92
+ provider,
93
+ stage,
94
+ currentStep: null,
95
+ completedUnits: 0,
96
+ totalUnits: null,
97
+ percentComplete: 0,
98
+ elapsedSeconds: 0,
99
+ estimatedRemainingSeconds: null,
100
+ heartbeatAt: new Date().toISOString(),
101
+ };
102
+ }
103
+ /**
104
+ * Drives progress heartbeats for the whole lifetime of a sync run - including
105
+ * phases that emit no worker output (the Intervals extractor and the Garmin
106
+ * JSONL import). `publish` throttles writes to the database and sidecar; a
107
+ * 30-second keep-alive interval refreshes the last known state so a run never
108
+ * looks stale while it is still active, even during the lock-holding import.
109
+ */
110
+ export class ProgressPump {
111
+ runId;
112
+ provider;
113
+ options;
114
+ lastProgress = null;
115
+ lastHeartbeatAt = 0;
116
+ interval = null;
117
+ stageOverride = null;
118
+ constructor(runId, provider, options) {
119
+ this.runId = runId;
120
+ this.provider = provider;
121
+ this.options = options;
122
+ }
123
+ start() {
124
+ if (this.interval)
125
+ return;
126
+ this.interval = setInterval(() => {
127
+ this.publish(this.lastProgress
128
+ ? { ...this.lastProgress, heartbeatAt: new Date().toISOString() }
129
+ : baselineState(this.runId, this.provider, this.stageOverride ?? 'starting'));
130
+ }, PROGRESS_KEEPALIVE_MS);
131
+ this.interval.unref();
132
+ }
133
+ publish(state) {
134
+ const now = Date.now();
135
+ if (this.lastHeartbeatAt !== 0 && now - this.lastHeartbeatAt < PROGRESS_PUBLISH_THROTTLE_MS) {
136
+ this.lastProgress = state;
137
+ return;
138
+ }
139
+ this.lastHeartbeatAt = now;
140
+ this.lastProgress = state;
141
+ const { database, paths, log } = this.options;
142
+ void database.heartbeatRun(this.runId, state).catch((error) => {
143
+ log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
144
+ });
145
+ void writeProgressSidecar(paths, this.runId, state).catch((error) => {
146
+ log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
147
+ });
148
+ }
149
+ /** Force subsequent keep-alive heartbeats to report the given stage. */
150
+ setStage(stage) {
151
+ this.stageOverride = stage;
152
+ if (this.lastProgress) {
153
+ this.lastProgress = { ...this.lastProgress, stage };
154
+ this.publish(this.lastProgress);
155
+ }
156
+ }
157
+ /** Transition the run to the JSONL import stage; keep-alives persist it. */
158
+ setStageImporting() {
159
+ this.setStage('importing');
160
+ }
161
+ /** Write a terminal heartbeat immediately, bypassing the publish throttle. */
162
+ publishFinal(state) {
163
+ this.lastHeartbeatAt = Date.now();
164
+ this.lastProgress = state;
165
+ const { database, paths, log } = this.options;
166
+ void database.heartbeatRun(this.runId, state).catch((error) => {
167
+ log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
168
+ });
169
+ void writeProgressSidecar(paths, this.runId, state).catch((error) => {
170
+ log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
171
+ });
172
+ }
173
+ stop() {
174
+ if (this.interval) {
175
+ clearInterval(this.interval);
176
+ this.interval = null;
177
+ }
178
+ }
179
+ }
180
+ //# sourceMappingURL=progress-sidecar.js.map
@@ -87,7 +87,17 @@ The query layer has four shared pieces:
87
87
 
88
88
  ### 4. Serving and retrieval
89
89
 
90
- The server exposes `catence_status`, `describe_data`, `describe_dataset`, `read_series`, `aggregate_data`, `analyze_series`, `fit_series_model`, `query_read_only_data`, `find_activities`, `get_ftp_history`, `get_vo2max_history`, `power_curve_trend`, `power_coverage_report`, `latest_cycling_activities`, `cycling_progress_report`, `review_daily_recovery_load`, `review_weekly_training`, `review_activity_deep_dive`, `search_context`, `hydrate_strava_activity`, `hydrate_recent_strava_activities`, and `hydrate_strava_segment_history`, plus resources for status, catalog, a single activity, and a date-range summary.
90
+ The server exposes these tools, grouped by purpose:
91
+
92
+ - Catalog and status: `list_athletes`, `catence_status`, `catence_sync_progress`.
93
+ - Review workflows: `review_daily_recovery_load`, `review_weekly_training`, `review_activity_deep_dive`, plus the matching `daily_recovery_load_review`, `weekly_training_review`, and `activity_deep_dive` prompts.
94
+ - General data and analytics: `describe_data`, `describe_dataset`, `read_series`, `aggregate_data`, `analyze_series`, `fit_series_model`, `query_read_only_data`, `search_context`.
95
+ - Wellness: `wellness_correlate`, `wellness_baselines`, `wellness_anomalies`, `wellness_coverage`.
96
+ - Fitness and progress: `get_ftp_history`, `get_vo2max_history`, `power_curve_trend`, `power_coverage_report`, `latest_cycling_activities`, `cycling_progress_report`, `get_swim_laps`, `swim_progress_report`.
97
+ - Activity detail and segments: `find_activities`, `get_activity_segments`.
98
+ - Strava hydration (the only writers): `hydrate_strava_activity`, `hydrate_recent_strava_activities`, `hydrate_strava_segment_history`.
99
+
100
+ Resources are mode-dependent. In catalog mode the server registers only `athletes` (`catence://athletes`), the machine-readable roster of configured athlete IDs and labels; in a single-store (non-catalog) deployment it registers resources for status, catalog, a single activity template (`catence://activity/{activityId}`), and a date-range summary template (`catence://summary/{startDate}/{endDate}`).
91
101
 
92
102
  `catence serve` is a loopback-only-by-default Streamable HTTP adapter. It creates a stateless MCP server and transport for each `/mcp` request, so the HTTP and stdio surfaces use the same tool implementation. `GET /health` is a versioned client handshake with runtime version, protocol version, and capabilities; distributed Console builds reject an incompatible protocol before starting a chat. The same listener serves a small read-only `/api/v1/dashboard` snapshot for the local Console: canonical daily-health rows, weekly canonical activity aggregates, recent canonical activities, status, coverage, and explicit missing-data caveats.
93
103
 
@@ -0,0 +1,221 @@
1
+ # Catence configuration reference
2
+
3
+ This is the complete schema for `config.json` in the data home
4
+ (`~/.catence/config.json` locally, `/data/config.json` in Docker). It is
5
+ validated by the runtime before every run: unknown fields or invalid values
6
+ produce an `Invalid Catence config at <path>: …` error. A missing or empty
7
+ file is treated as `{}` (no rate limits, no budget, no Console profiles).
8
+
9
+ The file is shared between the runtime (MCP rate limits, Strava budget) and
10
+ the Console (model profiles and limits). It deliberately contains **no
11
+ credential values** — only the *names* of environment variables that hold the
12
+ values. See [`config.example.json`](../config.example.json) for a complete
13
+ worked example and [`llm-providers.md`](llm-providers.md) for the provider
14
+ profile how-to.
15
+
16
+ ## Top-level sections
17
+
18
+ ```jsonc
19
+ {
20
+ "mcp": { "rateLimits": { … } }, // optional
21
+ "providers": { "strava": { "budget": { … } } }, // optional
22
+ "console": { "defaultProfile": …, "limits": { … }, "profiles": { … } } // optional
23
+ }
24
+ ```
25
+
26
+ Every section is optional and strict: the schema rejects unknown top-level
27
+ keys and unknown keys inside each section.
28
+
29
+ ## `mcp.rateLimits`
30
+
31
+ Limits the MCP server's sliding-window limiter. Each entry is
32
+ `{ "requests": <positive int>, "windowSeconds": <positive int> }` or `null`.
33
+
34
+ ```jsonc
35
+ {
36
+ "mcp": {
37
+ "rateLimits": {
38
+ "server": { "requests": 240, "windowSeconds": 60 },
39
+ "tools": {
40
+ "*": { "requests": 60, "windowSeconds": 60 },
41
+ "hydrate_strava_activity": { "requests": 6, "windowSeconds": 60 },
42
+ "hydrate_recent_strava_activities": { "requests": 1, "windowSeconds": 60 },
43
+ "hydrate_strava_segment_history": { "requests": 4, "windowSeconds": 60 }
44
+ },
45
+ "resources": {
46
+ "*": { "requests": 120, "windowSeconds": 60 }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ - `tools` keys are tool names; `resources` keys are resource names; `*` is the
54
+ catch-all default for that kind.
55
+ - A specific entry overrides `*`, which overrides `server`.
56
+ - **`null` disables the limit** for that specific tool/resource name (or for
57
+ the whole server when set on `server`). This is the escape hatch for
58
+ long-running hydration tools.
59
+ - Limits are enforced **per athlete**: each athlete gets its own window
60
+ (keyed `athleteId:tool:<name>` / `athleteId:resource:<name>`).
61
+
62
+ ## `providers.strava.budget`
63
+
64
+ Throttles Strava API usage during sync and hydration.
65
+
66
+ ```jsonc
67
+ {
68
+ "providers": {
69
+ "strava": {
70
+ "budget": {
71
+ "maxConcurrentRequests": 1,
72
+ "readRequestsPer15Minutes": 80,
73
+ "readRequestsPerDay": 800
74
+ }
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ - `readRequestsPer15Minutes` and `readRequestsPerDay` are enforced: a sync or
81
+ hydration throws a retryable `rate_limited` / `StravaRateLimitError` when
82
+ the rolling usage reaches either threshold, and when Strava itself reports a
83
+ `blocked_until` timestamp.
84
+ - `maxConcurrentRequests` is validated and documented but **not yet enforced**
85
+ by the current sync/hydration worker (hydration already runs serially); it
86
+ is reserved for a future concurrent worker. Set each value to `null` to
87
+ disable that particular check.
88
+
89
+ ## `console`
90
+
91
+ The Console model configuration. `defaultProfile` must name one of
92
+ `profiles`.
93
+
94
+ ```jsonc
95
+ {
96
+ "console": {
97
+ "defaultProfile": "openai",
98
+ "limits": { "toolRounds": 8, "toolResultCharacters": 24000 },
99
+ "profiles": { … }
100
+ }
101
+ }
102
+ ```
103
+
104
+ ### `console.limits`
105
+
106
+ | Field | Default | Range |
107
+ | --- | --- | --- |
108
+ | `toolRounds` | 8 | 1–32 — max model tool-calling iterations per chat turn |
109
+ | `toolResultCharacters` | 24000 | 1,000–250,000 — max characters of a tool result passed to the model (truncated past the limit) |
110
+
111
+ ### `console.profiles`
112
+
113
+ Each profile holds one set of credentials and one or more model deployments:
114
+
115
+ ```jsonc
116
+ "profiles": {
117
+ "openai": {
118
+ "label": "OpenAI",
119
+ "model": "openai/gpt-5-mini",
120
+ "apiKeyEnv": "OPENAI_API_KEY"
121
+ }
122
+ }
123
+ ```
124
+
125
+ Allowed profile fields (strict):
126
+
127
+ | Field | Required | Meaning |
128
+ | --- | --- | --- |
129
+ | `label` | — | Display name in the Model dropdown |
130
+ | `model` | exactly one of `model`/`models` | Single LiteLLM model name, e.g. `openai/gpt-5-mini` |
131
+ | `models` | exactly one of `model`/`models` | Map of deployment id → per-model object for multiple deployments |
132
+ | `defaultModel` | with `models` | Which deployment id is preselected; must name one of `models` (defaults to the first) |
133
+ | `defaultReasoningEffort` | — | One of `minimal`, `low`, `medium`, `high`, `xhigh` |
134
+ | `apiKeyEnv` | — | Name of the env var holding the API key |
135
+ | `apiBaseEnv` | — | Name of the env var holding the base URL (OpenAI-compatible endpoints) |
136
+ | `apiVersionEnv` | — | Name of the env var holding an API version, for endpoints that require one |
137
+
138
+ Every `*Env` value must be a valid uppercase environment-variable name
139
+ (`/^[A-Z][A-Z0-9_]*$/`); a raw secret there is rejected. Credentials are read
140
+ from the environment only, never persisted.
141
+
142
+ Per-model objects (under `models`):
143
+
144
+ | Field | Meaning |
145
+ | --- | --- |
146
+ | `label` | Display name |
147
+ | `model` | LiteLLM model name, e.g. `openai/deepseek-v4-flash-free` |
148
+ | `reasoningEffort` | Fixed reasoning-effort level for every request |
149
+ | `variants` | Map of dropdown label → exact value sent to the provider (non-OpenAI schemes); `{}` disables reasoning effort for the model |
150
+
151
+ Validation rules:
152
+
153
+ - A profile must define **exactly one** of `model` or `models`.
154
+ - `defaultModel` requires `models` and must name one of its keys.
155
+ - `defaultReasoningEffort` must be one of the OpenAI-standard levels.
156
+ - A model cannot combine `variants: {}` with a fixed `reasoningEffort`.
157
+ - `console.profiles` must be non-empty; `models` must be non-empty.
158
+
159
+ Precedence for reasoning effort: per-chat dropdown selection > model
160
+ `reasoningEffort` > profile `defaultReasoningEffort` > provider default. See
161
+ [`llm-providers.md`](llm-providers.md#reasoning-effort) for the full behavior.
162
+
163
+ ### The Console wizard
164
+
165
+ On the first authenticated chat, the Console wizard (`write_provider_setup`)
166
+ offers presets for `openai-compatible`, `openai`, or `anthropic`. It writes a
167
+ single-profile `console` section and **replaces the whole `console` section**,
168
+ so re-running it overwrites any hand-edited profiles. The discovery scripts
169
+ (`discover:opencode-go`, `discover:opencode-zen`) instead **merge** their
170
+ profiles and preserve existing ones, limits, and `defaultProfile` unless
171
+ `--set-default` is passed.
172
+
173
+ ## Environment variables
174
+
175
+ ### Catalog home and runtime
176
+
177
+ | Variable | Purpose |
178
+ | --- | --- |
179
+ | `CATENCE_HOME` | Catalog home (default `~/.catence`); equivalent to `--home` |
180
+ | `CATENCE_HTTP_HOST` / `CATENCE_HTTP_PORT` | Defaults for `catence serve --host/--port` (host `127.0.0.1`, port `8787`) |
181
+
182
+ Provider credentials are normally stored per athlete with
183
+ `catence-data secret set` (see [`local-mcp.md`](deployment/local-mcp.md#3-store-provider-credentials-stdin-only-never-shell-history)); those secrets are injected
184
+ into the provider worker environment, and inherited `GARMIN_*`/`INTERVALS_*`/
185
+ `STRAVA_*` variables are stripped first so an athlete without a provider cannot
186
+ inherit its credentials.
187
+
188
+ ### Console auth and serving
189
+
190
+ | Variable | Purpose |
191
+ | --- | --- |
192
+ | `CATENCE_CONSOLE_USERNAME` | Console login username (required) |
193
+ | `CATENCE_CONSOLE_PASSWORD_HASH` | bcrypt hash for the login password (required; `catence-console auth hash-password`) |
194
+ | `CHAINLIT_AUTH_SECRET` | JWT signing secret for the Console session (required) |
195
+ | `CATENCE_HOME` | Data home served by the Console (default `~/.catence`) |
196
+ | `CATENCE_MCP_URL` | MCP endpoint for the Console (default `http://127.0.0.1:8787/mcp`; when unset the Console auto-starts a runtime) |
197
+ | `CATENCE_CONSOLE_HOST` | Default for `--ui-host` (`127.0.0.1`) |
198
+
199
+ ### Model providers
200
+
201
+ Each env var name is referenced by a profile's `apiKeyEnv` / `apiBaseEnv` /
202
+ `apiVersionEnv`; see [`llm-providers.md`](llm-providers.md) for the full
203
+ provider guide. Common ones from [`config.example.json`](../config.example.json):
204
+
205
+ | Variable | Profile |
206
+ | --- | --- |
207
+ | `OPENAI_API_KEY`, `OPENAI_API_BASE`, `OPENAI_API_VERSION` | `openai`, `openai-compatible`, Azure, Opencode proxy |
208
+ | `ANTHROPIC_API_KEY` | `anthropic` |
209
+ | `OPENCODE_GO_API_KEY`, `OPENCODE_GO_API_BASE`, `OPENCODE_GO_MESSAGES_API_BASE` | `opencode-go`, `opencode-go-messages` |
210
+ | `OPENCODE_API_KEY`, `OPENCODE_ZEN_API_BASE`, `OPENCODE_ZEN_RESPONSES_API_BASE` | `opencode-zen`, `opencode-zen-responses` |
211
+
212
+ ### Docker (`.env`, generated by `deploy-console.sh`)
213
+
214
+ | Variable | Purpose |
215
+ | --- | --- |
216
+ | `CATENCE_NPM_VERSION` | npm package version to install in the image |
217
+ | `CATENCE_CONSOLE_VERSION` | PyPI `catence-console` version (PEP 440) |
218
+ | `CATENCE_CONSOLE_PORT` | Host port for the Console (default 8000) |
219
+ | `CATENCE_MCP_BIND` | MCP bind: `127.0.0.1` loopback (default) or `0.0.0.0` for network/Tailscale exposure; also drives the compose port mapping |
220
+
221
+ See [`docker.md`](deployment/docker.md) for the Docker workflow.