catence 0.2.0-beta.21 → 0.2.0-beta.23
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/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 +7 -3
- 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/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 +99 -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,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,11 @@ 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.',
|
|
30
35
|
].join(' ');
|
|
31
36
|
function textResult(value) {
|
|
32
37
|
return { content: [{ type: 'text', text: JSON.stringify(jsonSafe(value), null, 2) }] };
|
|
@@ -133,9 +138,16 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
133
138
|
const activeAthlete = () => scope.getStore()?.athlete;
|
|
134
139
|
const limiter = new SlidingWindowLimiter();
|
|
135
140
|
const athleteIdSchema = z.string().regex(/^[a-z][a-z0-9-]{0,62}$/);
|
|
141
|
+
// Discoverability: record every registered tool so the catence://tools resource can
|
|
142
|
+
// enumerate all tools even when the host harness truncates the visible tool list.
|
|
143
|
+
const TOOL_INDEX = [];
|
|
144
|
+
function registerTool(name, config, callback) {
|
|
145
|
+
TOOL_INDEX.push({ name, title: config.title ?? name, description: config.description ?? '' });
|
|
146
|
+
server.registerTool(name, config, callback);
|
|
147
|
+
}
|
|
136
148
|
const promptAthleteSchema = catalogPaths ? { athleteId: athleteIdSchema } : {};
|
|
137
149
|
if (catalogPaths) {
|
|
138
|
-
|
|
150
|
+
registerTool('list_athletes', {
|
|
139
151
|
title: 'List configured athletes',
|
|
140
152
|
description: 'List athlete IDs and labels available to this shared Catence agent. No personal metrics are returned.',
|
|
141
153
|
}, async () => {
|
|
@@ -229,24 +241,24 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
229
241
|
}, ({ activityId, ...input }) => ({
|
|
230
242
|
messages: [{
|
|
231
243
|
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.` },
|
|
244
|
+
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
245
|
}],
|
|
234
246
|
}));
|
|
235
|
-
|
|
247
|
+
registerTool('catence_status', {
|
|
236
248
|
title: 'Catence data status',
|
|
237
249
|
description: 'Read sync state, data coverage, entity counts, stream availability, unresolved errors, and retrieval-index freshness. Read-only.',
|
|
238
250
|
}, tool('catence_status', async () => useRepository(activePaths(), async (repository) => ({
|
|
239
251
|
data: { ...(await repository.status()), ...(await repository.coverage()) },
|
|
240
252
|
provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
|
|
241
253
|
}))));
|
|
242
|
-
|
|
254
|
+
registerTool('catence_sync_progress', {
|
|
243
255
|
title: 'Catence sync progress',
|
|
244
256
|
description: 'Read live progress heartbeats for active sync runs, plus the most recent completed or interrupted runs. Read-only.',
|
|
245
257
|
}, tool('catence_sync_progress', async () => useRepository(activePaths(), async (repository) => ({
|
|
246
258
|
data: await repository.progress(),
|
|
247
259
|
provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
|
|
248
260
|
}))));
|
|
249
|
-
|
|
261
|
+
registerTool('start_detached_sync', {
|
|
250
262
|
title: 'Start a detached data sync',
|
|
251
263
|
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
264
|
inputSchema: {
|
|
@@ -268,7 +280,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
268
280
|
],
|
|
269
281
|
};
|
|
270
282
|
}));
|
|
271
|
-
|
|
283
|
+
registerTool('review_daily_recovery_load', {
|
|
272
284
|
title: 'Review daily recovery and training load',
|
|
273
285
|
description: 'Return source-cited daily health, training, and nutrition facts for a recovery/load review. It does not prescribe a training plan.',
|
|
274
286
|
inputSchema: { date: z.string().date().optional() },
|
|
@@ -284,7 +296,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
284
296
|
],
|
|
285
297
|
}));
|
|
286
298
|
}));
|
|
287
|
-
|
|
299
|
+
registerTool('review_weekly_training', {
|
|
288
300
|
title: 'Review seven days of training',
|
|
289
301
|
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
302
|
inputSchema: { endDate: z.string().date().optional() },
|
|
@@ -301,7 +313,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
301
313
|
],
|
|
302
314
|
}));
|
|
303
315
|
}));
|
|
304
|
-
|
|
316
|
+
registerTool('review_activity_deep_dive', {
|
|
305
317
|
title: 'Review one activity in depth',
|
|
306
318
|
description: 'Return canonical activity identity, source summaries, and structured intervals. Call read_series separately only when a specific sampled metric is required.',
|
|
307
319
|
inputSchema: { activityId: z.string().min(1) },
|
|
@@ -316,14 +328,14 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
316
328
|
: ['No canonical activity matched this activityId. Use find_activities before drawing conclusions.'],
|
|
317
329
|
};
|
|
318
330
|
})));
|
|
319
|
-
|
|
331
|
+
registerTool('describe_data', {
|
|
320
332
|
title: 'Describe available Catence datasets',
|
|
321
333
|
description: 'List cataloged datasets, fields, units, permitted filters/groupings, providers, and time coverage. Read-only.',
|
|
322
334
|
}, tool('describe_data', async () => useRepository(activePaths(), async (repository) => ({
|
|
323
335
|
data: { datasets: repository.catalog(), ...(await repository.coverage()) },
|
|
324
336
|
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
337
|
}))));
|
|
326
|
-
|
|
338
|
+
registerTool('describe_dataset', {
|
|
327
339
|
title: 'Describe one Catence dataset',
|
|
328
340
|
description: 'Read a compact schema, permitted filters/groupings, provenance fields, and coverage for one cataloged dataset. Read-only.',
|
|
329
341
|
inputSchema: { dataset: z.string().min(1) },
|
|
@@ -346,10 +358,10 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
346
358
|
provenance: { catalog: 'Catence catalog', database: 'read-only DuckDB snapshot' }, query: input, caveats: [],
|
|
347
359
|
};
|
|
348
360
|
})));
|
|
349
|
-
|
|
361
|
+
registerTool('read_series', {
|
|
350
362
|
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
363
|
}, tool('read_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).readSeries({ ...input, filters: input.filters }))));
|
|
352
|
-
|
|
364
|
+
registerTool('aggregate_data', {
|
|
353
365
|
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
366
|
inputSchema: {
|
|
355
367
|
dataset: z.string().min(1),
|
|
@@ -357,15 +369,42 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
357
369
|
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
370
|
},
|
|
359
371
|
}, tool('aggregate_data', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).aggregate({ ...input, filters: input.filters }))));
|
|
360
|
-
|
|
372
|
+
registerTool('analyze_series', {
|
|
361
373
|
title: 'Analyze a descriptive series', description: 'Run deterministic rolling statistics, baselines, correlations, seasonal comparisons, or trends on a cataloged series.',
|
|
362
374
|
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
375
|
}, tool('analyze_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).analyzeSeries({ ...input, filters: input.filters }))));
|
|
364
|
-
|
|
376
|
+
registerTool('fit_series_model', {
|
|
365
377
|
title: 'Fit a descriptive series model', description: 'Fit a bounded OLS, Theil–Sen, quadratic, or cubic descriptive model. Not a sport-performance model.',
|
|
366
378
|
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
379
|
}, tool('fit_series_model', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).fitSeriesModel({ ...input, filters: input.filters }))));
|
|
368
|
-
|
|
380
|
+
registerTool('activity_decoupling', {
|
|
381
|
+
title: 'Derive aerobic decoupling and GAP for one activity',
|
|
382
|
+
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.',
|
|
383
|
+
inputSchema: { activityId: z.string().min(1), includeGap: z.boolean().optional() },
|
|
384
|
+
}, tool('activity_decoupling', async (input) => useRepository(activePaths(), async (repository) => {
|
|
385
|
+
const analytics = new AnalyticsService(repository);
|
|
386
|
+
const decoupling = await analytics.deriveDecoupling({ activityId: input.activityId });
|
|
387
|
+
const gap = input.includeGap === false ? null : await analytics.deriveGap({ activityId: input.activityId });
|
|
388
|
+
const decouplingData = decoupling;
|
|
389
|
+
const gapData = gap;
|
|
390
|
+
const combinedCaveats = [
|
|
391
|
+
...(decouplingData.caveats ?? []),
|
|
392
|
+
...(gapData?.caveats ?? []),
|
|
393
|
+
];
|
|
394
|
+
return jsonSafe({
|
|
395
|
+
data: { decoupling: decouplingData.data ?? null, gap: gapData?.data ?? null, decouplingRaw: decouplingData, gapRaw: gapData },
|
|
396
|
+
provenance: {
|
|
397
|
+
decoupling: decouplingData.provenance,
|
|
398
|
+
gap: gapData?.provenance ?? null,
|
|
399
|
+
derivation: 'derived',
|
|
400
|
+
note: 'Both metrics are derived from activity_samples (Parquet lake) when provider summaries do not supply them; provider values remain authoritative.',
|
|
401
|
+
},
|
|
402
|
+
query: { activityId: input.activityId, includeGap: input.includeGap ?? true, decouplingQuery: decouplingData.query, gapQuery: gapData?.query ?? null },
|
|
403
|
+
caveats: combinedCaveats.length ? combinedCaveats : ['Derived metrics are descriptive only; not a physiological performance model.'],
|
|
404
|
+
inputs: { decouplingInput: decouplingData.input, gapInput: gapData?.input ?? null },
|
|
405
|
+
});
|
|
406
|
+
})));
|
|
407
|
+
registerTool('wellness_correlate', {
|
|
369
408
|
title: 'Correlate recovery and training metrics',
|
|
370
409
|
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
410
|
inputSchema: {
|
|
@@ -373,32 +412,51 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
373
412
|
method: z.enum(['pearson', 'spearman']).optional(), lagDays: z.number().int().min(-30).max(30).optional(), scanLags: z.boolean().optional(),
|
|
374
413
|
},
|
|
375
414
|
}, tool('wellness_correlate', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).correlate(input))));
|
|
376
|
-
|
|
415
|
+
registerTool('wellness_baselines', {
|
|
377
416
|
title: 'Read personal wellness baselines',
|
|
378
417
|
description: 'Return trailing means, standard-deviation bands, latest values, and latest z-scores for common recovery and training metrics. Missing values remain missing.',
|
|
379
418
|
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
419
|
}, tool('wellness_baselines', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).baselines(input))));
|
|
381
|
-
|
|
420
|
+
registerTool('wellness_anomalies', {
|
|
382
421
|
title: 'Find statistical wellness anomalies',
|
|
383
422
|
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
423
|
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
424
|
}, tool('wellness_anomalies', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).anomalies(input))));
|
|
386
|
-
|
|
425
|
+
registerTool('wellness_coverage', {
|
|
387
426
|
title: 'Inspect wellness data coverage',
|
|
388
427
|
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
428
|
inputSchema: { metrics: z.array(z.enum(WELLNESS_METRICS)).min(1).max(12).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
390
429
|
}, tool('wellness_coverage', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).coverage(input))));
|
|
391
|
-
|
|
430
|
+
registerTool('get_ftp_history', {
|
|
392
431
|
title: 'Get dated FTP history',
|
|
393
432
|
description: 'Return normalized cycling FTP settings and activity-summary observations with source-aware preferred daily values. Read-only.',
|
|
394
433
|
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
434
|
}, tool('get_ftp_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).ftpHistory(input))));
|
|
396
|
-
|
|
435
|
+
registerTool('readiness_baseline', {
|
|
436
|
+
title: 'Read a running/race readiness baseline',
|
|
437
|
+
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.',
|
|
438
|
+
inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
439
|
+
}, tool('readiness_baseline', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).readinessBaseline(input))));
|
|
440
|
+
registerTool('resolve_event_course', {
|
|
441
|
+
title: 'Resolve a race event course and its synced geometry',
|
|
442
|
+
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.',
|
|
443
|
+
inputSchema: { eventId: z.string().min(1), pastActivityId: z.string().min(1).optional() },
|
|
444
|
+
}, tool('resolve_event_course', async (input) => useRepository(activePaths(), async (repository) => {
|
|
445
|
+
const events = new EventsService(repository);
|
|
446
|
+
const resolved = await events.resolveEventCourse(input.eventId);
|
|
447
|
+
const diff = input.pastActivityId ? await events.diffCourseProfiles(resolved.courseId, input.pastActivityId) : null;
|
|
448
|
+
return {
|
|
449
|
+
data: { event: resolved, courseDiff: diff },
|
|
450
|
+
provenance: { relation: 'source_entities' },
|
|
451
|
+
caveats: [...resolved.caveats, ...(diff?.caveats ?? [])],
|
|
452
|
+
};
|
|
453
|
+
})));
|
|
454
|
+
registerTool('get_vo2max_history', {
|
|
397
455
|
title: 'Get sport-specific VO₂max history',
|
|
398
456
|
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
457
|
inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
400
458
|
}, tool('get_vo2max_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).vo2MaxHistory(input))));
|
|
401
|
-
|
|
459
|
+
registerTool('find_activities', {
|
|
402
460
|
title: 'Find activities and likely races',
|
|
403
461
|
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
462
|
inputSchema: {
|
|
@@ -409,17 +467,17 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
409
467
|
sort: z.enum(['date_desc', 'date_asc']).optional(), limit: z.number().int().min(1).max(100).optional(), cursor: z.string().min(1).optional(),
|
|
410
468
|
},
|
|
411
469
|
}, tool('find_activities', async (input) => useRepository(activePaths(), (repository) => new ActivityDiscoveryService(repository).findActivities(input))));
|
|
412
|
-
|
|
470
|
+
registerTool('get_swim_laps', {
|
|
413
471
|
title: 'Read explicit swim lengths and grouped sets',
|
|
414
472
|
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
473
|
inputSchema: { activityId: z.string().min(1), provider: z.enum(['garmin', 'intervals']).optional() },
|
|
416
474
|
}, tool('get_swim_laps', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimLaps(input))));
|
|
417
|
-
|
|
475
|
+
registerTool('swim_progress_report', {
|
|
418
476
|
title: 'Build a source-aware swim progress report',
|
|
419
477
|
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
478
|
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), poolLengthM: z.number().positive().max(200).optional() },
|
|
421
479
|
}, tool('swim_progress_report', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimProgressReport(input))));
|
|
422
|
-
|
|
480
|
+
registerTool('get_activity_segments', {
|
|
423
481
|
title: 'Hydrate and read an activity’s Strava segments',
|
|
424
482
|
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
483
|
inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional(), limit: z.number().int().min(1).max(500).optional() },
|
|
@@ -437,35 +495,35 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
437
495
|
],
|
|
438
496
|
};
|
|
439
497
|
}));
|
|
440
|
-
|
|
498
|
+
registerTool('power_curve_trend', {
|
|
441
499
|
title: 'Read labelled power-curve trends',
|
|
442
500
|
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
501
|
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
502
|
}, tool('power_curve_trend', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCurveTrend(input))));
|
|
445
|
-
|
|
503
|
+
registerTool('power_coverage_report', {
|
|
446
504
|
title: 'Inspect FIT-derived power coverage',
|
|
447
505
|
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
506
|
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
507
|
}, tool('power_coverage_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCoverageReport(input))));
|
|
450
|
-
|
|
508
|
+
registerTool('latest_cycling_activities', {
|
|
451
509
|
title: 'List latest cycling activities',
|
|
452
510
|
description: 'List Garmin cycling source records without duplicate Intervals summaries, optionally flagging multisport parent records. Read-only.',
|
|
453
511
|
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
512
|
}, tool('latest_cycling_activities', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).latestCyclingActivities(input))));
|
|
455
|
-
|
|
513
|
+
registerTool('cycling_progress_report', {
|
|
456
514
|
title: 'Build a cycling progress report',
|
|
457
515
|
description: 'Combine source-aware FTP and VO₂max histories with monthly canonical volume/load and labelled power-curve trends. Read-only and descriptive.',
|
|
458
516
|
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
459
517
|
}, tool('cycling_progress_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).cyclingProgressReport(input))));
|
|
460
|
-
|
|
518
|
+
registerTool('query_read_only_data', {
|
|
461
519
|
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
520
|
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
521
|
}, tool('query_read_only_data', async (input) => useRepository(activePaths(), (repository) => queryReadOnlyData(repository, input))));
|
|
464
|
-
|
|
522
|
+
registerTool('search_context', {
|
|
465
523
|
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
524
|
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
525
|
}, tool('search_context', async (input) => useRepository(activePaths(), (repository) => searchContext(repository, input))));
|
|
468
|
-
|
|
526
|
+
registerTool('hydrate_strava_activity', {
|
|
469
527
|
title: 'Hydrate one activity from Strava',
|
|
470
528
|
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
529
|
inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional() },
|
|
@@ -473,7 +531,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
473
531
|
const data = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
|
|
474
532
|
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
533
|
}));
|
|
476
|
-
|
|
534
|
+
registerTool('hydrate_recent_strava_activities', {
|
|
477
535
|
title: 'Hydrate several recent Strava activities',
|
|
478
536
|
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
537
|
inputSchema: z.object({
|
|
@@ -541,7 +599,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
541
599
|
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
600
|
};
|
|
543
601
|
}));
|
|
544
|
-
|
|
602
|
+
registerTool('hydrate_strava_segment_history', {
|
|
545
603
|
title: 'Hydrate one Strava segment history',
|
|
546
604
|
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
605
|
inputSchema: { segmentId: z.string().min(1), refresh: z.boolean().optional() },
|
|
@@ -597,6 +655,13 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
597
655
|
}
|
|
598
656
|
});
|
|
599
657
|
}
|
|
658
|
+
server.registerResource('tools', 'catence://tools', { title: 'Catence tool index', mimeType: 'application/json' }, async (uri) => {
|
|
659
|
+
return resource(uri, {
|
|
660
|
+
count: TOOL_INDEX.length,
|
|
661
|
+
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.',
|
|
662
|
+
tools: TOOL_INDEX.map((tool) => ({ name: tool.name, purpose: tool.title, description: tool.description })),
|
|
663
|
+
});
|
|
664
|
+
});
|
|
600
665
|
return server;
|
|
601
666
|
}
|
|
602
667
|
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"))
|