catence 0.0.0 → 0.1.0
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/LICENSE +661 -0
- package/README.md +373 -29
- package/config.example.json +63 -0
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/release.d.ts +20 -0
- package/dist/contracts/release.js +17 -0
- package/dist/contracts/runtime.d.ts +11 -0
- package/dist/contracts/runtime.js +2 -0
- package/dist/contracts/staging.d.ts +183 -0
- package/dist/contracts/staging.js +36 -0
- package/dist/contracts/storage.d.ts +15 -0
- package/dist/contracts/storage.js +2 -0
- package/dist/core/dashboard/snapshot.d.ts +16 -0
- package/dist/core/dashboard/snapshot.js +65 -0
- package/dist/core/query/activity-discovery.d.ts +21 -0
- package/dist/core/query/activity-discovery.js +133 -0
- package/dist/core/query/analytics.d.ts +59 -0
- package/dist/core/query/analytics.js +395 -0
- package/dist/core/query/catalog.d.ts +26 -0
- package/dist/core/query/catalog.js +141 -0
- package/dist/core/query/fitness.d.ts +40 -0
- package/dist/core/query/fitness.js +297 -0
- package/dist/core/query/repository.d.ts +19 -0
- package/dist/core/query/repository.js +182 -0
- package/dist/core/query/sql-guard.d.ts +7 -0
- package/dist/core/query/sql-guard.js +121 -0
- package/dist/core/query/sql.d.ts +2 -0
- package/dist/core/query/sql.js +5 -0
- package/dist/core/query/swimming.d.ts +19 -0
- package/dist/core/query/swimming.js +184 -0
- package/dist/core/query/wellness.d.ts +45 -0
- package/dist/core/query/wellness.js +292 -0
- package/dist/core/retrieval/index.d.ts +13 -0
- package/dist/core/retrieval/index.js +248 -0
- package/dist/core/runtime/configuration.d.ts +71 -0
- package/dist/core/runtime/configuration.js +106 -0
- package/dist/core/runtime/limiter.d.ts +13 -0
- package/dist/core/runtime/limiter.js +17 -0
- package/dist/elt/application/demo.d.ts +23 -0
- package/dist/elt/application/demo.js +165 -0
- package/dist/elt/application/management.d.ts +25 -0
- package/dist/elt/application/management.js +283 -0
- package/dist/elt/ingestion/importer.d.ts +8 -0
- package/dist/elt/ingestion/importer.js +87 -0
- package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
- package/dist/elt/ingestion/providers/intervals.js +224 -0
- package/dist/elt/ingestion/providers/registry.d.ts +19 -0
- package/dist/elt/ingestion/providers/registry.js +62 -0
- package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
- package/dist/elt/ingestion/providers/strava/service.js +365 -0
- package/dist/elt/normalization/activities/linking.d.ts +5 -0
- package/dist/elt/normalization/activities/linking.js +165 -0
- package/dist/elt/normalization/normalizers.d.ts +5 -0
- package/dist/elt/normalization/normalizers.js +816 -0
- package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
- package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
- package/dist/elt/storage/artifacts.d.ts +13 -0
- package/dist/elt/storage/artifacts.js +48 -0
- package/dist/elt/storage/database.d.ts +74 -0
- package/dist/elt/storage/database.js +296 -0
- package/dist/elt/storage/migrations.d.ts +3 -0
- package/dist/elt/storage/migrations.js +832 -0
- package/dist/elt/storage/migrations.legacy.d.ts +12 -0
- package/dist/elt/storage/migrations.legacy.js +332 -0
- package/dist/elt/storage/sql.d.ts +2 -0
- package/dist/elt/storage/sql.js +7 -0
- package/dist/elt/storage/write-lock.d.ts +6 -0
- package/dist/elt/storage/write-lock.js +28 -0
- package/dist/elt/streams.d.ts +24 -0
- package/dist/elt/streams.js +62 -0
- package/dist/interfaces/cli/commands.d.ts +2 -0
- package/dist/interfaces/cli/commands.js +101 -0
- package/dist/interfaces/cli/main.d.ts +2 -0
- package/dist/interfaces/cli/main.js +3 -0
- package/dist/interfaces/http/cli-options.d.ts +9 -0
- package/dist/interfaces/http/cli-options.js +81 -0
- package/dist/interfaces/http/server.d.ts +14 -0
- package/dist/interfaces/http/server.js +169 -0
- package/dist/interfaces/mcp/cli-options.d.ts +6 -0
- package/dist/interfaces/mcp/cli-options.js +36 -0
- package/dist/interfaces/mcp/main.d.ts +2 -0
- package/dist/interfaces/mcp/main.js +59 -0
- package/dist/interfaces/mcp/server.d.ts +8 -0
- package/dist/interfaces/mcp/server.js +521 -0
- package/dist/runtime/index.d.ts +29 -0
- package/dist/runtime/index.js +19 -0
- package/docs/architecture.md +100 -0
- package/docs/assets/image.png +0 -0
- package/docs/mcp-activity-retrieval.md +377 -0
- package/package.json +81 -6
- package/pyproject.toml +28 -0
- package/python/__init__.py +1 -0
- package/python/catence/__init__.py +0 -0
- package/python/catence/providers/__init__.py +0 -0
- package/python/catence/providers/garmin/__init__.py +3 -0
- package/python/catence/providers/garmin/cli.py +62 -0
- package/python/catence/providers/garmin/contracts.py +23 -0
- package/python/catence/providers/garmin/registry.py +48 -0
- package/python/catence/providers/garmin/staging.py +87 -0
- package/python/catence/providers/garmin/streams.py +195 -0
- package/python/catence/providers/garmin/worker.py +450 -0
- package/python/catence/providers/strava/__init__.py +3 -0
- package/python/catence/providers/strava/cli.py +404 -0
- package/python/catence/providers/strava/staging.py +73 -0
- package/uv.lock +708 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ActivityDiscoveryService, AnalyticsService, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, ReadOnlyDatabaseError, resolvePaths, searchContext, SlidingWindowLimiter, StravaEnrichmentError, StravaRateLimitError, SwimmingService, WELLNESS_METRICS, WellnessService, } from '../../runtime/index.js';
|
|
4
|
+
const filterSchema = z.object({
|
|
5
|
+
column: z.string().min(1),
|
|
6
|
+
op: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'between', 'contains']),
|
|
7
|
+
value: z.unknown(),
|
|
8
|
+
});
|
|
9
|
+
const resolutionSchema = z.enum(['raw', '1m', '5m', '15m', '1h', 'day', 'week', 'auto']);
|
|
10
|
+
const seriesInput = {
|
|
11
|
+
dataset: z.string().min(1),
|
|
12
|
+
metrics: z.array(z.string().min(1)).min(1).max(12),
|
|
13
|
+
filters: z.array(filterSchema).max(50).optional(),
|
|
14
|
+
startDate: z.string().date().optional(),
|
|
15
|
+
endDate: z.string().date().optional(),
|
|
16
|
+
activityId: z.string().min(1).optional(),
|
|
17
|
+
resolution: resolutionSchema.optional(),
|
|
18
|
+
cursor: z.string().min(1).optional(),
|
|
19
|
+
pageSize: z.number().int().min(1).max(1_000).optional(),
|
|
20
|
+
};
|
|
21
|
+
const MCP_INSTRUCTIONS = [
|
|
22
|
+
'For a selected activity\'s Strava segments, climb segments, grade by segment, KOM/PR, or per-segment analysis, call get_activity_segments after identifying the activity.',
|
|
23
|
+
'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.',
|
|
24
|
+
'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.',
|
|
25
|
+
'Aggregate elevation alone cannot support an individual-climb conclusion. Use source-specific facts and their stated coverage.',
|
|
26
|
+
].join(' ');
|
|
27
|
+
function textResult(value) {
|
|
28
|
+
return { content: [{ type: 'text', text: JSON.stringify(jsonSafe(value), null, 2) }] };
|
|
29
|
+
}
|
|
30
|
+
function errorResult(error) {
|
|
31
|
+
const classified = error instanceof DataWriteBusyError
|
|
32
|
+
? { code: 'data_sync_in_progress', message: error.message, retryable: true }
|
|
33
|
+
: error instanceof StravaRateLimitError
|
|
34
|
+
? { code: 'rate_limited', message: error.message, retryable: true, retryAfterSeconds: error.retryAfterSeconds ?? null }
|
|
35
|
+
: error instanceof StravaEnrichmentError
|
|
36
|
+
? { code: 'strava_connection_error', message: error.message }
|
|
37
|
+
: error instanceof ReadOnlyDatabaseError
|
|
38
|
+
? { code: error.code, message: error.message }
|
|
39
|
+
: error instanceof QueryValidationError
|
|
40
|
+
? { code: 'invalid_request', message: error.message }
|
|
41
|
+
: { code: 'data_unavailable', message: error instanceof Error ? error.message : String(error) };
|
|
42
|
+
return { ...textResult({ data: null, provenance: {}, query: {}, caveats: [classified.message], error: classified }), isError: true };
|
|
43
|
+
}
|
|
44
|
+
function hydrationFailure(error) {
|
|
45
|
+
if (error instanceof DataWriteBusyError)
|
|
46
|
+
return { code: 'data_sync_in_progress', message: error.message, retryable: true };
|
|
47
|
+
if (error instanceof StravaRateLimitError)
|
|
48
|
+
return { code: 'rate_limited', message: error.message, retryable: true, retryAfterSeconds: error.retryAfterSeconds ?? null };
|
|
49
|
+
if (error instanceof StravaEnrichmentError)
|
|
50
|
+
return { code: 'strava_connection_error', message: error.message };
|
|
51
|
+
return { code: 'data_unavailable', message: error instanceof Error ? error.message : String(error) };
|
|
52
|
+
}
|
|
53
|
+
async function useRepository(paths, fn) {
|
|
54
|
+
const repository = await openReadOnlyRepository(paths);
|
|
55
|
+
try {
|
|
56
|
+
return await fn(repository);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await repository.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function resource(uri, value) {
|
|
63
|
+
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(jsonSafe(value), null, 2) }] };
|
|
64
|
+
}
|
|
65
|
+
function stravaActivityIds(hydration) {
|
|
66
|
+
const identifiers = new Set();
|
|
67
|
+
const collect = (value) => {
|
|
68
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
69
|
+
return;
|
|
70
|
+
const record = value;
|
|
71
|
+
if (typeof record.stravaActivityId === 'string' && record.stravaActivityId)
|
|
72
|
+
identifiers.add(record.stravaActivityId);
|
|
73
|
+
if (Array.isArray(record.childOutcomes)) {
|
|
74
|
+
for (const outcome of record.childOutcomes) {
|
|
75
|
+
if (outcome && typeof outcome === 'object' && !Array.isArray(outcome))
|
|
76
|
+
collect(outcome.result);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
collect(hydration);
|
|
81
|
+
return [...identifiers];
|
|
82
|
+
}
|
|
83
|
+
function utcDate() {
|
|
84
|
+
return new Date().toISOString().slice(0, 10);
|
|
85
|
+
}
|
|
86
|
+
function subtractDays(date, days) {
|
|
87
|
+
const value = new Date(`${date}T00:00:00.000Z`);
|
|
88
|
+
value.setUTCDate(value.getUTCDate() - days);
|
|
89
|
+
return value.toISOString().slice(0, 10);
|
|
90
|
+
}
|
|
91
|
+
async function activitySegments(repository, activityId, hydratedStravaActivityIds, limit) {
|
|
92
|
+
const hydratedSourceClause = hydratedStravaActivityIds.length
|
|
93
|
+
? ` OR source.activity_source_id IN (${hydratedStravaActivityIds.map((_, index) => `$hydratedSource${index}`).join(', ')})`
|
|
94
|
+
: '';
|
|
95
|
+
const values = { activityId, limit };
|
|
96
|
+
for (const [index, stravaActivityId] of hydratedStravaActivityIds.entries())
|
|
97
|
+
values[`hydratedSource${index}`] = `strava:${stravaActivityId}`;
|
|
98
|
+
return repository.rows(`
|
|
99
|
+
WITH selected_activity AS (
|
|
100
|
+
SELECT activity_id FROM activity_sources WHERE activity_source_id = $activityId
|
|
101
|
+
UNION
|
|
102
|
+
SELECT activity_id FROM activities WHERE activity_id = $activityId
|
|
103
|
+
)
|
|
104
|
+
SELECT effort.activity_source_id, effort.effort_id, effort.segment_id,
|
|
105
|
+
segment.name AS segment_name, segment.average_grade_pct, segment.maximum_grade_pct,
|
|
106
|
+
segment.climb_category, segment.total_elevation_gain_m,
|
|
107
|
+
effort.elapsed_s, effort.moving_s, effort.distance_m, effort.average_watts,
|
|
108
|
+
effort.average_hr, effort.max_hr, effort.average_cadence, effort.device_watts,
|
|
109
|
+
effort.pr_rank, effort.kom_rank, cast(effort.started_at AS VARCHAR) AS started_at,
|
|
110
|
+
effort.raw_object_hash
|
|
111
|
+
FROM activity_segments AS effort
|
|
112
|
+
JOIN activity_sources AS source USING (activity_source_id)
|
|
113
|
+
LEFT JOIN strava_segments AS segment USING (segment_id)
|
|
114
|
+
WHERE (source.activity_id IN (SELECT activity_id FROM selected_activity)${hydratedSourceClause})
|
|
115
|
+
ORDER BY effort.started_at ASC NULLS LAST, effort.effort_id ASC
|
|
116
|
+
LIMIT $limit
|
|
117
|
+
`, values);
|
|
118
|
+
}
|
|
119
|
+
export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}) {
|
|
120
|
+
const server = new McpServer({ name: 'catence', version: '0.1.0' }, { instructions: MCP_INSTRUCTIONS });
|
|
121
|
+
const hydrateActivity = dependencies.hydrateStravaActivity ?? hydrateStravaActivity;
|
|
122
|
+
const configPromise = loadCatenceConfig(paths);
|
|
123
|
+
const demoStorePromise = demoStoreMetadata(paths);
|
|
124
|
+
const limiter = new SlidingWindowLimiter();
|
|
125
|
+
const tool = (name, fn) => async (input) => {
|
|
126
|
+
try {
|
|
127
|
+
const config = await configPromise;
|
|
128
|
+
const decision = limiter.check(`tool:${name}`, configuredMcpRateLimit(config, 'tools', name));
|
|
129
|
+
if (!decision.allowed)
|
|
130
|
+
return { ...textResult({ data: null, error: { code: 'rate_limited', message: `MCP tool ${name} is locally rate limited.`, retryAfterSeconds: decision.retryAfterSeconds } }), isError: true };
|
|
131
|
+
const value = await fn(input);
|
|
132
|
+
const demoStore = await demoStorePromise;
|
|
133
|
+
if (!demoStore || !value || typeof value !== 'object' || Array.isArray(value))
|
|
134
|
+
return textResult(value);
|
|
135
|
+
const result = value;
|
|
136
|
+
const caveats = Array.isArray(result.caveats) ? result.caveats : [];
|
|
137
|
+
return textResult({
|
|
138
|
+
...result,
|
|
139
|
+
demoStore: { generated: true, seed: demoStore.seed, days: demoStore.days, startDate: demoStore.startDate, endDate: demoStore.endDate },
|
|
140
|
+
caveats: [...caveats, 'This response uses Catence generated demo data, not personal measurements.'],
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
return errorResult(error);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const resourceLimit = async (name, uri) => {
|
|
148
|
+
try {
|
|
149
|
+
const config = await configPromise;
|
|
150
|
+
const decision = limiter.check(`resource:${name}`, configuredMcpRateLimit(config, 'resources', name));
|
|
151
|
+
if (!decision.allowed)
|
|
152
|
+
return resource(uri, { error: { code: 'rate_limited', message: `MCP resource ${name} is locally rate limited.`, retryAfterSeconds: decision.retryAfterSeconds } });
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return resource(uri, { error: JSON.parse(errorResult(error).content[0].text) });
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
server.registerPrompt('daily_recovery_load_review', {
|
|
160
|
+
title: 'Daily recovery and load review',
|
|
161
|
+
description: 'Ask for a source-cited recovery and training-load review for one date.',
|
|
162
|
+
argsSchema: { date: z.string().date().optional() },
|
|
163
|
+
}, ({ date }) => ({
|
|
164
|
+
messages: [{
|
|
165
|
+
role: 'user',
|
|
166
|
+
content: { type: 'text', text: `Run review_daily_recovery_load for ${date ?? utcDate()}. Summarize only returned evidence, name data gaps, and do not present a training prescription as fact.` },
|
|
167
|
+
}],
|
|
168
|
+
}));
|
|
169
|
+
server.registerPrompt('weekly_training_review', {
|
|
170
|
+
title: 'Weekly training review',
|
|
171
|
+
description: 'Ask for a source-cited seven-day training review ending on an optional date.',
|
|
172
|
+
argsSchema: { endDate: z.string().date().optional() },
|
|
173
|
+
}, ({ endDate }) => ({
|
|
174
|
+
messages: [{
|
|
175
|
+
role: 'user',
|
|
176
|
+
content: { type: 'text', text: `Run review_weekly_training ending on ${endDate ?? utcDate()}. Explain volume, load, and recovery evidence with dates and coverage caveats.` },
|
|
177
|
+
}],
|
|
178
|
+
}));
|
|
179
|
+
server.registerPrompt('activity_deep_dive', {
|
|
180
|
+
title: 'Activity deep dive',
|
|
181
|
+
description: 'Ask for an evidence-first deep dive into one canonical Catence activity.',
|
|
182
|
+
argsSchema: { activityId: z.string().min(1) },
|
|
183
|
+
}, ({ activityId }) => ({
|
|
184
|
+
messages: [{
|
|
185
|
+
role: 'user',
|
|
186
|
+
content: { type: 'text', text: `Run review_activity_deep_dive for ${activityId}. Use its source records and intervals first; load read_series only if a specific sampled metric is needed.` },
|
|
187
|
+
}],
|
|
188
|
+
}));
|
|
189
|
+
server.registerTool('catence_status', {
|
|
190
|
+
title: 'Catence data status',
|
|
191
|
+
description: 'Read sync state, data coverage, entity counts, stream availability, unresolved errors, and retrieval-index freshness. Read-only.',
|
|
192
|
+
}, tool('catence_status', async () => useRepository(paths, async (repository) => ({
|
|
193
|
+
data: { ...(await repository.status()), ...(await repository.coverage()) },
|
|
194
|
+
provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
|
|
195
|
+
}))));
|
|
196
|
+
server.registerTool('review_daily_recovery_load', {
|
|
197
|
+
title: 'Review daily recovery and training load',
|
|
198
|
+
description: 'Return source-cited daily health, training, and nutrition facts for a recovery/load review. It does not prescribe a training plan.',
|
|
199
|
+
inputSchema: { date: z.string().date().optional() },
|
|
200
|
+
}, tool('review_daily_recovery_load', async (input) => {
|
|
201
|
+
const date = input.date ?? utcDate();
|
|
202
|
+
return useRepository(paths, async (repository) => ({
|
|
203
|
+
data: { date, ...(await repository.summary(date, date)) },
|
|
204
|
+
provenance: { relations: ['daily_health', 'canonical_activity_training', 'nutrition_days'], database: 'read-only DuckDB snapshot' },
|
|
205
|
+
query: { date },
|
|
206
|
+
caveats: [
|
|
207
|
+
'This is an evidence bundle for an LLM or coach to interpret; it is not a generated training prescription.',
|
|
208
|
+
'Absent health, training, or nutrition rows indicate unavailable source coverage rather than a zero value.',
|
|
209
|
+
],
|
|
210
|
+
}));
|
|
211
|
+
}));
|
|
212
|
+
server.registerTool('review_weekly_training', {
|
|
213
|
+
title: 'Review seven days of training',
|
|
214
|
+
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.',
|
|
215
|
+
inputSchema: { endDate: z.string().date().optional() },
|
|
216
|
+
}, tool('review_weekly_training', async (input) => {
|
|
217
|
+
const endDate = input.endDate ?? utcDate();
|
|
218
|
+
const startDate = subtractDays(endDate, 6);
|
|
219
|
+
return useRepository(paths, async (repository) => ({
|
|
220
|
+
data: { startDate, endDate, ...(await repository.summary(startDate, endDate)) },
|
|
221
|
+
provenance: { relations: ['daily_health', 'canonical_activity_training', 'nutrition_days'], database: 'read-only DuckDB snapshot' },
|
|
222
|
+
query: { startDate, endDate },
|
|
223
|
+
caveats: [
|
|
224
|
+
'This is an evidence bundle for an LLM or coach to interpret; it is not a generated training prescription.',
|
|
225
|
+
'A day without a row is unavailable source coverage, not an inferred rest day.',
|
|
226
|
+
],
|
|
227
|
+
}));
|
|
228
|
+
}));
|
|
229
|
+
server.registerTool('review_activity_deep_dive', {
|
|
230
|
+
title: 'Review one activity in depth',
|
|
231
|
+
description: 'Return canonical activity identity, source summaries, and structured intervals. Call read_series separately only when a specific sampled metric is required.',
|
|
232
|
+
inputSchema: { activityId: z.string().min(1) },
|
|
233
|
+
}, tool('review_activity_deep_dive', async (input) => useRepository(paths, async (repository) => {
|
|
234
|
+
const activity = await repository.activity(input.activityId);
|
|
235
|
+
return {
|
|
236
|
+
data: activity,
|
|
237
|
+
provenance: { relations: ['activities', 'activity_sources', 'activity_summary_facts', 'activity_interval_facts'], database: 'read-only DuckDB snapshot' },
|
|
238
|
+
query: input,
|
|
239
|
+
caveats: activity
|
|
240
|
+
? ['This workflow does not infer interval structure or sampled metrics that providers did not supply. Use read_series for a bounded requested metric.']
|
|
241
|
+
: ['No canonical activity matched this activityId. Use find_activities before drawing conclusions.'],
|
|
242
|
+
};
|
|
243
|
+
})));
|
|
244
|
+
server.registerTool('describe_data', {
|
|
245
|
+
title: 'Describe available Catence datasets',
|
|
246
|
+
description: 'List cataloged datasets, fields, units, permitted filters/groupings, providers, and time coverage. Read-only.',
|
|
247
|
+
}, tool('describe_data', async () => useRepository(paths, async (repository) => ({
|
|
248
|
+
data: { datasets: repository.catalog(), ...(await repository.coverage()) },
|
|
249
|
+
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.'],
|
|
250
|
+
}))));
|
|
251
|
+
server.registerTool('describe_dataset', {
|
|
252
|
+
title: 'Describe one Catence dataset',
|
|
253
|
+
description: 'Read a compact schema, permitted filters/groupings, provenance fields, and coverage for one cataloged dataset. Read-only.',
|
|
254
|
+
inputSchema: { dataset: z.string().min(1) },
|
|
255
|
+
}, tool('describe_dataset', async (input) => useRepository(paths, async (repository) => {
|
|
256
|
+
const dataset = getDataset(input.dataset);
|
|
257
|
+
const coverage = (await repository.coverage()).coverage;
|
|
258
|
+
const observedValues = dataset.name === 'training_metric_observations'
|
|
259
|
+
? await (async () => {
|
|
260
|
+
const [sports, metricNames, units, sourceTypes] = await Promise.all([
|
|
261
|
+
repository.rows(`SELECT distinct sport FROM training_metric_observations WHERE sport IS NOT NULL ORDER BY sport ASC`),
|
|
262
|
+
repository.rows(`SELECT distinct metric_name FROM training_metric_observations ORDER BY metric_name ASC`),
|
|
263
|
+
repository.rows(`SELECT distinct unit FROM training_metric_observations WHERE unit IS NOT NULL ORDER BY unit ASC`),
|
|
264
|
+
repository.rows(`SELECT distinct source_type FROM training_metric_observations ORDER BY source_type ASC`),
|
|
265
|
+
]);
|
|
266
|
+
return { sports: sports.map((row) => row.sport), metricNames: metricNames.map((row) => row.metric_name), units: units.map((row) => row.unit), sourceTypes: sourceTypes.map((row) => row.source_type) };
|
|
267
|
+
})()
|
|
268
|
+
: undefined;
|
|
269
|
+
return {
|
|
270
|
+
data: { dataset, coverage: coverage.find((item) => item.dataset === dataset.name) ?? null, observedValues },
|
|
271
|
+
provenance: { catalog: 'Catence catalog', database: 'read-only DuckDB snapshot' }, query: input, caveats: [],
|
|
272
|
+
};
|
|
273
|
+
})));
|
|
274
|
+
server.registerTool('read_series', {
|
|
275
|
+
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,
|
|
276
|
+
}, tool('read_series', async (input) => useRepository(paths, (repository) => new AnalyticsService(repository).readSeries({ ...input, filters: input.filters }))));
|
|
277
|
+
server.registerTool('aggregate_data', {
|
|
278
|
+
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.',
|
|
279
|
+
inputSchema: {
|
|
280
|
+
dataset: z.string().min(1),
|
|
281
|
+
metrics: z.array(z.object({ column: z.string().min(1), operation: z.enum(['count', 'sum', 'mean', 'min', 'max', 'percentile']), percentile: z.number().gt(0).lt(1).optional(), as: z.string().min(1).max(64).optional() })).min(1).max(12),
|
|
282
|
+
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(),
|
|
283
|
+
},
|
|
284
|
+
}, tool('aggregate_data', async (input) => useRepository(paths, (repository) => new AnalyticsService(repository).aggregate({ ...input, filters: input.filters }))));
|
|
285
|
+
server.registerTool('analyze_series', {
|
|
286
|
+
title: 'Analyze a descriptive series', description: 'Run deterministic rolling statistics, baselines, correlations, seasonal comparisons, or trends on a cataloged series.',
|
|
287
|
+
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() },
|
|
288
|
+
}, tool('analyze_series', async (input) => useRepository(paths, (repository) => new AnalyticsService(repository).analyzeSeries({ ...input, filters: input.filters }))));
|
|
289
|
+
server.registerTool('fit_series_model', {
|
|
290
|
+
title: 'Fit a descriptive series model', description: 'Fit a bounded OLS, Theil–Sen, quadratic, or cubic descriptive model. Not a sport-performance model.',
|
|
291
|
+
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() },
|
|
292
|
+
}, tool('fit_series_model', async (input) => useRepository(paths, (repository) => new AnalyticsService(repository).fitSeriesModel({ ...input, filters: input.filters }))));
|
|
293
|
+
server.registerTool('wellness_correlate', {
|
|
294
|
+
title: 'Correlate recovery and training metrics',
|
|
295
|
+
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.',
|
|
296
|
+
inputSchema: {
|
|
297
|
+
metricA: z.enum(WELLNESS_METRICS), metricB: z.enum(WELLNESS_METRICS), startDate: z.string().date().optional(), endDate: z.string().date().optional(),
|
|
298
|
+
method: z.enum(['pearson', 'spearman']).optional(), lagDays: z.number().int().min(-30).max(30).optional(), scanLags: z.boolean().optional(),
|
|
299
|
+
},
|
|
300
|
+
}, tool('wellness_correlate', async (input) => useRepository(paths, (repository) => new WellnessService(repository).correlate(input))));
|
|
301
|
+
server.registerTool('wellness_baselines', {
|
|
302
|
+
title: 'Read personal wellness baselines',
|
|
303
|
+
description: 'Return trailing means, standard-deviation bands, latest values, and latest z-scores for common recovery and training metrics. Missing values remain missing.',
|
|
304
|
+
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() },
|
|
305
|
+
}, tool('wellness_baselines', async (input) => useRepository(paths, (repository) => new WellnessService(repository).baselines(input))));
|
|
306
|
+
server.registerTool('wellness_anomalies', {
|
|
307
|
+
title: 'Find statistical wellness anomalies',
|
|
308
|
+
description: 'Find daily recovery and wellness outliers by z-score and group dates with anomalies across multiple signals. This does not diagnose or prescribe.',
|
|
309
|
+
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() },
|
|
310
|
+
}, tool('wellness_anomalies', async (input) => useRepository(paths, (repository) => new WellnessService(repository).anomalies(input))));
|
|
311
|
+
server.registerTool('wellness_coverage', {
|
|
312
|
+
title: 'Inspect wellness data coverage',
|
|
313
|
+
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.',
|
|
314
|
+
inputSchema: { metrics: z.array(z.enum(WELLNESS_METRICS)).min(1).max(12).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
315
|
+
}, tool('wellness_coverage', async (input) => useRepository(paths, (repository) => new WellnessService(repository).coverage(input))));
|
|
316
|
+
server.registerTool('get_ftp_history', {
|
|
317
|
+
title: 'Get dated FTP history',
|
|
318
|
+
description: 'Return normalized cycling FTP settings and activity-summary observations with source-aware preferred daily values. Read-only.',
|
|
319
|
+
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() },
|
|
320
|
+
}, tool('get_ftp_history', async (input) => useRepository(paths, (repository) => new FitnessService(repository).ftpHistory(input))));
|
|
321
|
+
server.registerTool('get_vo2max_history', {
|
|
322
|
+
title: 'Get sport-specific VO₂max history',
|
|
323
|
+
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.',
|
|
324
|
+
inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
325
|
+
}, tool('get_vo2max_history', async (input) => useRepository(paths, (repository) => new FitnessService(repository).vo2MaxHistory(input))));
|
|
326
|
+
server.registerTool('find_activities', {
|
|
327
|
+
title: 'Find activities and likely races',
|
|
328
|
+
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.',
|
|
329
|
+
inputSchema: {
|
|
330
|
+
sports: z.array(z.string().min(1)).min(1).max(12).optional(),
|
|
331
|
+
distanceKm: z.tuple([z.number().min(0), z.number().min(0)]).optional(),
|
|
332
|
+
nameContains: z.array(z.string().min(1)).min(1).max(12).optional(),
|
|
333
|
+
startDate: z.string().date().optional(), endDate: z.string().date().optional(),
|
|
334
|
+
sort: z.enum(['date_desc', 'date_asc']).optional(), limit: z.number().int().min(1).max(100).optional(), cursor: z.string().min(1).optional(),
|
|
335
|
+
},
|
|
336
|
+
}, tool('find_activities', async (input) => useRepository(paths, (repository) => new ActivityDiscoveryService(repository).findActivities(input))));
|
|
337
|
+
server.registerTool('get_swim_laps', {
|
|
338
|
+
title: 'Read explicit swim lengths and grouped sets',
|
|
339
|
+
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.',
|
|
340
|
+
inputSchema: { activityId: z.string().min(1), provider: z.enum(['garmin', 'intervals']).optional() },
|
|
341
|
+
}, tool('get_swim_laps', async (input) => useRepository(paths, (repository) => new SwimmingService(repository).swimLaps(input))));
|
|
342
|
+
server.registerTool('swim_progress_report', {
|
|
343
|
+
title: 'Build a source-aware swim progress report',
|
|
344
|
+
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.',
|
|
345
|
+
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), poolLengthM: z.number().positive().max(200).optional() },
|
|
346
|
+
}, tool('swim_progress_report', async (input) => useRepository(paths, (repository) => new SwimmingService(repository).swimProgressReport(input))));
|
|
347
|
+
server.registerTool('get_activity_segments', {
|
|
348
|
+
title: 'Hydrate and read an activity’s Strava segments',
|
|
349
|
+
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.',
|
|
350
|
+
inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional(), limit: z.number().int().min(1).max(500).optional() },
|
|
351
|
+
}, tool('get_activity_segments', async (input) => {
|
|
352
|
+
const hydration = await hydrateActivity(paths, input.activityId, input.refresh ?? false);
|
|
353
|
+
const hydratedStravaActivityIds = stravaActivityIds(hydration);
|
|
354
|
+
const segments = await useRepository(paths, (repository) => activitySegments(repository, input.activityId, hydratedStravaActivityIds, input.limit ?? 200));
|
|
355
|
+
return {
|
|
356
|
+
data: { hydration, hydratedStravaActivityIds, segments },
|
|
357
|
+
provenance: { provider: 'strava', relations: ['activity_segments', 'strava_segments'], operation: 'targeted_activity_hydration_then_read' },
|
|
358
|
+
query: { ...input, refresh: input.refresh ?? false, limit: input.limit ?? 200 },
|
|
359
|
+
caveats: [
|
|
360
|
+
'Strava hydration is attempted before these rows are read. If segments is empty, inspect the hydration status and match diagnostics before treating segment data as unavailable.',
|
|
361
|
+
'Segment verification is unavailable from Strava public segment data and is never inferred.',
|
|
362
|
+
],
|
|
363
|
+
};
|
|
364
|
+
}));
|
|
365
|
+
server.registerTool('power_curve_trend', {
|
|
366
|
+
title: 'Read labelled power-curve trends',
|
|
367
|
+
description: 'Return monthly bests for selected power durations from an explicit sport or sport family, including the supporting activity and source type. Read-only.',
|
|
368
|
+
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() },
|
|
369
|
+
}, tool('power_curve_trend', async (input) => useRepository(paths, (repository) => new FitnessService(repository).powerCurveTrend(input))));
|
|
370
|
+
server.registerTool('power_coverage_report', {
|
|
371
|
+
title: 'Inspect FIT-derived power coverage',
|
|
372
|
+
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.',
|
|
373
|
+
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() },
|
|
374
|
+
}, tool('power_coverage_report', async (input) => useRepository(paths, (repository) => new FitnessService(repository).powerCoverageReport(input))));
|
|
375
|
+
server.registerTool('latest_cycling_activities', {
|
|
376
|
+
title: 'List latest cycling activities',
|
|
377
|
+
description: 'List Garmin cycling source records without duplicate Intervals summaries, optionally flagging multisport parent records. Read-only.',
|
|
378
|
+
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), includeMultisport: z.boolean().optional(), limit: z.number().int().min(1).max(100).optional() },
|
|
379
|
+
}, tool('latest_cycling_activities', async (input) => useRepository(paths, (repository) => new FitnessService(repository).latestCyclingActivities(input))));
|
|
380
|
+
server.registerTool('cycling_progress_report', {
|
|
381
|
+
title: 'Build a cycling progress report',
|
|
382
|
+
description: 'Combine source-aware FTP and VO₂max histories with monthly canonical volume/load and labelled power-curve trends. Read-only and descriptive.',
|
|
383
|
+
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
384
|
+
}, tool('cycling_progress_report', async (input) => useRepository(paths, (repository) => new FitnessService(repository).cyclingProgressReport(input))));
|
|
385
|
+
server.registerTool('query_read_only_data', {
|
|
386
|
+
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.',
|
|
387
|
+
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() },
|
|
388
|
+
}, tool('query_read_only_data', async (input) => useRepository(paths, (repository) => queryReadOnlyData(repository, input))));
|
|
389
|
+
server.registerTool('search_context', {
|
|
390
|
+
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.',
|
|
391
|
+
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() },
|
|
392
|
+
}, tool('search_context', async (input) => useRepository(paths, (repository) => searchContext(repository, input))));
|
|
393
|
+
server.registerTool('hydrate_strava_activity', {
|
|
394
|
+
title: 'Hydrate one activity from Strava',
|
|
395
|
+
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.',
|
|
396
|
+
inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional() },
|
|
397
|
+
}, tool('hydrate_strava_activity', async (input) => {
|
|
398
|
+
const data = await hydrateActivity(paths, input.activityId, input.refresh ?? false);
|
|
399
|
+
return { data, coverage: await useRepository(paths, (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.'] };
|
|
400
|
+
}));
|
|
401
|
+
server.registerTool('hydrate_recent_strava_activities', {
|
|
402
|
+
title: 'Hydrate several recent Strava activities',
|
|
403
|
+
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.',
|
|
404
|
+
inputSchema: z.object({
|
|
405
|
+
activityIds: z.array(z.string().min(1)).min(1).max(20).optional(),
|
|
406
|
+
startDate: z.string().date().optional(), endDate: z.string().date().optional(), sports: z.array(z.string().min(1)).min(1).max(12).optional(),
|
|
407
|
+
limit: z.number().int().min(1).max(20).optional(), refresh: z.boolean().optional(),
|
|
408
|
+
}).refine((value) => Boolean(value.activityIds?.length) || Boolean(value.startDate && value.endDate), 'Provide activityIds or both startDate and endDate.'),
|
|
409
|
+
}, tool('hydrate_recent_strava_activities', async (input) => {
|
|
410
|
+
const activityIds = input.activityIds
|
|
411
|
+
? [...new Set(input.activityIds)]
|
|
412
|
+
: await useRepository(paths, async (repository) => {
|
|
413
|
+
const values = {
|
|
414
|
+
startDate: input.startDate, endDate: `${input.endDate}T23:59:59.999Z`, limit: input.limit ?? 20,
|
|
415
|
+
};
|
|
416
|
+
const clauses = [
|
|
417
|
+
"source.provider = 'garmin'",
|
|
418
|
+
'activity.started_at_utc >= cast($startDate AS TIMESTAMPTZ)',
|
|
419
|
+
'activity.started_at_utc <= cast($endDate AS TIMESTAMPTZ)',
|
|
420
|
+
];
|
|
421
|
+
if (input.sports?.length) {
|
|
422
|
+
const placeholders = input.sports.map((sport, index) => {
|
|
423
|
+
const key = `sport${index}`;
|
|
424
|
+
values[key] = sport;
|
|
425
|
+
return `lower($${key})`;
|
|
426
|
+
});
|
|
427
|
+
clauses.push(`lower(activity.sport) IN (${placeholders.join(', ')})`);
|
|
428
|
+
}
|
|
429
|
+
const rows = await repository.rows(`
|
|
430
|
+
SELECT source.activity_source_id
|
|
431
|
+
FROM activity_sources AS source JOIN activities AS activity USING (activity_id)
|
|
432
|
+
WHERE ${clauses.join(' AND ')}
|
|
433
|
+
ORDER BY activity.started_at_utc DESC, source.activity_source_id ASC
|
|
434
|
+
LIMIT $limit
|
|
435
|
+
`, values);
|
|
436
|
+
return rows.map((row) => row.activity_source_id);
|
|
437
|
+
});
|
|
438
|
+
const outcomes = [];
|
|
439
|
+
let haltedBy = null;
|
|
440
|
+
for (const activityId of activityIds) {
|
|
441
|
+
if (haltedBy) {
|
|
442
|
+
outcomes.push({ activityId, status: 'skipped', reason: haltedBy });
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const result = await hydrateStravaActivity(paths, activityId, input.refresh ?? false);
|
|
447
|
+
const status = typeof result.status === 'string' ? result.status : 'completed';
|
|
448
|
+
outcomes.push({ activityId, status, result });
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
const failure = hydrationFailure(error);
|
|
452
|
+
outcomes.push({ activityId, status: 'failed', error: failure });
|
|
453
|
+
if (failure.code === 'rate_limited' || failure.code === 'data_sync_in_progress')
|
|
454
|
+
haltedBy = failure.message;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const summary = outcomes.reduce((counts, outcome) => {
|
|
458
|
+
const status = String(outcome.status);
|
|
459
|
+
counts[status] = (counts[status] ?? 0) + 1;
|
|
460
|
+
return counts;
|
|
461
|
+
}, {});
|
|
462
|
+
return {
|
|
463
|
+
data: { activityIds, outcomes, summary },
|
|
464
|
+
provenance: { provider: 'strava', operation: 'serial_activity_hydration', archivedBeforeNormalization: true },
|
|
465
|
+
query: { ...input, activityIds },
|
|
466
|
+
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.'],
|
|
467
|
+
};
|
|
468
|
+
}));
|
|
469
|
+
server.registerTool('hydrate_strava_segment_history', {
|
|
470
|
+
title: 'Hydrate one Strava segment history',
|
|
471
|
+
description: 'Write-only targeted enrichment. Fetches a persisted Strava segment and the authenticated athlete’s paged historic efforts; interrupted or throttled work is resumable.',
|
|
472
|
+
inputSchema: { segmentId: z.string().min(1), refresh: z.boolean().optional() },
|
|
473
|
+
}, tool('hydrate_strava_segment_history', async (input) => {
|
|
474
|
+
const data = await hydrateStravaSegmentHistory(paths, input.segmentId, input.refresh ?? false);
|
|
475
|
+
return { data, coverage: await useRepository(paths, (repository) => repository.coverage()), provenance: { provider: 'strava', operation: 'segment_effort_history', archivedBeforeNormalization: true }, caveats: ['Only the authenticated athlete’s historic segment efforts are requested.'] };
|
|
476
|
+
}));
|
|
477
|
+
server.registerResource('status', 'catence://status', { title: 'Catence status', mimeType: 'application/json' }, async (uri) => {
|
|
478
|
+
const limited = await resourceLimit('status', uri);
|
|
479
|
+
if (limited)
|
|
480
|
+
return limited;
|
|
481
|
+
try {
|
|
482
|
+
return resource(uri, await useRepository(paths, (repository) => repository.status()));
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
return resource(uri, { error: errorResult(error).content[0].text });
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
server.registerResource('catalog', 'catence://catalog', { title: 'Catence catalog', mimeType: 'application/json' }, async (uri) => { const limited = await resourceLimit('catalog', uri); if (limited)
|
|
489
|
+
return limited; return resource(uri, { datasets: Object.values(DATASET_CATALOG) }); });
|
|
490
|
+
server.registerResource('activity', new ResourceTemplate('catence://activity/{activityId}', { list: undefined }), { title: 'Activity detail', mimeType: 'application/json' }, async (uri, variables) => {
|
|
491
|
+
const limited = await resourceLimit('activity', uri);
|
|
492
|
+
if (limited)
|
|
493
|
+
return limited;
|
|
494
|
+
try {
|
|
495
|
+
return resource(uri, await useRepository(paths, (repository) => repository.activity(variable(variables.activityId))));
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
return resource(uri, { error: errorResult(error).content[0].text });
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
server.registerResource('summary', new ResourceTemplate('catence://summary/{startDate}/{endDate}', { list: undefined }), { title: 'Catence date-range summary', mimeType: 'application/json' }, async (uri, variables) => {
|
|
502
|
+
const limited = await resourceLimit('summary', uri);
|
|
503
|
+
if (limited)
|
|
504
|
+
return limited;
|
|
505
|
+
try {
|
|
506
|
+
return resource(uri, await useRepository(paths, (repository) => repository.summary(variable(variables.startDate), variable(variables.endDate))));
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
return resource(uri, { error: errorResult(error).content[0].text });
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
return server;
|
|
513
|
+
}
|
|
514
|
+
function variable(value) {
|
|
515
|
+
if (typeof value === 'string')
|
|
516
|
+
return value;
|
|
517
|
+
if (Array.isArray(value) && typeof value[0] === 'string')
|
|
518
|
+
return value[0];
|
|
519
|
+
throw new QueryValidationError('A required resource URI variable is missing.');
|
|
520
|
+
}
|
|
521
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal runtime facade.
|
|
3
|
+
*
|
|
4
|
+
* Transports (CLI, MCP, and HTTP) import only from this module. The concrete
|
|
5
|
+
* ETL, storage, analysis, and provider implementation remains free to evolve
|
|
6
|
+
* behind this boundary and can later move into private workspace packages
|
|
7
|
+
* without changing the public product adapters.
|
|
8
|
+
*/
|
|
9
|
+
export type { CatencePaths } from '../contracts/runtime.js';
|
|
10
|
+
export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth } from '../contracts/release.js';
|
|
11
|
+
export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
|
|
12
|
+
export { SlidingWindowLimiter } from '../core/runtime/limiter.js';
|
|
13
|
+
export { DashboardSnapshotService } from '../core/dashboard/snapshot.js';
|
|
14
|
+
export { searchContext } from '../core/retrieval/index.js';
|
|
15
|
+
export { AnalyticsService } from '../core/query/analytics.js';
|
|
16
|
+
export type { DataFilter } from '../core/query/analytics.js';
|
|
17
|
+
export { ActivityDiscoveryService } from '../core/query/activity-discovery.js';
|
|
18
|
+
export { DATASET_CATALOG, getDataset, QueryValidationError } from '../core/query/catalog.js';
|
|
19
|
+
export { FitnessService } from '../core/query/fitness.js';
|
|
20
|
+
export { SwimmingService } from '../core/query/swimming.js';
|
|
21
|
+
export { WELLNESS_METRICS, WellnessService } from '../core/query/wellness.js';
|
|
22
|
+
export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
|
|
23
|
+
export { queryReadOnlyData } from '../core/query/sql-guard.js';
|
|
24
|
+
export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
|
|
25
|
+
export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, unlinkActivity, } from '../elt/application/management.js';
|
|
26
|
+
export type { ProviderChoice } from '../elt/application/management.js';
|
|
27
|
+
export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
|
|
28
|
+
export { DataWriteBusyError } from '../elt/storage/write-lock.js';
|
|
29
|
+
export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth } from '../contracts/release.js';
|
|
2
|
+
export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
|
|
3
|
+
export { SlidingWindowLimiter } from '../core/runtime/limiter.js';
|
|
4
|
+
export { DashboardSnapshotService } from '../core/dashboard/snapshot.js';
|
|
5
|
+
export { searchContext } from '../core/retrieval/index.js';
|
|
6
|
+
export { AnalyticsService } from '../core/query/analytics.js';
|
|
7
|
+
export { ActivityDiscoveryService } from '../core/query/activity-discovery.js';
|
|
8
|
+
export { DATASET_CATALOG, getDataset, QueryValidationError } from '../core/query/catalog.js';
|
|
9
|
+
export { FitnessService } from '../core/query/fitness.js';
|
|
10
|
+
export { SwimmingService } from '../core/query/swimming.js';
|
|
11
|
+
export { WELLNESS_METRICS, WellnessService } from '../core/query/wellness.js';
|
|
12
|
+
export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
|
|
13
|
+
export { queryReadOnlyData } from '../core/query/sql-guard.js';
|
|
14
|
+
export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
|
|
15
|
+
export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, unlinkActivity, } from '../elt/application/management.js';
|
|
16
|
+
export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
|
|
17
|
+
export { DataWriteBusyError } from '../elt/storage/write-lock.js';
|
|
18
|
+
export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
|
|
19
|
+
//# sourceMappingURL=index.js.map
|