catence 0.2.0-beta.1 → 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 (38) hide show
  1. package/README.md +144 -2
  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.js +17 -7
  14. package/dist/core/runtime/self-update.d.ts +44 -0
  15. package/dist/core/runtime/self-update.js +138 -0
  16. package/dist/elt/application/management.d.ts +2 -0
  17. package/dist/elt/application/management.js +194 -17
  18. package/dist/elt/ingestion/importer.d.ts +3 -2
  19. package/dist/elt/ingestion/importer.js +4 -3
  20. package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
  21. package/dist/elt/ingestion/providers/intervals.js +4 -1
  22. package/dist/elt/ingestion/providers/strava/service.js +7 -1
  23. package/dist/elt/storage/database.d.ts +7 -2
  24. package/dist/elt/storage/database.js +81 -4
  25. package/dist/elt/storage/migrations.js +25 -1
  26. package/dist/interfaces/cli/commands.js +91 -1
  27. package/dist/interfaces/mcp/server.js +7 -0
  28. package/dist/runtime/index.d.ts +4 -1
  29. package/dist/runtime/index.js +2 -1
  30. package/docs/deployment.md +338 -0
  31. package/docs/docker-deployment.md +232 -0
  32. package/docs/llm-providers.md +467 -0
  33. package/package.json +5 -1
  34. package/python/catence/providers/garmin/cli.py +30 -9
  35. package/python/catence/providers/garmin/progress.py +85 -0
  36. package/python/catence/providers/garmin/worker.py +132 -5
  37. package/scripts/discover-opencode-go.mjs +210 -0
  38. package/scripts/discover-opencode-zen.mjs +218 -0
@@ -2,6 +2,7 @@ import { DuckDBInstance } from '@duckdb/node-api';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { ReadOnlyRepository } from '../../core/query/repository.js';
5
+ import { SYNC_STAGES, STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
5
6
  import { migrations } from './migrations.js';
6
7
  import { json } from './sql.js';
7
8
  export class CatenceDatabase {
@@ -81,6 +82,66 @@ export class CatenceDatabase {
81
82
  await this.run(`UPDATE sync_runs SET status = CASE WHEN error_count > 0 THEN 'completed_with_errors' ELSE 'completed' END, completed_at = now() WHERE run_id = $runId`, { runId });
82
83
  await this.run("UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context'");
83
84
  }
85
+ async heartbeatRun(runId, progress) {
86
+ await this.run(`INSERT INTO sync_run_progress (run_id, provider, stage, current_step, completed_units, total_units, percent, elapsed_seconds, estimated_remaining_seconds, heartbeat_at, detail_json)
87
+ VALUES ($runId, $provider, $stage, $currentStep, $completedUnits, $totalUnits, $percent, $elapsedSeconds, $estimatedRemainingSeconds, now(), '{}')
88
+ ON CONFLICT (run_id) DO UPDATE SET
89
+ provider = EXCLUDED.provider,
90
+ stage = EXCLUDED.stage,
91
+ current_step = EXCLUDED.current_step,
92
+ completed_units = EXCLUDED.completed_units,
93
+ total_units = EXCLUDED.total_units,
94
+ percent = EXCLUDED.percent,
95
+ elapsed_seconds = EXCLUDED.elapsed_seconds,
96
+ estimated_remaining_seconds = EXCLUDED.estimated_remaining_seconds,
97
+ heartbeat_at = now(),
98
+ detail_json = '{}'`, {
99
+ runId,
100
+ provider: progress.provider,
101
+ stage: progress.stage,
102
+ currentStep: progress.currentStep,
103
+ completedUnits: progress.completedUnits,
104
+ totalUnits: progress.totalUnits,
105
+ percent: progress.percentComplete,
106
+ elapsedSeconds: progress.elapsedSeconds,
107
+ estimatedRemainingSeconds: progress.estimatedRemainingSeconds,
108
+ });
109
+ }
110
+ async markRunInterrupted(runId, stage = 'interrupted') {
111
+ await this.run("UPDATE sync_runs SET status = 'interrupted', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId });
112
+ await this.run('UPDATE sync_run_progress SET stage = $stage, heartbeat_at = now() WHERE run_id = $runId', { runId, stage });
113
+ }
114
+ async interruptStaleSyncRuns(timeoutMs = STALE_RUN_TIMEOUT_MS) {
115
+ const cutoff = new Date(Date.now() - timeoutMs).toISOString();
116
+ const stale = await this.rows(`SELECT run_id FROM sync_runs WHERE status = 'running' AND started_at < $cutoff`, { cutoff });
117
+ for (const row of stale) {
118
+ await this.run("UPDATE sync_runs SET status = 'timed_out', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId: row.run_id });
119
+ await this.run("UPDATE sync_run_progress SET stage = 'timed_out', heartbeat_at = now() WHERE run_id = $runId", { runId: row.run_id });
120
+ }
121
+ return { runIds: stale.map((row) => row.run_id) };
122
+ }
123
+ async syncProgress() {
124
+ const running = await this.rows(`SELECT runs.run_id, runs.provider,
125
+ coalesce(progress.stage, 'starting') AS stage,
126
+ progress.current_step AS current_step,
127
+ coalesce(progress.completed_units, 0) AS completed_units,
128
+ progress.total_units AS total_units,
129
+ coalesce(progress.percent, 0) AS percent,
130
+ coalesce(progress.elapsed_seconds, 0) AS elapsed_seconds,
131
+ progress.estimated_remaining_seconds AS estimated_remaining_seconds,
132
+ cast(coalesce(progress.heartbeat_at, runs.started_at) AS VARCHAR) AS heartbeat_at
133
+ FROM sync_runs AS runs
134
+ LEFT JOIN sync_run_progress AS progress USING (run_id)
135
+ WHERE runs.status = 'running'
136
+ ORDER BY runs.started_at DESC`);
137
+ const recent = await this.rows(`SELECT progress.run_id, progress.provider, progress.stage, progress.current_step,
138
+ progress.completed_units, progress.total_units, progress.percent, progress.elapsed_seconds,
139
+ progress.estimated_remaining_seconds, cast(progress.heartbeat_at AS VARCHAR) AS heartbeat_at
140
+ FROM sync_run_progress AS progress
141
+ ORDER BY progress.heartbeat_at DESC
142
+ LIMIT 10`);
143
+ return { running: running.map(toSyncProgressState), recent: recent.map(toSyncProgressState) };
144
+ }
84
145
  async addError(runId, provider, endpoint, remoteId, message, retryable) {
85
146
  await this.run(`INSERT INTO normalization_errors (error_id, run_id, provider, endpoint, remote_id, message, retryable)
86
147
  VALUES ($errorId, $runId, $provider, $endpoint, $remoteId, $message, $retryable)`, { errorId: randomUUID(), runId, provider, endpoint, remoteId, message, retryable });
@@ -94,9 +155,11 @@ export class CatenceDatabase {
94
155
  const rawObjects = await this.rows('SELECT count(*)::INTEGER AS count FROM raw_objects');
95
156
  const activities = await this.rows('SELECT count(*)::INTEGER AS count FROM activities');
96
157
  const streams = await this.rows('SELECT count(*)::INTEGER AS count FROM stream_manifest');
158
+ const errorDetails = await this.rows('SELECT run_id, provider, endpoint, remote_id, message, cast(created_at AS VARCHAR) AS created_at FROM normalization_errors WHERE resolved_at IS NULL ORDER BY created_at DESC LIMIT 100');
97
159
  const cursors = await this.rows('SELECT provider, cursor_name, cast(covered_through_date AS VARCHAR) AS covered_through_date, cast(latest_source_date AS VARCHAR) AS latest_source_date, lookback_days, last_successful_run_id, cast(last_completed_at AS VARCHAR) AS last_completed_at, status FROM sync_cursors ORDER BY provider, cursor_name');
98
160
  return {
99
161
  runs, unresolvedErrors: errors[0]?.count ?? 0, rawObjects: rawObjects[0]?.count ?? 0, activities: activities[0]?.count ?? 0, streams: streams[0]?.count ?? 0,
162
+ errors: errorDetails,
100
163
  cursors: cursors.map((cursor) => ({ ...cursor, next_from_date: subtractDays(cursor.covered_through_date, Number(cursor.lookback_days)) })),
101
164
  };
102
165
  }
@@ -281,10 +344,6 @@ export class ReadOnlyCatenceDatabase {
281
344
  }
282
345
  }
283
346
  }
284
- /** Compose core read services with the DuckDB-backed, read-only adapter. */
285
- export async function openReadOnlyRepository(paths) {
286
- return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
287
- }
288
347
  export class ReadOnlyDatabaseError extends Error {
289
348
  code;
290
349
  constructor(code, message) {
@@ -293,4 +352,22 @@ export class ReadOnlyDatabaseError extends Error {
293
352
  this.name = 'ReadOnlyDatabaseError';
294
353
  }
295
354
  }
355
+ /** Compose core read services with the DuckDB-backed, read-only adapter. */
356
+ export async function openReadOnlyRepository(paths) {
357
+ return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
358
+ }
359
+ function toSyncProgressState(row) {
360
+ return {
361
+ runId: row.run_id,
362
+ provider: row.provider,
363
+ stage: SYNC_STAGES.includes(row.stage) ? row.stage : 'starting',
364
+ currentStep: row.current_step,
365
+ completedUnits: Math.max(0, Number(row.completed_units) || 0),
366
+ totalUnits: row.total_units === null || row.total_units === undefined ? null : Math.max(0, Number(row.total_units) || 0),
367
+ percentComplete: Math.min(100, Math.max(0, Number(row.percent) || 0)),
368
+ elapsedSeconds: Math.max(0, Number(row.elapsed_seconds) || 0),
369
+ estimatedRemainingSeconds: row.estimated_remaining_seconds === null || row.estimated_remaining_seconds === undefined ? null : Math.max(0, Number(row.estimated_remaining_seconds) || 0),
370
+ heartbeatAt: row.heartbeat_at,
371
+ };
372
+ }
296
373
  //# sourceMappingURL=database.js.map
@@ -828,5 +828,29 @@ const swimFactsAndQualityMigration = {
828
828
  UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
829
829
  `,
830
830
  };
831
- export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration];
831
+ const syncRunProgressMigration = {
832
+ version: 14,
833
+ name: 'sync_run_progress_heartbeats',
834
+ sql: `
835
+ -- Live progress heartbeats for in-flight sync runs. Written by the Node
836
+ -- side from progress records streamed by provider workers; read-only
837
+ -- surfaces (status, progress CLI, MCP) join against sync_runs.
838
+ CREATE TABLE IF NOT EXISTS sync_run_progress (
839
+ run_id VARCHAR PRIMARY KEY,
840
+ provider VARCHAR NOT NULL,
841
+ stage VARCHAR NOT NULL,
842
+ current_step VARCHAR,
843
+ completed_units INTEGER NOT NULL DEFAULT 0,
844
+ total_units INTEGER,
845
+ percent DOUBLE NOT NULL DEFAULT 0,
846
+ elapsed_seconds DOUBLE NOT NULL DEFAULT 0,
847
+ estimated_remaining_seconds DOUBLE,
848
+ heartbeat_at TIMESTAMPTZ NOT NULL,
849
+ detail_json JSON NOT NULL DEFAULT '{}'
850
+ );
851
+
852
+ UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
853
+ `,
854
+ };
855
+ export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration, syncRunProgressMigration];
832
856
  //# sourceMappingURL=migrations.js.map
@@ -2,7 +2,7 @@
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
4
  import { Command } from 'commander';
5
- import { addAthlete, athleteStorePaths, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, initializeCatalog, linkActivity, loadCatalog, rebuildRetrievalIndex, resolveAthlete, resolveCatalogPaths, retryDataSync, setAthleteSecret, syncData, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
5
+ import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
6
6
  const program = new Command()
7
7
  .name('catence-data')
8
8
  .description('Athlete-scoped Catence data setup, extraction, and normalization.')
@@ -158,6 +158,96 @@ program.command('status').action(async () => {
158
158
  const { paths } = await currentAthlete();
159
159
  process.stdout.write(`${JSON.stringify(await dataStatus(paths), null, 2)}\n`);
160
160
  });
161
+ function renderProgress(snapshot) {
162
+ const lines = snapshot.running.map((run) => {
163
+ const pct = run.percentComplete.toFixed(1).padStart(5);
164
+ const units = run.totalUnits !== null ? `${run.completedUnits}/${run.totalUnits}` : String(run.completedUnits);
165
+ const eta = run.estimatedRemainingSeconds !== null ? `${Math.round(run.estimatedRemainingSeconds)}s` : '?';
166
+ const step = run.currentStep ?? '';
167
+ return `${pct}% ${run.stage.padEnd(12)} ${units.padEnd(11)} ${eta.padEnd(6)} ${step}`;
168
+ });
169
+ return lines.length === 0 ? 'no active sync runs' : lines.join('\n');
170
+ }
171
+ program.command('progress')
172
+ .description('Show live progress for active sync runs.')
173
+ .option('--watch', 'refresh every few seconds until no sync runs are active')
174
+ .action(async (options) => {
175
+ const { paths } = await currentAthlete();
176
+ if (!options.watch) {
177
+ process.stdout.write(`${JSON.stringify(await syncProgress(paths), null, 2)}\n`);
178
+ return;
179
+ }
180
+ while (true) {
181
+ const snapshot = await syncProgress(paths);
182
+ if (output.isTTY) {
183
+ output.write(`\x1b[2J\x1b[H${renderProgress(snapshot)}\n`);
184
+ }
185
+ else {
186
+ process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`);
187
+ }
188
+ if (snapshot.running.length === 0)
189
+ break;
190
+ await new Promise((resolve) => setTimeout(resolve, 3000));
191
+ }
192
+ });
193
+ program.command('update')
194
+ .description('Check for and apply the newest Catence release on the tracked channel (runtime and Console).')
195
+ .option('--check', 'report available releases without changing anything')
196
+ .option('--channel <stable|beta>', 'release channel to track; defaults to the installed runtime channel')
197
+ .option('--runtime', 'only check or update the Catence runtime package')
198
+ .option('--console', 'only check or update the Catence Console package')
199
+ .action(async (options) => {
200
+ if (options.channel !== undefined && options.channel !== 'stable' && options.channel !== 'beta') {
201
+ throw new Error('--channel must be stable or beta.');
202
+ }
203
+ const channel = options.channel ?? channelForVersion(CATENCE_RUNTIME_VERSION);
204
+ const runtimeOnly = options.runtime ?? false;
205
+ const consoleOnly = options.console ?? false;
206
+ const updateRuntime = runtimeOnly || !consoleOnly;
207
+ const updateConsole = consoleOnly || !runtimeOnly;
208
+ const [npmDistTags, pypiReleases, consoleInstalled] = await Promise.all([
209
+ fetchNpmDistTags('catence'),
210
+ fetchPypiReleases('catence-console'),
211
+ readInstalledConsoleVersion(),
212
+ ]);
213
+ const plan = planSelfUpdate({ runtimeVersion: CATENCE_RUNTIME_VERSION, channel, npmDistTags, pypiReleases, consoleInstalled });
214
+ const report = { channel, runtime: plan.runtime, console: plan.console };
215
+ if (options.check) {
216
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
217
+ process.exit(plan.ok ? 0 : 1);
218
+ }
219
+ const applied = [];
220
+ if (plan.runtime.updateAvailable && updateRuntime) {
221
+ const runtimeTarget = plan.runtime.target;
222
+ if (runtimeTarget === undefined)
223
+ throw new Error('No runtime update target is available.');
224
+ const npmPrefix = await npmGlobalPrefix();
225
+ if (isGlobalNpmInstall(import.meta.url, npmPrefix)) {
226
+ const exitCode = await runSelfUpdateCommand('npm', ['install', '--global', `catence@${runtimeTarget}`]);
227
+ if (exitCode !== 0)
228
+ throw new Error(`npm failed to install catence@${runtimeTarget} (exit ${exitCode}).`);
229
+ applied.push(`runtime: catence@${runtimeTarget}`);
230
+ }
231
+ else {
232
+ process.stderr.write(`Runtime is not a global npm install; run: npm install --global catence@${runtimeTarget}\n`);
233
+ }
234
+ }
235
+ if (plan.console.updateAvailable && updateConsole) {
236
+ const consoleTarget = plan.console.target;
237
+ if (consoleTarget === undefined)
238
+ throw new Error('No Console update target is available.');
239
+ if (await hasCommand('uv')) {
240
+ const exitCode = await runSelfUpdateCommand('uv', ['tool', 'install', '--upgrade', `catence-console==${consoleTarget}`]);
241
+ if (exitCode !== 0)
242
+ throw new Error(`uv failed to install catence-console==${consoleTarget} (exit ${exitCode}).`);
243
+ applied.push(`console: catence-console==${consoleTarget}`);
244
+ }
245
+ else {
246
+ process.stderr.write(`uv is not installed; run: pip install --upgrade 'catence-console==${consoleTarget}'\n`);
247
+ }
248
+ }
249
+ process.stdout.write(`${JSON.stringify({ ...report, applied }, null, 2)}\n`);
250
+ });
161
251
  program.command('build-retrieval-index').description('Build derived local retrieval context for one athlete.').action(async () => {
162
252
  const { paths } = await currentAthlete();
163
253
  process.stdout.write(`${JSON.stringify(await rebuildRetrievalIndex(paths), null, 2)}\n`);
@@ -231,6 +231,13 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
231
231
  data: { ...(await repository.status()), ...(await repository.coverage()) },
232
232
  provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
233
233
  }))));
234
+ server.registerTool('catence_sync_progress', {
235
+ title: 'Catence sync progress',
236
+ description: 'Read live progress heartbeats for active sync runs, plus the most recent completed or interrupted runs. Read-only.',
237
+ }, tool('catence_sync_progress', async () => useRepository(activePaths(), async (repository) => ({
238
+ data: await repository.progress(),
239
+ provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
240
+ }))));
234
241
  server.registerTool('review_daily_recovery_load', {
235
242
  title: 'Review daily recovery and training load',
236
243
  description: 'Return source-cited daily health, training, and nutrition facts for a recovery/load review. It does not prescribe a training plan.',
@@ -7,6 +7,8 @@
7
7
  * without changing the public product adapters.
8
8
  */
9
9
  export type { CatencePaths } from '../contracts/runtime.js';
10
+ export { channelForVersion, compareReleaseVersions, fetchNpmDistTags, fetchPypiReleases, hasCommand, isGlobalNpmInstall, latestRelease, npmGlobalPrefix, parseReleaseVersion, planSelfUpdate, readInstalledConsoleVersion, runSelfUpdateCommand, } from '../core/runtime/self-update.js';
11
+ export type { SelfUpdatePlan, UpdateChannel } from '../core/runtime/self-update.js';
10
12
  export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth } from '../contracts/release.js';
11
13
  export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
12
14
  export { addAthlete, athleteStorePaths, defaultAthlete, defaultCatalogHome, initializeCatalog, loadCatalog, resolveAthlete, resolveCatalogPaths, } from '../core/runtime/catalog.js';
@@ -26,8 +28,9 @@ export { WELLNESS_METRICS, WellnessService } from '../core/query/wellness.js';
26
28
  export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
27
29
  export { queryReadOnlyData } from '../core/query/sql-guard.js';
28
30
  export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
29
- export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, unlinkActivity, } from '../elt/application/management.js';
31
+ export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
30
32
  export type { ProviderChoice } from '../elt/application/management.js';
33
+ export type { InterruptedRuns, SyncProgressSnapshot, SyncProgressState } from '../contracts/progress.js';
31
34
  export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
32
35
  export { DataWriteBusyError } from '../elt/storage/write-lock.js';
33
36
  export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
@@ -1,3 +1,4 @@
1
+ export { channelForVersion, compareReleaseVersions, fetchNpmDistTags, fetchPypiReleases, hasCommand, isGlobalNpmInstall, latestRelease, npmGlobalPrefix, parseReleaseVersion, planSelfUpdate, readInstalledConsoleVersion, runSelfUpdateCommand, } from '../core/runtime/self-update.js';
1
2
  export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth } from '../contracts/release.js';
2
3
  export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
3
4
  export { addAthlete, athleteStorePaths, defaultAthlete, defaultCatalogHome, initializeCatalog, loadCatalog, resolveAthlete, resolveCatalogPaths, } from '../core/runtime/catalog.js';
@@ -14,7 +15,7 @@ export { WELLNESS_METRICS, WellnessService } from '../core/query/wellness.js';
14
15
  export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
15
16
  export { queryReadOnlyData } from '../core/query/sql-guard.js';
16
17
  export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
17
- export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, unlinkActivity, } from '../elt/application/management.js';
18
+ export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
18
19
  export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
19
20
  export { DataWriteBusyError } from '../elt/storage/write-lock.js';
20
21
  export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
@@ -0,0 +1,338 @@
1
+ # Deploying Catence Console
2
+
3
+ Catence Console is a password-protected local web chat that starts a matching
4
+ Catence runtime on loopback. There are two supported ways to run it: **locally**
5
+ (npm package + Python Console on your machine) or **in Docker** (one container
6
+ built from the public registries). Both paths end with the same Console, data
7
+ directory layout, model configuration, and credentials model.
8
+
9
+ Everything below assumes a recent beta or stable release; channels are
10
+ explained in the [beta release notes](../README.md#beta-releases).
11
+
12
+ ## Prerequisites
13
+
14
+ - Node.js 22+ and Python 3.12+ with [uv](https://docs.astral.sh/uv/)
15
+ (local path), or Docker Engine with the `docker compose` plugin (Docker path).
16
+ - Provider credentials for the athletes you choose to sync (Garmin,
17
+ Intervals.icu, Strava).
18
+ - One model-provider key (OpenAI, Anthropic, OpenCode Go, Azure, or any
19
+ OpenAI-compatible endpoint).
20
+
21
+ ## What gets stored where
22
+
23
+ | What | Where | Example |
24
+ | --- | --- | --- |
25
+ | Catalog + athlete stores | data home | `~/.catence/` or `/data` in Docker |
26
+ | Model profiles (no secrets) | `config.json` in the data home | `console.profiles` |
27
+ | Provider credentials | per-athlete secret file, mode 0600 | `catence-data secret set` |
28
+ | API keys | process environment only | `.env` in Docker, shell exports locally |
29
+ | Chat history + preferences | data home | `console/chat-history.sqlite3` |
30
+
31
+ Credentials never land in `config.json`; profiles reference environment
32
+ variable *names* (`apiKeyEnv`, `apiBaseEnv`, `apiVersionEnv`). See
33
+ [`llm-providers.md`](llm-providers.md) for the profile reference and
34
+ [`config.example.json`](../config.example.json) for a complete example.
35
+
36
+ ---
37
+
38
+ ## Path A — Local deployment
39
+
40
+ ### 1. Install the runtime and the Console
41
+
42
+ ```sh
43
+ npm install --global catence@beta # or catence@latest for stable
44
+ uv tool install catence-console # persistent `catence-console` command
45
+ # alternative to the last line: uvx catence-console@<version> serve (ephemeral)
46
+ ```
47
+
48
+ `catence-console serve` launches a matching Catence runtime automatically: it
49
+ uses a globally installed `catence` if present, otherwise `npx catence@<pinned>`.
50
+
51
+ ### 2. Create the catalog and first athlete
52
+
53
+ ```sh
54
+ catence-data setup --athlete alex --label "Alex"
55
+ ```
56
+
57
+ This creates `~/.catence/` (or `$CATENCE_HOME`). It also tolerates a directory
58
+ that already holds only Console artifacts (`config.json`, `console/`).
59
+
60
+ ### 3. Store provider credentials (stdin only, never shell history)
61
+
62
+ Garmin (`email`, `password`), Intervals.icu (`apiKey`, `athleteId`), and Strava
63
+ (`clientId`, `clientSecret`):
64
+
65
+ ```sh
66
+ # Garmin
67
+ printf %s 'alex@example.com' | catence-data --athlete alex secret set --provider garmin --field email --value-stdin
68
+ printf %s 'your-garmin-password' | catence-data --athlete alex secret set --provider garmin --field password --value-stdin
69
+ # Intervals.icu
70
+ printf %s 'intervals-api-key' | catence-data --athlete alex secret set --provider intervals --field apiKey --value-stdin
71
+ printf %s '12345' | catence-data --athlete alex secret set --provider intervals --field athleteId --value-stdin
72
+ # Strava (create an API application at https://www.strava.com/settings/api)
73
+ printf %s 'strava-client-id' | catence-data --athlete alex secret set --provider strava --field clientId --value-stdin
74
+ printf %s 'strava-client-secret' | catence-data --athlete alex secret set --provider strava --field clientSecret --value-stdin
75
+ ```
76
+
77
+ Set only the providers you actually sync; `secret set` accepts each field
78
+ independently.
79
+
80
+ ### 4. Sync data and build retrieval context
81
+
82
+ ```sh
83
+ catence-data --athlete alex sync --provider all
84
+ catence-data --athlete alex build-retrieval-index
85
+ ```
86
+
87
+ ### 5. Configure the model
88
+
89
+ On the first chat, the Console wizard asks for a provider and model and writes
90
+ a starter profile. To start from the documented profiles instead, copy the
91
+ `console` section from [`config.example.json`](../config.example.json) into
92
+ `~/.catence/config.json`. To add OpenCode Go models, run the
93
+ [model discovery script](#model-discovery-opencode-go) below.
94
+
95
+ ### 6. Set the model credentials and Console login
96
+
97
+ ```sh
98
+ export OPENAI_API_KEY='…' # or ANTHROPIC_API_KEY / OPENCODE_GO_API_KEY …
99
+ export CATENCE_CONSOLE_USERNAME='coach'
100
+ export CATENCE_CONSOLE_PASSWORD_HASH="$(catence-console auth hash-password)"
101
+ export CHAINLIT_AUTH_SECRET="$(openssl rand -hex 32)"
102
+ ```
103
+
104
+ ### 7. Start the Console
105
+
106
+ ```sh
107
+ catence-console serve
108
+ # open http://127.0.0.1:8000
109
+ ```
110
+
111
+ The Model dropdown in the settings panel lists every profile's deployments.
112
+ Preflight with:
113
+
114
+ ```sh
115
+ catence-console doctor
116
+ ```
117
+
118
+ ---
119
+
120
+ ## Path B — Docker deployment
121
+
122
+ ### 1. Deploy with the script
123
+
124
+ ```sh
125
+ curl -fsSL https://raw.githubusercontent.com/rifusaki/catence/beta/scripts/deploy-console.sh | bash -s beta
126
+ # or from a checkout: ./scripts/deploy-console.sh beta
127
+ # use `stable` instead of `beta` for the stable channel
128
+ ```
129
+
130
+ The script resolves the newest matching versions from npm and PyPI, writes a
131
+ self-contained `catence-deploy/` project (Dockerfile, docker-compose.yml, and
132
+ `.env` with placeholders), builds the image, **seeds a starter
133
+ `/data/config.json`** into the data volume, and starts the stack — or, when
134
+ secrets are missing, prints the remaining steps and exits.
135
+
136
+ ### 2. Fill in `catence-deploy/.env`
137
+
138
+ ```sh
139
+ CATENCE_CONSOLE_USERNAME=coach
140
+ CATENCE_CONSOLE_PASSWORD_HASH='…' # generate: ./catence-deploy/hash-password.sh
141
+ CHAINLIT_AUTH_SECRET='…' # generate: openssl rand -hex 32
142
+ OPENAI_API_KEY='…' # or ANTHROPIC_API_KEY / OPENCODE_GO_API_KEY …
143
+ OPENAI_API_BASE='' # only for OpenAI-compatible endpoints
144
+ OPENCODE_GO_API_KEY='…' # the OpenCode Go base URLs are pre-filled below
145
+ OPENCODE_GO_API_BASE='https://opencode.ai/zen/go/v1'
146
+ OPENCODE_GO_MESSAGES_API_BASE='https://opencode.ai/zen/go'
147
+ ```
148
+
149
+ Then start the stack (or re-run the script, which re-reads the saved `.env`):
150
+
151
+ ```sh
152
+ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env up -d
153
+ ```
154
+
155
+ ### 3. Initialize an athlete store
156
+
157
+ The data volume already contains a seeded `config.json`, so `setup` accepts
158
+ it. Run through the container:
159
+
160
+ ```sh
161
+ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm \
162
+ --entrypoint catence-data console setup --athlete alex --label "Alex"
163
+ ```
164
+
165
+ ### 4. Store provider credentials
166
+
167
+ Garmin (`email`, `password`), Intervals.icu (`apiKey`, `athleteId`), and Strava
168
+ (`clientId`, `clientSecret`), one `printf` per field:
169
+
170
+ ```sh
171
+ # Garmin
172
+ printf %s 'alex@example.com' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
173
+ --entrypoint catence-data console --athlete alex secret set \
174
+ --provider garmin --field email --value-stdin
175
+ printf %s 'your-garmin-password' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
176
+ --entrypoint catence-data console --athlete alex secret set \
177
+ --provider garmin --field password --value-stdin
178
+ # Intervals.icu
179
+ printf %s 'intervals-api-key' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
180
+ --entrypoint catence-data console --athlete alex secret set \
181
+ --provider intervals --field apiKey --value-stdin
182
+ printf %s '12345' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
183
+ --entrypoint catence-data console --athlete alex secret set \
184
+ --provider intervals --field athleteId --value-stdin
185
+ # Strava (create an API application at https://www.strava.com/settings/api)
186
+ printf %s 'strava-client-id' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
187
+ --entrypoint catence-data console --athlete alex secret set \
188
+ --provider strava --field clientId --value-stdin
189
+ printf %s 'strava-client-secret' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
190
+ --entrypoint catence-data console --athlete alex secret set \
191
+ --provider strava --field clientSecret --value-stdin
192
+ ```
193
+
194
+ Set only the providers you actually sync. Use the same `--entrypoint
195
+ catence-data` pattern for `sync`, `backfill`, and `auth strava`.
196
+
197
+ ### 5. Configure the model
198
+
199
+ The seeded `config.json` already offers OpenAI, OpenAI-compatible, and
200
+ Anthropic starter profiles, so the settings panel works before the first chat.
201
+ Edit `/data/config.json` inside the volume:
202
+
203
+ ```sh
204
+ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm \
205
+ --entrypoint sh console -c 'cat > /data/config.json' # paste JSON, then Ctrl-D
206
+ ```
207
+
208
+ With `--home <dir>` (bind mount), edit `<dir>/config.json` on the host
209
+ instead. To add OpenCode Go models, see [model discovery](#model-discovery-opencode-go).
210
+
211
+ ### 6. Verify with doctor
212
+
213
+ `catence-console doctor` checks each profile's environment variables and the
214
+ local Catence runtime. The deploy scaffold includes a helper for it:
215
+
216
+ ```sh
217
+ ./catence-deploy/doctor.sh
218
+ ```
219
+
220
+ The helper runs the doctor *inside the running container* with `exec` — the
221
+ container's own loopback holds the live MCP server at `127.0.0.1:8787`, and the
222
+ provider keys from `.env` are already in its environment. (A `docker compose
223
+ run` container has a separate loopback and would report the runtime as
224
+ unreachable.) The equivalent long form is:
225
+
226
+ ```sh
227
+ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env exec console \
228
+ /opt/catence-console/bin/catence-console doctor --home /data --mcp-url http://127.0.0.1:8787/mcp
229
+ ```
230
+
231
+ Expect `"ready": true` once the model key is set; missing keys show up per
232
+ profile without ever printing their values.
233
+
234
+ ### 7. Sync, backfill, and inspect athlete data
235
+
236
+ The deploy scaffold includes a helper that runs `catence-data` *inside the
237
+ running container* with `exec`, so the athlete's provider secrets from the
238
+ data volume are already available:
239
+
240
+ ```sh
241
+ ./catence-deploy/sync.sh --athlete alex sync # incremental sync, all providers
242
+ ./catence-deploy/sync.sh --athlete alex backfill 2026-01-01 # full backfill from a date
243
+ ./catence-deploy/sync.sh --athlete alex status # coverage and errors
244
+ ./catence-deploy/sync.sh athletes # list athlete IDs
245
+ ```
246
+
247
+ `sync` defaults to `--provider all`; extra flags pass through, for example
248
+ `./catence-deploy/sync.sh --athlete alex sync --refresh` or
249
+ `./catence-deploy/sync.sh --athlete alex backfill 2026-01-01 --provider garmin`.
250
+ Set a default athlete with `export CATENCE_ATHLETE=alex` to drop the
251
+ `--athlete` argument. The equivalent long form is:
252
+
253
+ ```sh
254
+ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env exec console \
255
+ catence-data sync --athlete alex --provider all --home /data
256
+ ```
257
+
258
+ ### 8. Expose the Console safely
259
+
260
+ Keep the Console port bound to `127.0.0.1` (the default) and front it with a
261
+ Cloudflare Tunnel or reverse proxy at HTTPS. Never expose Catence's MCP port
262
+ 8787 — it stays loopback-only inside the container. For remote MCP checks use
263
+ SSH port forwarding (`ssh -L 8787:127.0.0.1:8787 server`).
264
+
265
+ ---
266
+
267
+ ## Model discovery (OpenCode Go)
268
+
269
+ OpenCode Go publishes an OpenAI-compatible API at
270
+ `https://opencode.ai/zen/go/v1`; its model list is public. The discovery
271
+ script fetches it and merges two ready-made profiles into `config.json`:
272
+ `opencode-go` (chat + responses models, base `…/zen/go/v1`) and
273
+ `opencode-go-messages` (messages models, base `…/zen/go`). Existing profiles,
274
+ limits, and `defaultProfile` are preserved unless `--set-default` is passed.
275
+
276
+ ```sh
277
+ # Local (from a checkout)
278
+ npm run discover:opencode-go -- --write ~/.catence/config.json
279
+
280
+ # Docker (the script ships inside the npm package in the image)
281
+ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm \
282
+ --entrypoint node console \
283
+ /usr/local/lib/node_modules/catence/scripts/discover-opencode-go.mjs --write /data/config.json
284
+ ```
285
+
286
+ Then set the credentials and verify. The generated `.env` already contains
287
+ `OPENCODE_GO_API_BASE` and `OPENCODE_GO_MESSAGES_API_BASE`, so on Docker only
288
+ the key is missing:
289
+
290
+ ```sh
291
+ export OPENCODE_GO_API_KEY='…' # any non-empty value passes the key check
292
+ catence-console doctor
293
+ ```
294
+
295
+ The `openai/responses/…` models (for example `grok-4.5`) go through LiteLLM's
296
+ responses bridge and are the least battle-tested path; smoke-test one chat turn
297
+ before relying on them.
298
+
299
+ ---
300
+
301
+ ## Updating
302
+
303
+ From `catence-data update`-capable releases (0.2.0-beta.3 and later), the
304
+ runtime updates both components on the tracked channel — beta installs follow
305
+ the npm `beta` tag and matching PyPI prereleases:
306
+
307
+ ```sh
308
+ catence-data update --check # report only; exit 1 when updates are pending
309
+ catence-data update # npm runtime + uv tool catence-console upgrade
310
+ catence-data update --channel stable # move off a beta onto the stable channel
311
+ ```
312
+
313
+ Older betas (for example a beta 2 install) predate the command; upgrade them
314
+ manually once:
315
+
316
+ ```sh
317
+ npm install --global catence@beta
318
+ uv tool install --upgrade catence-console
319
+ ```
320
+
321
+ Docker deployments: re-run `deploy-console.sh beta` (or `stable`); it
322
+ re-resolves the newest versions from the registries and rebuilds. The data
323
+ volume and your `config.json` are preserved.
324
+
325
+ ## Troubleshooting
326
+
327
+ - **"No Catence config exists"** — should not happen after a seeded deploy or
328
+ wizard run; ensure `/data/config.json` (Docker) or `~/.catence/config.json`
329
+ exists and contains a `console` section.
330
+ - **"Refusing to initialize"** — the data home contains unrelated files; the
331
+ error lists them. Console artifacts (`config.json`, `console/`, and Chainlit's
332
+ `.files/`, `.chainlit/`, `public/`) are allowed; anything else blocks `setup`.
333
+ - **Settings panel has no Model dropdown** — the Console loads profiles from
334
+ `config.json`; write one via the wizard, a manual copy, or the discovery
335
+ script.
336
+ - **Profile not ready** — `catence-console doctor` lists the missing
337
+ environment variables; set them in `.env` (Docker) or the shell (local) and
338
+ restart the Console.