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
@@ -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, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, 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,9 +158,124 @@ 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`);
164
254
  });
255
+ program.command('reimport-nutrition').description('Re-run normalization over captured nutrition entities without contacting providers.').action(async () => {
256
+ const { paths } = await currentAthlete();
257
+ process.stdout.write(`${JSON.stringify(await reimportNutrition(paths), null, 2)}\n`);
258
+ });
259
+ program
260
+ .command('import')
261
+ .description('Import a staged JSONL file (for example a swim-length backfill) into one athlete’s data store.')
262
+ .requiredOption('--provider <provider>', 'garmin, intervals, or strava')
263
+ .argument('<file>', 'path to the staged JSONL file')
264
+ .action(async (file, options) => {
265
+ const { paths } = await currentAthlete();
266
+ process.stdout.write(`${JSON.stringify(await importStagedFile(paths, options.provider, file), null, 2)}\n`);
267
+ });
268
+ program
269
+ .command('resolve-errors')
270
+ .description('Mark unresolved extraction errors as resolved (for example stale bootstrap failures from an environment that has since recovered).')
271
+ .option('--run <run-id>', 'resolve errors from a specific run only (repeatable)')
272
+ .option('--provider <provider>', 'resolve errors for garmin, intervals, or strava only')
273
+ .option('--before <timestamp>', 'resolve errors created before this ISO timestamp')
274
+ .option('--all', 'resolve every unresolved error')
275
+ .action(async (options) => {
276
+ const { paths } = await currentAthlete();
277
+ const runIds = Array.isArray(options.run) ? options.run : options.run ? [options.run] : undefined;
278
+ process.stdout.write(`${JSON.stringify(await resolveExtractionErrors(paths, { runIds, provider: options.provider, before: options.before, all: options.all }), null, 2)}\n`);
279
+ });
165
280
  await program.parseAsync();
166
281
  //# sourceMappingURL=commands.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Read + classify the generation progress sidecar written by the Catence
3
+ * Console agent turn (see `catence_console/generation_sidecar.py`). Mirrors the
4
+ * detached-sync sidecar logic: a run is "running" until it reaches a terminal
5
+ * stage, and "stale" (i.e. likely stuck) when its heartbeat is older than the
6
+ * threshold. This powers the chat UI's "still thinking vs stalled" signal.
7
+ */
8
+ export interface GenerationStatus {
9
+ threadId: string;
10
+ stage?: string;
11
+ running: boolean;
12
+ stale: boolean;
13
+ heartbeatAt?: string;
14
+ startedAt?: string;
15
+ updatedAt?: string;
16
+ toolCallCount: number;
17
+ lastTool?: string | null;
18
+ }
19
+ export declare function generationStatus(homeRoot: string, threadId: string): Promise<GenerationStatus>;
@@ -0,0 +1,55 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const TERMINAL_STAGES = new Set([
4
+ 'completed',
5
+ 'failed',
6
+ 'interrupted',
7
+ 'timed_out'
8
+ ]);
9
+ const STALE_MS = 5 * 60 * 1000; // 5 minutes
10
+ function emptyStatus(threadId) {
11
+ return {
12
+ threadId,
13
+ running: false,
14
+ stale: false,
15
+ toolCallCount: 0
16
+ };
17
+ }
18
+ export async function generationStatus(homeRoot, threadId) {
19
+ const safe = threadId.replace(/[^a-zA-Z0-9_-]/g, '');
20
+ if (!safe)
21
+ return emptyStatus(threadId);
22
+ const file = path.join(homeRoot, 'generation', `${safe}.generation.json`);
23
+ let raw;
24
+ try {
25
+ raw = await readFile(file, 'utf8');
26
+ }
27
+ catch {
28
+ return emptyStatus(threadId);
29
+ }
30
+ let data;
31
+ try {
32
+ data = JSON.parse(raw);
33
+ }
34
+ catch {
35
+ return emptyStatus(threadId);
36
+ }
37
+ const base = {
38
+ threadId,
39
+ stage: typeof data.stage === 'string' ? data.stage : undefined,
40
+ heartbeatAt: typeof data.heartbeatAt === 'string' ? data.heartbeatAt : undefined,
41
+ startedAt: typeof data.startedAt === 'string' ? data.startedAt : undefined,
42
+ updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : undefined,
43
+ toolCallCount: typeof data.toolCallCount === 'number' ? data.toolCallCount : 0,
44
+ lastTool: data.lastTool === null || typeof data.lastTool === 'string'
45
+ ? data.lastTool
46
+ : undefined
47
+ };
48
+ if (base.stage && TERMINAL_STAGES.has(base.stage)) {
49
+ return { ...base, running: false, stale: false };
50
+ }
51
+ const hb = base.heartbeatAt ? Date.parse(base.heartbeatAt) : NaN;
52
+ const stale = !Number.isFinite(hb) || Date.now() - hb > STALE_MS;
53
+ return { ...base, running: true, stale };
54
+ }
55
+ //# sourceMappingURL=generation-status.js.map
@@ -1,11 +1,17 @@
1
1
  import { type Server } from 'node:http';
2
- import { type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
2
+ import { type DetachedSyncSpawner, mergeOpenCodeGoConsoleProfiles, startDetachedSync, type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
3
3
  export type CatenceHttpServerOptions = {
4
4
  /** A shared catalog for public servers. All data APIs then require athleteId. */
5
5
  catalogPaths?: CatalogPaths;
6
6
  /** Internal compatibility hook for tests and embedded single-store callers. */
7
7
  paths?: CatencePaths;
8
8
  allowedOrigins?: readonly string[];
9
+ /** Internal compatibility hook for tests: override the detached sync spawner. */
10
+ startSync?: typeof startDetachedSync;
11
+ /** Internal compatibility hook for tests: passed into the default startDetachedSync. */
12
+ syncSpawnProcess?: DetachedSyncSpawner;
13
+ /** Internal compatibility hook for tests: override OpenCode Go model discovery. */
14
+ mergeModels?: typeof mergeOpenCodeGoConsoleProfiles;
9
15
  };
10
16
  /**
11
17
  * Create Catence's local Streamable HTTP server.
@@ -1,7 +1,9 @@
1
1
  import { createServer } from 'node:http';
2
+ import path from 'node:path';
2
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
- import { catenceRuntimeHealth, DashboardSnapshotService, jsonSafe, loadCatalog, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths } from '../../runtime/index.js';
4
+ import { catenceRuntimeHealth, DashboardSnapshotService, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, } from '../../runtime/index.js';
4
5
  import { createCatenceMcpServer } from '../mcp/server.js';
6
+ import { generationStatus } from './generation-status.js';
5
7
  const MAX_REQUEST_BYTES = 1_000_000;
6
8
  function json(response, statusCode, value) {
7
9
  const body = JSON.stringify(jsonSafe(value));
@@ -85,6 +87,84 @@ async function dashboardPaths(catalogPaths, staticPaths, url) {
85
87
  return staticPaths;
86
88
  throw new Error('No Catence data store is configured.');
87
89
  }
90
+ function parseSyncBody(body) {
91
+ if (body === undefined)
92
+ return {};
93
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
94
+ throw new Error('Request body must be a JSON object.');
95
+ const raw = body;
96
+ for (const key of Object.keys(raw)) {
97
+ if (!['athleteId', 'provider', 'from', 'refresh', 'refreshModels'].includes(key)) {
98
+ throw new Error(`Unknown sync field: ${key}.`);
99
+ }
100
+ }
101
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
102
+ throw new Error('athleteId must be a string.');
103
+ if (raw.provider !== undefined && (typeof raw.provider !== 'string' || !DETACHED_SYNC_PROVIDERS.includes(raw.provider))) {
104
+ throw new Error('provider must be intervals, garmin, strava, or all.');
105
+ }
106
+ if (raw.from !== undefined && (typeof raw.from !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.from))) {
107
+ throw new Error('from must use YYYY-MM-DD.');
108
+ }
109
+ for (const flag of ['refresh', 'refreshModels']) {
110
+ if (raw[flag] !== undefined && typeof raw[flag] !== 'boolean')
111
+ throw new Error(`${flag} must be a boolean.`);
112
+ }
113
+ return raw;
114
+ }
115
+ async function resolveSyncPaths(catalogPaths, staticPaths, body) {
116
+ if (catalogPaths) {
117
+ if (!body.athleteId)
118
+ throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
119
+ return (await resolveAthlete(catalogPaths, body.athleteId)).paths;
120
+ }
121
+ if (staticPaths)
122
+ return staticPaths;
123
+ throw new Error('No Catence data store is configured.');
124
+ }
125
+ /** Best-effort last-success timestamps; null while a run holds the write lock. */
126
+ async function lastSyncSummary(paths) {
127
+ const completedAtIso = (value) => {
128
+ if (value instanceof Date)
129
+ return Number.isNaN(value.getTime()) ? null : value.toISOString();
130
+ if (typeof value === 'string' && value.trim())
131
+ return value;
132
+ return null;
133
+ };
134
+ try {
135
+ // Read-only repository on purpose: it exposes per-run history (`syncRuns`)
136
+ // and degrades through the typed lock-conflict error while a detached
137
+ // sync holds DuckDB's single-writer lock. The write-capable dataStatus()
138
+ // would both block here and has no run history at all.
139
+ const repository = await openReadOnlyRepository(paths);
140
+ try {
141
+ const status = await repository.status();
142
+ const runs = Array.isArray(status.syncRuns) ? status.syncRuns : [];
143
+ const completed = runs.filter((run) => String(run.status ?? '').startsWith('completed') && completedAtIso(run.completed_at));
144
+ const providers = {};
145
+ let lastCompletedAt = null;
146
+ for (const run of completed) {
147
+ const completedAt = completedAtIso(run.completed_at);
148
+ const provider = String(run.provider ?? 'unknown');
149
+ if (!providers[provider] || providers[provider] < completedAt)
150
+ providers[provider] = completedAt;
151
+ if (!lastCompletedAt || lastCompletedAt < completedAt)
152
+ lastCompletedAt = completedAt;
153
+ }
154
+ return { lastCompletedAt, providers, recentRuns: runs.slice(0, 5) };
155
+ }
156
+ finally {
157
+ await repository.close();
158
+ }
159
+ }
160
+ catch {
161
+ // Best-effort by contract: while a detached sync holds the single-writer
162
+ // DuckDB lock, even opening a snapshot fails cross-process. Progress (from
163
+ // sidecar heartbeats) must still reach the dashboard, so any failure here
164
+ // degrades to "no last-sync summary" instead of failing /sync/status.
165
+ return null;
166
+ }
167
+ }
88
168
  async function handleMcpRequest(request, response, paths) {
89
169
  if (request.method !== 'POST') {
90
170
  methodNotAllowed(response);
@@ -143,6 +223,8 @@ export function createCatenceHttpServer(options = {}) {
143
223
  const catalogPaths = options.catalogPaths ?? (options.paths ? null : resolveCatalogPaths());
144
224
  const staticPaths = options.paths ?? null;
145
225
  const allowedOrigins = options.allowedOrigins ?? [];
226
+ const startSync = options.startSync ?? startDetachedSync;
227
+ const mergeModels = options.mergeModels ?? mergeOpenCodeGoConsoleProfiles;
146
228
  return createServer((request, response) => {
147
229
  void (async () => {
148
230
  if (!addCorsHeaders(request, response, allowedOrigins)) {
@@ -158,6 +240,10 @@ export function createCatenceHttpServer(options = {}) {
158
240
  json(response, 200, catenceRuntimeHealth());
159
241
  return;
160
242
  }
243
+ if (pathname === '/api/v1/health' && request.method === 'GET') {
244
+ json(response, 200, catenceRuntimeHealth());
245
+ return;
246
+ }
161
247
  if (pathname === '/api/v1/athletes' && request.method === 'GET') {
162
248
  if (!catalogPaths) {
163
249
  json(response, 200, { defaultAthleteId: 'local', athletes: [{ id: 'local', label: 'Local athlete' }] });
@@ -182,6 +268,103 @@ export function createCatenceHttpServer(options = {}) {
182
268
  }
183
269
  return;
184
270
  }
271
+ if (pathname === '/api/v1/sync' && request.method === 'POST') {
272
+ let body;
273
+ try {
274
+ body = parseSyncBody(await readJsonBody(request));
275
+ const paths = await resolveSyncPaths(catalogPaths, staticPaths, body);
276
+ // Optional model self-discovery runs before the data sync so new
277
+ // OpenCode Go models appear without a manual script run. Failure is
278
+ // non-fatal: it must never block or fail a data sync. The Console
279
+ // reads <CATENCE_HOME>/config.json, so catalog deployments merge
280
+ // into the catalog root's config instead of the athlete store.
281
+ const discoveryConfigPath = catalogPaths ? path.join(catalogPaths.root, 'config.json') : paths.config;
282
+ let discoveryWarning = null;
283
+ if (body.refreshModels) {
284
+ try {
285
+ await mergeModels({ configPath: discoveryConfigPath, setDefault: false });
286
+ }
287
+ catch (error) {
288
+ discoveryWarning = `OpenCode Go model discovery failed; profiles were left unchanged (${error instanceof Error ? error.message : String(error)}).`;
289
+ }
290
+ }
291
+ const handle = await startSync({
292
+ paths,
293
+ athleteId: body.athleteId ?? 'local',
294
+ provider: body.provider,
295
+ from: body.from,
296
+ refresh: body.refresh,
297
+ // The child resolves the athlete within the catalog, not within
298
+ // the athlete store this route resolved for locks and logs.
299
+ cliHome: catalogPaths ? catalogPaths.root : undefined,
300
+ }, { spawnProcess: options.syncSpawnProcess });
301
+ json(response, 202, { ...handle, ...(discoveryWarning ? { warning: discoveryWarning } : {}), progressEndpoint: '/api/v1/sync/status' });
302
+ }
303
+ catch (error) {
304
+ json(response, error instanceof DetachedSyncBusyError ? 409 : 400, {
305
+ error: {
306
+ code: error instanceof DetachedSyncBusyError ? 'sync_in_progress' : 'sync_start_failed',
307
+ message: error instanceof Error ? error.message : String(error),
308
+ },
309
+ });
310
+ }
311
+ return;
312
+ }
313
+ if (pathname === '/api/v1/models/discover' && request.method === 'POST') {
314
+ // Refresh the OpenCode Go profiles without starting a data sync. The
315
+ // Console's Models page calls this behind its authenticated proxy.
316
+ try {
317
+ const body = await readJsonBody(request).catch(() => ({}));
318
+ const raw = (body ?? {});
319
+ if (raw.setDefault !== undefined && typeof raw.setDefault !== 'boolean')
320
+ throw new Error('setDefault must be a boolean.');
321
+ const configPath = catalogPaths ? path.join(catalogPaths.root, 'config.json') : staticPaths?.config;
322
+ if (!configPath)
323
+ throw new Error('No Catence data store is configured.');
324
+ json(response, 200, jsonSafe(await mergeModels({ configPath, setDefault: raw.setDefault === true })));
325
+ }
326
+ catch (error) {
327
+ json(response, 400, { error: { code: 'model_discovery_failed', message: error instanceof Error ? error.message : String(error) } });
328
+ }
329
+ return;
330
+ }
331
+ if (pathname === '/api/v1/sync/status' && request.method === 'GET') {
332
+ try {
333
+ const url = new URL(request.url ?? '/', 'http://localhost');
334
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url);
335
+ const athleteId = catalogPaths ? (await resolveAthlete(catalogPaths, url.searchParams.get('athleteId') ?? '')).athlete.id : 'local';
336
+ json(response, 200, { athleteId, progress: await syncProgress(paths), lastSync: await lastSyncSummary(paths) });
337
+ }
338
+ catch (error) {
339
+ json(response, 400, { error: { code: 'sync_status_unavailable', message: error instanceof Error ? error.message : String(error) } });
340
+ }
341
+ return;
342
+ }
343
+ if (pathname.startsWith('/api/v1/threads/') &&
344
+ pathname.endsWith('/generation') &&
345
+ request.method === 'GET') {
346
+ try {
347
+ const segments = pathname.split('/').filter(Boolean);
348
+ const threadId = segments[segments.length - 2];
349
+ if (!threadId)
350
+ throw new Error('Missing threadId in request path.');
351
+ const base = (catalogPaths ?? staticPaths ?? resolveCatalogPaths());
352
+ const root = base?.root;
353
+ if (!root || typeof root !== 'string') {
354
+ throw new Error('No Catence data store is configured.');
355
+ }
356
+ json(response, 200, await generationStatus(root, threadId));
357
+ }
358
+ catch (error) {
359
+ json(response, 400, {
360
+ error: {
361
+ code: 'generation_status_unavailable',
362
+ message: error instanceof Error ? error.message : String(error)
363
+ }
364
+ });
365
+ }
366
+ return;
367
+ }
185
368
  if (pathname === '/mcp') {
186
369
  await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths());
187
370
  return;
@@ -1,8 +1,9 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { type CatalogPaths, hydrateStravaActivity, type CatencePaths } from '../../runtime/index.js';
2
+ import { type CatalogPaths, hydrateStravaActivity, startDetachedSync, type CatencePaths } from '../../runtime/index.js';
3
3
  type StravaActivityHydrator = typeof hydrateStravaActivity;
4
4
  type McpDependencies = {
5
5
  hydrateStravaActivity?: StravaActivityHydrator;
6
+ startDetachedSync?: typeof startDetachedSync;
6
7
  };
7
8
  export declare function createCatenceMcpServer(paths?: CatencePaths | CatalogPaths, dependencies?: McpDependencies): McpServer;
8
9
  export {};
@@ -1,7 +1,7 @@
1
1
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { AsyncLocalStorage } from 'node:async_hooks';
3
3
  import { z } from 'zod';
4
- import { ActivityDiscoveryService, AnalyticsService, loadCatalog, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, searchContext, SlidingWindowLimiter, StravaEnrichmentError, StravaRateLimitError, SwimmingService, WELLNESS_METRICS, WellnessService, } from '../../runtime/index.js';
4
+ import { ActivityDiscoveryService, AnalyticsService, loadCatalog, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, DetachedSyncBusyError, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, searchContext, SlidingWindowLimiter, startDetachedSync, StravaEnrichmentError, StravaRateLimitError, SwimmingService, WELLNESS_METRICS, WellnessService, } from '../../runtime/index.js';
5
5
  const filterSchema = z.object({
6
6
  column: z.string().min(1),
7
7
  op: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'between', 'contains']),
@@ -32,17 +32,19 @@ function textResult(value) {
32
32
  return { content: [{ type: 'text', text: JSON.stringify(jsonSafe(value), null, 2) }] };
33
33
  }
34
34
  function errorResult(error) {
35
- const classified = error instanceof DataWriteBusyError
36
- ? { code: 'data_sync_in_progress', message: error.message, retryable: true }
37
- : error instanceof StravaRateLimitError
38
- ? { code: 'rate_limited', message: error.message, retryable: true, retryAfterSeconds: error.retryAfterSeconds ?? null }
39
- : error instanceof StravaEnrichmentError
40
- ? { code: 'strava_connection_error', message: error.message }
41
- : error instanceof ReadOnlyDatabaseError
42
- ? { code: error.code, message: error.message }
43
- : error instanceof QueryValidationError
44
- ? { code: 'invalid_request', message: error.message }
45
- : { code: 'data_unavailable', message: error instanceof Error ? error.message : String(error) };
35
+ const classified = error instanceof DetachedSyncBusyError
36
+ ? { code: 'sync_in_progress', message: error.message, retryable: true, runId: error.runId }
37
+ : error instanceof DataWriteBusyError
38
+ ? { code: 'data_sync_in_progress', message: error.message, retryable: true }
39
+ : error instanceof StravaRateLimitError
40
+ ? { code: 'rate_limited', message: error.message, retryable: true, retryAfterSeconds: error.retryAfterSeconds ?? null }
41
+ : error instanceof StravaEnrichmentError
42
+ ? { code: 'strava_connection_error', message: error.message }
43
+ : error instanceof ReadOnlyDatabaseError
44
+ ? { code: error.code, message: error.message }
45
+ : error instanceof QueryValidationError
46
+ ? { code: 'invalid_request', message: error.message }
47
+ : { code: 'data_unavailable', message: error instanceof Error ? error.message : String(error) };
46
48
  return { ...textResult({ data: null, provenance: {}, query: {}, caveats: [classified.message], error: classified }), isError: true };
47
49
  }
48
50
  function hydrationFailure(error) {
@@ -123,6 +125,7 @@ async function activitySegments(repository, activityId, hydratedStravaActivityId
123
125
  export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependencies = {}) {
124
126
  const server = new McpServer({ name: 'catence', version: '0.1.0' }, { instructions: MCP_INSTRUCTIONS });
125
127
  const hydrateActivity = dependencies.hydrateStravaActivity ?? hydrateStravaActivity;
128
+ const startSync = dependencies.startDetachedSync ?? startDetachedSync;
126
129
  const catalogPaths = isCatalogPaths(paths) ? paths : null;
127
130
  const staticPaths = isCatalogPaths(paths) ? null : paths;
128
131
  const scope = new AsyncLocalStorage();
@@ -176,7 +179,12 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
176
179
  ...(demoStore ? { demoStore: { generated: true, seed: demoStore.seed, days: demoStore.days, startDate: demoStore.startDate, endDate: demoStore.endDate }, caveats: [...caveats, 'This response uses Catence generated demo data, not personal measurements.'] } : {}),
177
180
  });
178
181
  };
179
- return selected ? scope.run(selected, run) : run();
182
+ // `return await` matters here: a bare `return run()` would let a
183
+ // rejected promise escape the try block, bypassing the classified
184
+ // errorResult payload entirely.
185
+ if (selected)
186
+ return await scope.run(selected, run);
187
+ return await run();
180
188
  }
181
189
  catch (error) {
182
190
  return errorResult(error);
@@ -231,6 +239,35 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
231
239
  data: { ...(await repository.status()), ...(await repository.coverage()) },
232
240
  provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
233
241
  }))));
242
+ server.registerTool('catence_sync_progress', {
243
+ title: 'Catence sync progress',
244
+ description: 'Read live progress heartbeats for active sync runs, plus the most recent completed or interrupted runs. Read-only.',
245
+ }, tool('catence_sync_progress', async () => useRepository(activePaths(), async (repository) => ({
246
+ data: await repository.progress(),
247
+ provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
248
+ }))));
249
+ server.registerTool('start_detached_sync', {
250
+ title: 'Start a detached data sync',
251
+ description: 'Write-only sync trigger, named like the other explicit lock-guarded write tools. Spawns one detached catence-data sync process for this athlete and returns immediately with the run handle and log file; it refuses while another sync run is active. Track the run with catence_sync_progress; this tool never waits for completion.',
252
+ inputSchema: {
253
+ provider: z.enum(['intervals', 'garmin', 'strava', 'all']).optional(),
254
+ from: z.string().date().optional(),
255
+ refresh: z.boolean().optional(),
256
+ },
257
+ }, tool('start_detached_sync', async (input) => {
258
+ const paths = activePaths();
259
+ const athleteId = activeAthlete()?.id ?? 'local';
260
+ const handle = await startSync({ paths, athleteId, provider: input.provider, from: input.from, refresh: input.refresh });
261
+ return {
262
+ data: { ...handle, progressTool: 'catence_sync_progress' },
263
+ provenance: { operation: 'detached_sync_spawn' },
264
+ query: { ...input },
265
+ caveats: [
266
+ 'The sync runs in its own process and keeps running if this session ends; its log file records the full worker output.',
267
+ 'Only one sync run may be active per athlete store.',
268
+ ],
269
+ };
270
+ }));
234
271
  server.registerTool('review_daily_recovery_load', {
235
272
  title: 'Review daily recovery and training load',
236
273
  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,13 @@ 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, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
30
32
  export type { ProviderChoice } from '../elt/application/management.js';
33
+ export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
34
+ export type { DetachedSyncHandle, DetachedSyncProvider, DetachedSyncRequest, DetachedSyncSpawner } from '../elt/application/detached-sync.js';
35
+ export { buildOpenCodeGoConsoleProfiles, classifyOpenCodeGoModel, fetchOpenCodeGoModelIds, mergeOpenCodeGoConsoleProfiles, OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_DEFAULT_BASE_URL, OPENCODE_GO_MESSAGES_API_BASE_ENV, } from '../core/runtime/opencode-go.js';
36
+ export type { OpenCodeGoConsoleProfiles, OpenCodeGoMergeResult, OpenCodeGoRoute } from '../core/runtime/opencode-go.js';
37
+ export type { InterruptedRuns, SyncProgressSnapshot, SyncProgressState } from '../contracts/progress.js';
31
38
  export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
32
39
  export { DataWriteBusyError } from '../elt/storage/write-lock.js';
33
40
  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,9 @@ 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, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
19
+ export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
20
+ export { buildOpenCodeGoConsoleProfiles, classifyOpenCodeGoModel, fetchOpenCodeGoModelIds, mergeOpenCodeGoConsoleProfiles, OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_DEFAULT_BASE_URL, OPENCODE_GO_MESSAGES_API_BASE_ENV, } from '../core/runtime/opencode-go.js';
18
21
  export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
19
22
  export { DataWriteBusyError } from '../elt/storage/write-lock.js';
20
23
  export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
@@ -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