catence 0.2.0-beta.21 → 0.2.0-beta.24
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.
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/query/activity-discovery.d.ts +2 -0
- package/dist/core/query/activity-discovery.js +1 -1
- package/dist/core/query/analytics.d.ts +10 -0
- package/dist/core/query/analytics.js +494 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +8 -4
- package/dist/core/query/events.d.ts +34 -0
- package/dist/core/query/events.js +121 -0
- package/dist/core/query/fitness.d.ts +4 -0
- package/dist/core/query/fitness.js +60 -0
- package/dist/core/query/swimming.js +2 -2
- package/dist/elt/ingestion/providers/registry.js +1 -0
- package/dist/elt/normalization/normalizers.js +96 -0
- package/dist/elt/storage/migrations.js +121 -1
- package/dist/interfaces/cli/commands.js +37 -1
- package/dist/interfaces/mcp/main.js +33 -1
- package/dist/interfaces/mcp/server.js +101 -34
- package/dist/runtime/index.d.ts +3 -1
- package/dist/runtime/index.js +3 -1
- package/package.json +1 -1
- package/python/catence/providers/garmin/registry.py +1 -0
- package/python/catence/providers/garmin/worker.py +45 -3
|
@@ -1,11 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { addAthlete, athleteStorePaths, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
|
|
4
|
+
import { addAthlete, athleteStorePaths, CatenceDatabase, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
|
|
5
5
|
import { parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
|
|
6
6
|
import { createCatenceHttpServer } from '../http/server.js';
|
|
7
7
|
import { MCP_USAGE, parseMcpCliOptions } from './cli-options.js';
|
|
8
8
|
import { createCatenceMcpServer } from './server.js';
|
|
9
|
+
async function migrateCatalogStores(home) {
|
|
10
|
+
// On Docker image bumps the DuckDB schema version increases. The
|
|
11
|
+
// read-only MCP health probe would otherwise report "version X required"
|
|
12
|
+
// until the next manual sync. Proactively opening each athlete store in
|
|
13
|
+
// writable mode runs the idempotent migrations (database.ts:migrate)
|
|
14
|
+
// before the HTTP server starts listening, so a plain `docker compose up`
|
|
15
|
+
// after `deploy-console.sh beta` heals existing installs without an extra
|
|
16
|
+
// manual `catence-data migrate --all` step. Failures are best-effort:
|
|
17
|
+
// a missing catalog or a store still locked by another process is retried
|
|
18
|
+
// on the next sync or health check.
|
|
19
|
+
try {
|
|
20
|
+
const catalogPaths = resolveCatalogPaths(home);
|
|
21
|
+
const catalog = await loadCatalog(catalogPaths).catch(() => null);
|
|
22
|
+
if (!catalog || catalog.athletes.length === 0)
|
|
23
|
+
return;
|
|
24
|
+
for (const athlete of catalog.athletes) {
|
|
25
|
+
const paths = athleteStorePaths(catalogPaths, athlete.id);
|
|
26
|
+
try {
|
|
27
|
+
const database = await CatenceDatabase.open(paths);
|
|
28
|
+
await database.close();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Best-effort: a lock or a corrupted store must not prevent the
|
|
32
|
+
// server from starting; the next sync will retry the migration.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Catalog read failures are non-fatal for startup.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
9
40
|
async function run() {
|
|
10
41
|
const arguments_ = process.argv.slice(2);
|
|
11
42
|
if (arguments_[0] === 'serve') {
|
|
@@ -14,6 +45,7 @@ async function run() {
|
|
|
14
45
|
process.stderr.write(`${SERVE_USAGE}\n`);
|
|
15
46
|
return;
|
|
16
47
|
}
|
|
48
|
+
await migrateCatalogStores(options.home);
|
|
17
49
|
const httpServer = createCatenceHttpServer({
|
|
18
50
|
catalogPaths: resolveCatalogPaths(options.home),
|
|
19
51
|
allowedOrigins: options.allowedOrigins,
|
|
@@ -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, 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';
|
|
4
|
+
import { ActivityDiscoveryService, AnalyticsService, loadCatalog, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, DetachedSyncBusyError, EventsService, 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']),
|
|
@@ -27,6 +27,12 @@ const MCP_INSTRUCTIONS = [
|
|
|
27
27
|
'That tool performs the targeted Strava hydration itself. Do not say segment data is unavailable before it returns; if it reports not_found, ambiguous, authorization, throttling, or an error, report that exact outcome instead.',
|
|
28
28
|
'For Garmin running VO₂max, call get_vo2max_history with sport: running. Garmin stores the source value as generic; returned rows preserve that source label.',
|
|
29
29
|
'Aggregate elevation alone cannot support an individual-climb conclusion. Use source-specific facts and their stated coverage.',
|
|
30
|
+
'For race-readiness / fitness-target questions, establish a trend-first baseline (readiness_baseline, analyze_series, aggregate_data time buckets) before comparing any single activity.',
|
|
31
|
+
'Never conclude from VO2max alone; pull full indicator set (LT/FTP/power/race-prediction).',
|
|
32
|
+
'When summary metric is NULL (e.g. avg_power), fall through to FIT-derived/samples layer before declaring absence.',
|
|
33
|
+
'Before reusing any prior course/elevation profile, resolve event\'s courseId and diff it against past activity\'s course; different courseId => different profile.',
|
|
34
|
+
'If a needed tool is not visible in your current tool list (the host may truncate it to a subset), read the catence://tools resource to discover all available tools before concluding a capability is absent.',
|
|
35
|
+
'For swim analysis: use find_activities (sports: ["lap_swimming"] or sport: "lap_swimming") to list pool sessions. Use get_swim_laps for per-length data (pace from duration_s/pool_length_m, SPL from stroke_count, SWOLF from duration_s+stroke_count). Use swim_progress_report for session-level SWOLF, fastest-100, and stroke cadence. Per-length SWOLF is derived when the provider does not supply it. Per-length distance is typically null in Garmin FIT; use pool_length_m for pace calculations.',
|
|
30
36
|
].join(' ');
|
|
31
37
|
function textResult(value) {
|
|
32
38
|
return { content: [{ type: 'text', text: JSON.stringify(jsonSafe(value), null, 2) }] };
|
|
@@ -133,9 +139,16 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
133
139
|
const activeAthlete = () => scope.getStore()?.athlete;
|
|
134
140
|
const limiter = new SlidingWindowLimiter();
|
|
135
141
|
const athleteIdSchema = z.string().regex(/^[a-z][a-z0-9-]{0,62}$/);
|
|
142
|
+
// Discoverability: record every registered tool so the catence://tools resource can
|
|
143
|
+
// enumerate all tools even when the host harness truncates the visible tool list.
|
|
144
|
+
const TOOL_INDEX = [];
|
|
145
|
+
function registerTool(name, config, callback) {
|
|
146
|
+
TOOL_INDEX.push({ name, title: config.title ?? name, description: config.description ?? '' });
|
|
147
|
+
server.registerTool(name, config, callback);
|
|
148
|
+
}
|
|
136
149
|
const promptAthleteSchema = catalogPaths ? { athleteId: athleteIdSchema } : {};
|
|
137
150
|
if (catalogPaths) {
|
|
138
|
-
|
|
151
|
+
registerTool('list_athletes', {
|
|
139
152
|
title: 'List configured athletes',
|
|
140
153
|
description: 'List athlete IDs and labels available to this shared Catence agent. No personal metrics are returned.',
|
|
141
154
|
}, async () => {
|
|
@@ -229,24 +242,24 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
229
242
|
}, ({ activityId, ...input }) => ({
|
|
230
243
|
messages: [{
|
|
231
244
|
role: 'user',
|
|
232
|
-
content: { type: 'text', text: `Run review_activity_deep_dive for ${activityId}${typeof input.athleteId === 'string' ? ` with athleteId ${input.athleteId}` : ''}. Use its source records and intervals first; load read_series only if a specific sampled metric is needed.` },
|
|
245
|
+
content: { type: 'text', text: `Run review_activity_deep_dive for ${activityId}${typeof input.athleteId === 'string' ? ` with athleteId ${input.athleteId}` : ''}. Use its source records and intervals first; load read_series only if a specific sampled metric is needed. Use as anchor for current form only after trend baseline exists.` },
|
|
233
246
|
}],
|
|
234
247
|
}));
|
|
235
|
-
|
|
248
|
+
registerTool('catence_status', {
|
|
236
249
|
title: 'Catence data status',
|
|
237
250
|
description: 'Read sync state, data coverage, entity counts, stream availability, unresolved errors, and retrieval-index freshness. Read-only.',
|
|
238
251
|
}, tool('catence_status', async () => useRepository(activePaths(), async (repository) => ({
|
|
239
252
|
data: { ...(await repository.status()), ...(await repository.coverage()) },
|
|
240
253
|
provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
|
|
241
254
|
}))));
|
|
242
|
-
|
|
255
|
+
registerTool('catence_sync_progress', {
|
|
243
256
|
title: 'Catence sync progress',
|
|
244
257
|
description: 'Read live progress heartbeats for active sync runs, plus the most recent completed or interrupted runs. Read-only.',
|
|
245
258
|
}, tool('catence_sync_progress', async () => useRepository(activePaths(), async (repository) => ({
|
|
246
259
|
data: await repository.progress(),
|
|
247
260
|
provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
|
|
248
261
|
}))));
|
|
249
|
-
|
|
262
|
+
registerTool('start_detached_sync', {
|
|
250
263
|
title: 'Start a detached data sync',
|
|
251
264
|
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
265
|
inputSchema: {
|
|
@@ -268,7 +281,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
268
281
|
],
|
|
269
282
|
};
|
|
270
283
|
}));
|
|
271
|
-
|
|
284
|
+
registerTool('review_daily_recovery_load', {
|
|
272
285
|
title: 'Review daily recovery and training load',
|
|
273
286
|
description: 'Return source-cited daily health, training, and nutrition facts for a recovery/load review. It does not prescribe a training plan.',
|
|
274
287
|
inputSchema: { date: z.string().date().optional() },
|
|
@@ -284,7 +297,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
284
297
|
],
|
|
285
298
|
}));
|
|
286
299
|
}));
|
|
287
|
-
|
|
300
|
+
registerTool('review_weekly_training', {
|
|
288
301
|
title: 'Review seven days of training',
|
|
289
302
|
description: 'Return source-cited health, training, and nutrition facts for the seven-day period ending on an optional date. It does not prescribe a training plan.',
|
|
290
303
|
inputSchema: { endDate: z.string().date().optional() },
|
|
@@ -301,7 +314,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
301
314
|
],
|
|
302
315
|
}));
|
|
303
316
|
}));
|
|
304
|
-
|
|
317
|
+
registerTool('review_activity_deep_dive', {
|
|
305
318
|
title: 'Review one activity in depth',
|
|
306
319
|
description: 'Return canonical activity identity, source summaries, and structured intervals. Call read_series separately only when a specific sampled metric is required.',
|
|
307
320
|
inputSchema: { activityId: z.string().min(1) },
|
|
@@ -316,14 +329,14 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
316
329
|
: ['No canonical activity matched this activityId. Use find_activities before drawing conclusions.'],
|
|
317
330
|
};
|
|
318
331
|
})));
|
|
319
|
-
|
|
332
|
+
registerTool('describe_data', {
|
|
320
333
|
title: 'Describe available Catence datasets',
|
|
321
334
|
description: 'List cataloged datasets, fields, units, permitted filters/groupings, providers, and time coverage. Read-only.',
|
|
322
335
|
}, tool('describe_data', async () => useRepository(activePaths(), async (repository) => ({
|
|
323
336
|
data: { datasets: repository.catalog(), ...(await repository.coverage()) },
|
|
324
337
|
provenance: { catalog: 'Catence catalog, restricted to analytical views' }, query: {}, caveats: ['activity_samples is available only through read_series or aggregate_data and only reads registered Parquet stream manifests.'],
|
|
325
338
|
}))));
|
|
326
|
-
|
|
339
|
+
registerTool('describe_dataset', {
|
|
327
340
|
title: 'Describe one Catence dataset',
|
|
328
341
|
description: 'Read a compact schema, permitted filters/groupings, provenance fields, and coverage for one cataloged dataset. Read-only.',
|
|
329
342
|
inputSchema: { dataset: z.string().min(1) },
|
|
@@ -346,10 +359,10 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
346
359
|
provenance: { catalog: 'Catence catalog', database: 'read-only DuckDB snapshot' }, query: input, caveats: [],
|
|
347
360
|
};
|
|
348
361
|
})));
|
|
349
|
-
|
|
362
|
+
registerTool('read_series', {
|
|
350
363
|
title: 'Read a bounded time series', description: 'Read cataloged numeric series with deterministic cursor pagination and automatic stream downsampling. metrics must be numeric catalog columns; place identifiers and other strings in filters. Call describe_dataset first if the fields are uncertain.', inputSchema: seriesInput,
|
|
351
364
|
}, tool('read_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).readSeries({ ...input, filters: input.filters }))));
|
|
352
|
-
|
|
365
|
+
registerTool('aggregate_data', {
|
|
353
366
|
title: 'Aggregate cataloged data', description: 'Declarative aggregation over one cataloged dataset. A timeBucket adds a time_bucket field, which can be used in orderBy. No joins, arbitrary expressions, or file paths.',
|
|
354
367
|
inputSchema: {
|
|
355
368
|
dataset: z.string().min(1),
|
|
@@ -357,15 +370,42 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
357
370
|
dimensions: z.array(z.string().min(1)).max(8).optional(), filters: z.array(filterSchema).max(50).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional(), timeBucket: z.enum(['day', 'week', 'month']).optional(), orderBy: z.object({ column: z.string().min(1), direction: z.enum(['asc', 'desc']).optional() }).optional(), limit: z.number().int().min(1).max(500).optional(),
|
|
358
371
|
},
|
|
359
372
|
}, tool('aggregate_data', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).aggregate({ ...input, filters: input.filters }))));
|
|
360
|
-
|
|
373
|
+
registerTool('analyze_series', {
|
|
361
374
|
title: 'Analyze a descriptive series', description: 'Run deterministic rolling statistics, baselines, correlations, seasonal comparisons, or trends on a cataloged series.',
|
|
362
375
|
inputSchema: { ...seriesInput, analysis: z.enum(['rolling_mean', 'rolling_median', 'baseline_change', 'z_score', 'pearson', 'spearman', 'seasonal_comparison', 'linear_trend', 'theil_sen_trend']), compareMetric: z.string().min(1).optional(), window: z.number().int().min(2).max(100).optional() },
|
|
363
376
|
}, tool('analyze_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).analyzeSeries({ ...input, filters: input.filters }))));
|
|
364
|
-
|
|
377
|
+
registerTool('fit_series_model', {
|
|
365
378
|
title: 'Fit a descriptive series model', description: 'Fit a bounded OLS, Theil–Sen, quadratic, or cubic descriptive model. Not a sport-performance model.',
|
|
366
379
|
inputSchema: { ...seriesInput, model: z.enum(['ols_linear', 'theil_sen_linear', 'polynomial_2', 'polynomial_3']), xMetric: z.string().min(1).optional(), yMetric: z.string().min(1).optional() },
|
|
367
380
|
}, tool('fit_series_model', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).fitSeriesModel({ ...input, filters: input.filters }))));
|
|
368
|
-
|
|
381
|
+
registerTool('activity_decoupling', {
|
|
382
|
+
title: 'Derive aerobic decoupling and GAP for one activity',
|
|
383
|
+
description: 'Derive aerobic decoupling (Pa:Hr for running using speed/HR, Pw:Hr for cycling using power/HR — drift across steady first/second halves) and grade-adjusted pace (GAP, Minetti 2002 cost model) from stored activity_samples for one activity. Returns null with explanatory caveats when samples are insufficient, walk-heavy, or stop-heavy. Derived values are descriptive only and never overwrite provider-supplied decoupling/GAP. Read-only.',
|
|
384
|
+
inputSchema: { activityId: z.string().min(1), includeGap: z.boolean().optional() },
|
|
385
|
+
}, tool('activity_decoupling', async (input) => useRepository(activePaths(), async (repository) => {
|
|
386
|
+
const analytics = new AnalyticsService(repository);
|
|
387
|
+
const decoupling = await analytics.deriveDecoupling({ activityId: input.activityId });
|
|
388
|
+
const gap = input.includeGap === false ? null : await analytics.deriveGap({ activityId: input.activityId });
|
|
389
|
+
const decouplingData = decoupling;
|
|
390
|
+
const gapData = gap;
|
|
391
|
+
const combinedCaveats = [
|
|
392
|
+
...(decouplingData.caveats ?? []),
|
|
393
|
+
...(gapData?.caveats ?? []),
|
|
394
|
+
];
|
|
395
|
+
return jsonSafe({
|
|
396
|
+
data: { decoupling: decouplingData.data ?? null, gap: gapData?.data ?? null, decouplingRaw: decouplingData, gapRaw: gapData },
|
|
397
|
+
provenance: {
|
|
398
|
+
decoupling: decouplingData.provenance,
|
|
399
|
+
gap: gapData?.provenance ?? null,
|
|
400
|
+
derivation: 'derived',
|
|
401
|
+
note: 'Both metrics are derived from activity_samples (Parquet lake) when provider summaries do not supply them; provider values remain authoritative.',
|
|
402
|
+
},
|
|
403
|
+
query: { activityId: input.activityId, includeGap: input.includeGap ?? true, decouplingQuery: decouplingData.query, gapQuery: gapData?.query ?? null },
|
|
404
|
+
caveats: combinedCaveats.length ? combinedCaveats : ['Derived metrics are descriptive only; not a physiological performance model.'],
|
|
405
|
+
inputs: { decouplingInput: decouplingData.input, gapInput: gapData?.input ?? null },
|
|
406
|
+
});
|
|
407
|
+
})));
|
|
408
|
+
registerTool('wellness_correlate', {
|
|
369
409
|
title: 'Correlate recovery and training metrics',
|
|
370
410
|
description: 'Calculate a compact daily Pearson or Spearman correlation between curated wellness and training metrics, with an optional -7 through +7 day lag scan. Descriptive only.',
|
|
371
411
|
inputSchema: {
|
|
@@ -373,35 +413,55 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
373
413
|
method: z.enum(['pearson', 'spearman']).optional(), lagDays: z.number().int().min(-30).max(30).optional(), scanLags: z.boolean().optional(),
|
|
374
414
|
},
|
|
375
415
|
}, tool('wellness_correlate', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).correlate(input))));
|
|
376
|
-
|
|
416
|
+
registerTool('wellness_baselines', {
|
|
377
417
|
title: 'Read personal wellness baselines',
|
|
378
418
|
description: 'Return trailing means, standard-deviation bands, latest values, and latest z-scores for common recovery and training metrics. Missing values remain missing.',
|
|
379
419
|
inputSchema: { metrics: z.array(z.enum(WELLNESS_METRICS)).min(1).max(12).optional(), endDate: z.string().date().optional(), windowDays: z.number().int().min(7).max(365).optional() },
|
|
380
420
|
}, tool('wellness_baselines', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).baselines(input))));
|
|
381
|
-
|
|
421
|
+
registerTool('wellness_anomalies', {
|
|
382
422
|
title: 'Find statistical wellness anomalies',
|
|
383
423
|
description: 'Find daily recovery and wellness outliers by z-score and group dates with anomalies across multiple signals. This does not diagnose or prescribe.',
|
|
384
424
|
inputSchema: { metrics: z.array(z.enum(WELLNESS_METRICS)).min(1).max(12).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional(), zThreshold: z.number().min(1).max(5).optional() },
|
|
385
425
|
}, tool('wellness_anomalies', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).anomalies(input))));
|
|
386
|
-
|
|
426
|
+
registerTool('wellness_coverage', {
|
|
387
427
|
title: 'Inspect wellness data coverage',
|
|
388
428
|
description: 'Report present and missing dates for curated wellness/training metrics, plus unresolved extraction errors. Missing data is never interpreted as rest or a health outcome.',
|
|
389
429
|
inputSchema: { metrics: z.array(z.enum(WELLNESS_METRICS)).min(1).max(12).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
390
430
|
}, tool('wellness_coverage', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).coverage(input))));
|
|
391
|
-
|
|
431
|
+
registerTool('get_ftp_history', {
|
|
392
432
|
title: 'Get dated FTP history',
|
|
393
433
|
description: 'Return normalized cycling FTP settings and activity-summary observations with source-aware preferred daily values. Read-only.',
|
|
394
434
|
inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional(), sourcePreference: z.enum(['settings', 'settings_then_activity', 'all']).optional() },
|
|
395
435
|
}, tool('get_ftp_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).ftpHistory(input))));
|
|
396
|
-
|
|
436
|
+
registerTool('readiness_baseline', {
|
|
437
|
+
title: 'Read a running/race readiness baseline',
|
|
438
|
+
description: 'Combine the full performance-indicator set — running lactate threshold (pace/power/HR), VO₂max, race predictions, fitness trend, endurance score, and FIT-derived power bests/coverage — as trends in one call. Use this before any single-activity comparison for race-readiness or fitness-target questions.',
|
|
439
|
+
inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
440
|
+
}, tool('readiness_baseline', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).readinessBaseline(input))));
|
|
441
|
+
registerTool('resolve_event_course', {
|
|
442
|
+
title: 'Resolve a race event course and its synced geometry',
|
|
443
|
+
description: 'Given a Garmin/Intervals eventId, return the courseId it references and whether that course geometry has been synced. When geometry is absent, the result carries an explicit caveat so a prior course profile is never reused. Optionally pass pastActivityId to diff the event course against that activity\'s course.',
|
|
444
|
+
inputSchema: { eventId: z.string().min(1), pastActivityId: z.string().min(1).optional() },
|
|
445
|
+
}, tool('resolve_event_course', async (input) => useRepository(activePaths(), async (repository) => {
|
|
446
|
+
const events = new EventsService(repository);
|
|
447
|
+
const resolved = await events.resolveEventCourse(input.eventId);
|
|
448
|
+
const diff = input.pastActivityId ? await events.diffCourseProfiles(resolved.courseId, input.pastActivityId) : null;
|
|
449
|
+
return {
|
|
450
|
+
data: { event: resolved, courseDiff: diff },
|
|
451
|
+
provenance: { relation: 'source_entities' },
|
|
452
|
+
caveats: [...resolved.caveats, ...(diff?.caveats ?? [])],
|
|
453
|
+
};
|
|
454
|
+
})));
|
|
455
|
+
registerTool('get_vo2max_history', {
|
|
397
456
|
title: 'Get sport-specific VO₂max history',
|
|
398
457
|
description: 'Return normalized Garmin VO₂max observations for exactly one sport. For running, pass sport: running (or run): Garmin supplies that series with raw sport generic, which is preserved in each row. Cycling remains separate. Omit sport only to inspect available source labels. Read-only.',
|
|
399
458
|
inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
400
459
|
}, tool('get_vo2max_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).vo2MaxHistory(input))));
|
|
401
|
-
|
|
460
|
+
registerTool('find_activities', {
|
|
402
461
|
title: 'Find activities and likely races',
|
|
403
462
|
description: 'Find canonical activities by sport, distance, name, and date. Results are paginated and transparently flag likely-race signals without claiming provider-confirmed race metadata. Read-only.',
|
|
404
463
|
inputSchema: {
|
|
464
|
+
sport: z.string().min(1).optional(),
|
|
405
465
|
sports: z.array(z.string().min(1)).min(1).max(12).optional(),
|
|
406
466
|
distanceKm: z.tuple([z.number().min(0), z.number().min(0)]).optional(),
|
|
407
467
|
nameContains: z.array(z.string().min(1)).min(1).max(12).optional(),
|
|
@@ -409,17 +469,17 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
409
469
|
sort: z.enum(['date_desc', 'date_asc']).optional(), limit: z.number().int().min(1).max(100).optional(), cursor: z.string().min(1).optional(),
|
|
410
470
|
},
|
|
411
471
|
}, tool('find_activities', async (input) => useRepository(activePaths(), (repository) => new ActivityDiscoveryService(repository).findActivities(input))));
|
|
412
|
-
|
|
472
|
+
registerTool('get_swim_laps', {
|
|
413
473
|
title: 'Read explicit swim lengths and grouped sets',
|
|
414
474
|
description: 'Return source-aware swim lengths when a provider actually supplied them, plus Garmin detected and Intervals.icu auto-detected sets. A missing length list is reported as unavailable; laps are never reconstructed from samples.',
|
|
415
475
|
inputSchema: { activityId: z.string().min(1), provider: z.enum(['garmin', 'intervals']).optional() },
|
|
416
476
|
}, tool('get_swim_laps', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimLaps(input))));
|
|
417
|
-
|
|
477
|
+
registerTool('swim_progress_report', {
|
|
418
478
|
title: 'Build a source-aware swim progress report',
|
|
419
479
|
description: 'Compare Garmin swimming summaries and data completeness over a date range, optionally within one pool length. It does not derive pace trends from moving time divided by distance.',
|
|
420
480
|
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), poolLengthM: z.number().positive().max(200).optional() },
|
|
421
481
|
}, tool('swim_progress_report', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimProgressReport(input))));
|
|
422
|
-
|
|
482
|
+
registerTool('get_activity_segments', {
|
|
423
483
|
title: 'Hydrate and read an activity’s Strava segments',
|
|
424
484
|
description: 'For a selected activity’s segments, climbs, KOM/PRs, or per-segment analysis: automatically hydrate the matching Strava activity before returning its persisted segment efforts. Use this before saying segment data is unavailable.',
|
|
425
485
|
inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional(), limit: z.number().int().min(1).max(500).optional() },
|
|
@@ -437,35 +497,35 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
437
497
|
],
|
|
438
498
|
};
|
|
439
499
|
}));
|
|
440
|
-
|
|
500
|
+
registerTool('power_curve_trend', {
|
|
441
501
|
title: 'Read labelled power-curve trends',
|
|
442
502
|
description: 'Return monthly bests for selected power durations from an explicit sport or sport family, including the supporting activity and source type. Read-only.',
|
|
443
503
|
inputSchema: { sport: z.string().min(1).optional(), sportFamily: z.enum(['cycling', 'running']).optional(), durations: z.array(z.number().int().min(1).max(86_400)).min(1).max(12).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional(), sourceQuality: z.enum(['all', 'garmin_fit_derived']).optional() },
|
|
444
504
|
}, tool('power_curve_trend', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCurveTrend(input))));
|
|
445
|
-
|
|
505
|
+
registerTool('power_coverage_report', {
|
|
446
506
|
title: 'Inspect FIT-derived power coverage',
|
|
447
507
|
description: 'Report powered activities and the complete duration-best inventory for an explicit sport or sport family. Uses Garmin FIT-derived power, not sparse activity summaries. Read-only.',
|
|
448
508
|
inputSchema: { sport: z.string().min(1).optional(), sportFamily: z.enum(['cycling', 'running']).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional(), sourceQuality: z.enum(['all', 'garmin_fit_derived']).optional() },
|
|
449
509
|
}, tool('power_coverage_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCoverageReport(input))));
|
|
450
|
-
|
|
510
|
+
registerTool('latest_cycling_activities', {
|
|
451
511
|
title: 'List latest cycling activities',
|
|
452
512
|
description: 'List Garmin cycling source records without duplicate Intervals summaries, optionally flagging multisport parent records. Read-only.',
|
|
453
513
|
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), includeMultisport: z.boolean().optional(), limit: z.number().int().min(1).max(100).optional() },
|
|
454
514
|
}, tool('latest_cycling_activities', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).latestCyclingActivities(input))));
|
|
455
|
-
|
|
515
|
+
registerTool('cycling_progress_report', {
|
|
456
516
|
title: 'Build a cycling progress report',
|
|
457
517
|
description: 'Combine source-aware FTP and VO₂max histories with monthly canonical volume/load and labelled power-curve trends. Read-only and descriptive.',
|
|
458
518
|
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
459
519
|
}, tool('cycling_progress_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).cyclingProgressReport(input))));
|
|
460
|
-
|
|
520
|
+
registerTool('query_read_only_data', {
|
|
461
521
|
title: 'Query cataloged read-only data', description: 'Advanced fallback after describe_data/describe_dataset: one parameterized SELECT or WITH … SELECT over cataloged views only. Do not query information_schema, DuckDB system tables, or uncataloged physical relations. Results use deterministic cursor pagination and always report whether they are incomplete. Filesystem access, extensions, DDL, and mutation are rejected.',
|
|
462
522
|
inputSchema: { sql: z.string().min(1).max(20_000), values: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).refine((value) => Object.keys(value).length <= 100, 'At most 100 bind values are allowed.').optional(), cursor: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(500).optional() },
|
|
463
523
|
}, tool('query_read_only_data', async (input) => useRepository(activePaths(), (repository) => queryReadOnlyData(repository, input))));
|
|
464
|
-
|
|
524
|
+
registerTool('search_context', {
|
|
465
525
|
title: 'Search generated context', description: 'Search compact generated activity, plan, nutrition, and message context. Results identify authoritative follow-up tools rather than making numerical claims.',
|
|
466
526
|
inputSchema: { query: z.string().min(2).max(500), filters: z.array(z.object({ column: z.enum(['provider', 'entity_type', 'occurred_on']), op: z.enum(['eq', 'in', 'between']), value: z.unknown() })).max(20).optional(), limit: z.number().int().min(1).max(50).optional() },
|
|
467
527
|
}, tool('search_context', async (input) => useRepository(activePaths(), (repository) => searchContext(repository, input))));
|
|
468
|
-
|
|
528
|
+
registerTool('hydrate_strava_activity', {
|
|
469
529
|
title: 'Hydrate one activity from Strava',
|
|
470
530
|
description: 'Write-only targeted enrichment. Safely matches exactly one Catence activity/source to Strava, then archives and persists its activity detail, gear assignment, and segment efforts. For a segment/climb request, prefer get_activity_segments, which invokes this prerequisite automatically.',
|
|
471
531
|
inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional() },
|
|
@@ -473,7 +533,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
473
533
|
const data = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
|
|
474
534
|
return { data, coverage: await useRepository(activePaths(), (repository) => repository.coverage()), provenance: { provider: 'strava', operation: 'activity_detail', archivedBeforeNormalization: true }, caveats: ['Segment verification is unavailable from Strava public segment data and is never inferred.'] };
|
|
475
535
|
}));
|
|
476
|
-
|
|
536
|
+
registerTool('hydrate_recent_strava_activities', {
|
|
477
537
|
title: 'Hydrate several recent Strava activities',
|
|
478
538
|
description: 'Write-only targeted batch enrichment. Select an explicit list or a bounded date/sport window; Catence hydrates one activity at a time and reports every outcome.',
|
|
479
539
|
inputSchema: z.object({
|
|
@@ -541,7 +601,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
541
601
|
caveats: ['Each write is intentionally awaited before the next one, preventing data-directory lock contention.', 'not_found and ambiguous results include match diagnostics when Strava supplied them.'],
|
|
542
602
|
};
|
|
543
603
|
}));
|
|
544
|
-
|
|
604
|
+
registerTool('hydrate_strava_segment_history', {
|
|
545
605
|
title: 'Hydrate one Strava segment history',
|
|
546
606
|
description: 'Write-only targeted enrichment. Fetches a persisted Strava segment and the authenticated athlete’s paged historic efforts; interrupted or throttled work is resumable.',
|
|
547
607
|
inputSchema: { segmentId: z.string().min(1), refresh: z.boolean().optional() },
|
|
@@ -597,6 +657,13 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
597
657
|
}
|
|
598
658
|
});
|
|
599
659
|
}
|
|
660
|
+
server.registerResource('tools', 'catence://tools', { title: 'Catence tool index', mimeType: 'application/json' }, async (uri) => {
|
|
661
|
+
return resource(uri, {
|
|
662
|
+
count: TOOL_INDEX.length,
|
|
663
|
+
note: 'If a needed tool is not visible in your current tool list (the host may truncate it), use this index to discover all available tools before concluding a capability is absent. SQL-only tables training_metric_observations and source_entities are reachable via query_read_only_data / describe_data.',
|
|
664
|
+
tools: TOOL_INDEX.map((tool) => ({ name: tool.name, purpose: tool.title, description: tool.description })),
|
|
665
|
+
});
|
|
666
|
+
});
|
|
600
667
|
return server;
|
|
601
668
|
}
|
|
602
669
|
function variable(value) {
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type { DataFilter } from '../core/query/analytics.js';
|
|
|
23
23
|
export { ActivityDiscoveryService } from '../core/query/activity-discovery.js';
|
|
24
24
|
export { DATASET_CATALOG, getDataset, QueryValidationError } from '../core/query/catalog.js';
|
|
25
25
|
export { FitnessService } from '../core/query/fitness.js';
|
|
26
|
+
export { EventsService, extractCourseId } from '../core/query/events.js';
|
|
26
27
|
export { SwimmingService } from '../core/query/swimming.js';
|
|
27
28
|
export { WELLNESS_METRICS, WellnessService } from '../core/query/wellness.js';
|
|
28
29
|
export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
|
|
@@ -35,6 +36,7 @@ export type { DetachedSyncHandle, DetachedSyncProvider, DetachedSyncRequest, Det
|
|
|
35
36
|
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
37
|
export type { OpenCodeGoConsoleProfiles, OpenCodeGoMergeResult, OpenCodeGoRoute } from '../core/runtime/opencode-go.js';
|
|
37
38
|
export type { InterruptedRuns, SyncProgressSnapshot, SyncProgressState } from '../contracts/progress.js';
|
|
38
|
-
export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
|
|
39
|
+
export { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
|
|
39
40
|
export { DataWriteBusyError } from '../elt/storage/write-lock.js';
|
|
41
|
+
export { migrations } from '../elt/storage/migrations.js';
|
|
40
42
|
export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
|
package/dist/runtime/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export { AnalyticsService } from '../core/query/analytics.js';
|
|
|
10
10
|
export { ActivityDiscoveryService } from '../core/query/activity-discovery.js';
|
|
11
11
|
export { DATASET_CATALOG, getDataset, QueryValidationError } from '../core/query/catalog.js';
|
|
12
12
|
export { FitnessService } from '../core/query/fitness.js';
|
|
13
|
+
export { EventsService, extractCourseId } from '../core/query/events.js';
|
|
13
14
|
export { SwimmingService } from '../core/query/swimming.js';
|
|
14
15
|
export { WELLNESS_METRICS, WellnessService } from '../core/query/wellness.js';
|
|
15
16
|
export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
|
|
@@ -18,7 +19,8 @@ export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
|
|
|
18
19
|
export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
|
|
19
20
|
export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
|
|
20
21
|
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';
|
|
21
|
-
export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
|
|
22
|
+
export { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
|
|
22
23
|
export { DataWriteBusyError } from '../elt/storage/write-lock.js';
|
|
24
|
+
export { migrations } from '../elt/storage/migrations.js';
|
|
23
25
|
export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
|
|
24
26
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ from typing import Any, Callable, Mapping
|
|
|
8
8
|
|
|
9
9
|
from .progress import ProgressReporter
|
|
10
10
|
from .registry import GARMIN_READ_METHODS, assert_read_only_registry
|
|
11
|
-
from .staging import StagingWriter
|
|
11
|
+
from .staging import StagingWriter, safe_segment
|
|
12
12
|
from .streams import SAMPLE_COLUMNS, activity_details_to_samples, activity_power_bests, fit_archive_to_samples, fit_archive_to_swim_lengths, write_parquet
|
|
13
13
|
|
|
14
14
|
|
|
@@ -794,9 +794,37 @@ class GarminStagingWorker:
|
|
|
794
794
|
if self.progress is not None:
|
|
795
795
|
self.progress.advance(completed=len(calls) + len(months) + 1)
|
|
796
796
|
|
|
797
|
+
def stage_course(self, course_id: str) -> None:
|
|
798
|
+
"""Fetch one Garmin course by id and stage it as a source entity.
|
|
799
|
+
|
|
800
|
+
garminconnect 0.3.x exposes no first-class course method, so this uses
|
|
801
|
+
the authenticated Connect API directly:
|
|
802
|
+
``connectapi("course-service/course/{course_id}")``. Validated against a
|
|
803
|
+
live account (returns course metadata plus a ``geoPoints`` geometry array).
|
|
804
|
+
Failures are captured per call and reported as extraction errors without
|
|
805
|
+
aborting the rest of the sync.
|
|
806
|
+
|
|
807
|
+
The course is staged with remote_id == course_id (not an auto-derived
|
|
808
|
+
id) so event resolution can match the courseId an event references.
|
|
809
|
+
"""
|
|
810
|
+
payload, raw_hash = self._capture(
|
|
811
|
+
"course",
|
|
812
|
+
lambda course_id=course_id: self.api.connectapi(f"course-service/course/{course_id}"),
|
|
813
|
+
course_id,
|
|
814
|
+
)
|
|
815
|
+
if payload is not None:
|
|
816
|
+
self.writer.source_entity("course", course_id, payload, raw_hash, course_id)
|
|
817
|
+
|
|
797
818
|
def _fetch_garmin_events(self, start_date: str) -> None:
|
|
798
|
-
"""Fetch calendar events from start_date, following pagination.
|
|
819
|
+
"""Fetch calendar events from start_date, following pagination.
|
|
820
|
+
|
|
821
|
+
Any event that references a Garmin course (``courseId``/``course_id``)
|
|
822
|
+
triggers an automatic course fetch via stage_course, so a race's
|
|
823
|
+
geometry is synced without a manual recon run. Already-archived courses
|
|
824
|
+
are skipped to avoid re-fetching unchanged geometry every sync.
|
|
825
|
+
"""
|
|
799
826
|
page = 1
|
|
827
|
+
discovered_course_ids: set[str] = set()
|
|
800
828
|
while True:
|
|
801
829
|
payload, raw_hash = self._capture(
|
|
802
830
|
"garmin_events",
|
|
@@ -806,6 +834,20 @@ class GarminStagingWorker:
|
|
|
806
834
|
items = payload if isinstance(payload, list) else []
|
|
807
835
|
if items:
|
|
808
836
|
self._entity("garmin_events", "event", items, raw_hash)
|
|
837
|
+
for item in items:
|
|
838
|
+
if isinstance(item, Mapping):
|
|
839
|
+
course_id = item.get("courseId") or item.get("course_id")
|
|
840
|
+
if course_id:
|
|
841
|
+
discovered_course_ids.add(str(course_id))
|
|
809
842
|
if len(items) < 100:
|
|
810
|
-
|
|
843
|
+
break
|
|
811
844
|
page += 1
|
|
845
|
+
for course_id in sorted(discovered_course_ids):
|
|
846
|
+
self._check_interrupted()
|
|
847
|
+
if not self._course_archived(course_id):
|
|
848
|
+
self.stage_course(course_id)
|
|
849
|
+
|
|
850
|
+
def _course_archived(self, course_id: str) -> bool:
|
|
851
|
+
"""True when a course archive already exists under raw/garmin/course/{id}."""
|
|
852
|
+
directory = self.data_dir / "raw" / "garmin" / "course" / safe_segment(course_id)
|
|
853
|
+
return directory.is_dir() and any(directory.glob("*.json"))
|