/** * GoalCoachingAgent — A2A pipeline for PeakPulse activity goal recommendations. * * Agent roster (3 steps): * 1. SessionHistoryAgent — fetch recent completed sessions, compute performance trends * 2. GoalAnalysisAgent — compare trends against overall stats, identify improvement areas * 3. GoalReportAgent — propose concrete next goals based on trend analysis * * MCP tools: * peakpulse.goals.coach(activityType?, lookback?) — run pipeline */ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { registerTool } from '../tools/registry.js'; import type { McpToolRequest } from '../tools/types.js'; import { peakpulseSessionsList, peakpulseGetStats, type PeakSessionDoc, } from '../../lib/peakpulse-client.js'; // ── Types ────────────────────────────────────────────────────────────────────── interface PerformanceTrend { metric: string; unit: string; values: number[]; average: number; recent: number; delta: number; trending: 'up' | 'down' | 'flat'; } interface GoalSuggestion { metric: string; currentAverage: number; suggestedTarget: number; unit: string; rationale: string; } export interface GoalCoachingReport { runId: string; productId: 'peakpulse'; activityType: string | null; sessionsAnalyzed: number; trends: PerformanceTrend[]; suggestions: GoalSuggestion[]; overallLevel: 'beginner' | 'intermediate' | 'advanced'; summary: string; generatedAt: string; } // ── Step 1: SessionHistoryAgent ──────────────────────────────────────────────── async function fetchSessionHistory( activityType: string | null, lookback: number, opts: { token?: string; requestId?: string } ): Promise { try { const result = await peakpulseSessionsList( { activityType: activityType ?? undefined, status: 'completed', limit: lookback }, opts ); return result.items.sort( (a: PeakSessionDoc, b: PeakSessionDoc) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() ); } catch { return []; } } // ── Step 2: GoalAnalysisAgent ───────────────────────────────────────────────── function buildTrends(sessions: PeakSessionDoc[]): PerformanceTrend[] { if (sessions.length < 2) return []; const metricDefs: Array<{ key: keyof PeakSessionDoc; unit: string; label: string }> = [ { key: 'durationSeconds', unit: 'min', label: 'duration' }, { key: 'distanceMeters', unit: 'km', label: 'distance' }, { key: 'elevationGainMeters', unit: 'm', label: 'elevation_gain' }, { key: 'averageSpeedMps', unit: 'km/h', label: 'average_speed' }, ]; const trends: PerformanceTrend[] = []; for (const def of metricDefs) { const rawValues = sessions .map(s => s[def.key] as number | undefined) .filter((v): v is number => typeof v === 'number' && v > 0); if (rawValues.length < 2) continue; // Convert units const values = def.key === 'durationSeconds' ? rawValues.map(v => Math.round(v / 60)) : def.key === 'distanceMeters' ? rawValues.map(v => Math.round(v / 100) / 10) : def.key === 'averageSpeedMps' ? rawValues.map(v => Math.round(v * 36) / 10) : rawValues; const average = values.reduce((s, v) => s + v, 0) / values.length; const recent = values[values.length - 1]!; const delta = recent - (values[0] ?? recent); const trending = delta > average * 0.05 ? 'up' : delta < -average * 0.05 ? 'down' : 'flat'; trends.push({ metric: def.label, unit: def.unit, values, average, recent, delta, trending }); } return trends; } function suggestGoals( trends: PerformanceTrend[], stats: Record ): GoalSuggestion[] { const suggestions: GoalSuggestion[] = []; const totalSessions = typeof stats['totalSessions'] === 'number' ? stats['totalSessions'] : 0; for (const trend of trends) { let suggestedTarget = trend.average; let rationale = ''; if (trend.trending === 'up') { // Already improving — push for 10% more suggestedTarget = Math.round(trend.recent * 1.1 * 10) / 10; rationale = `${trend.metric} is trending up (+${trend.delta.toFixed(1)} ${trend.unit}). Push to ${suggestedTarget} ${trend.unit}.`; } else if (trend.trending === 'flat' && totalSessions > 5) { // Plateau — target 15% improvement suggestedTarget = Math.round(trend.average * 1.15 * 10) / 10; rationale = `${trend.metric} has plateaued at avg ${trend.average.toFixed(1)} ${trend.unit}. Try targeting ${suggestedTarget} ${trend.unit} to break through.`; } else if (trend.trending === 'down') { // Declining — target returning to recent average suggestedTarget = Math.round(trend.average * 10) / 10; rationale = `${trend.metric} is declining. Focus on recovering to avg ${suggestedTarget} ${trend.unit}.`; } if (rationale) { suggestions.push({ metric: trend.metric, currentAverage: Math.round(trend.average * 10) / 10, suggestedTarget, unit: trend.unit, rationale, }); } } return suggestions; } function classifyLevel(sessions: PeakSessionDoc[]): 'beginner' | 'intermediate' | 'advanced' { if (sessions.length < 5) return 'beginner'; const avgDuration = sessions.reduce((s, x) => s + (x.durationSeconds ?? 0), 0) / sessions.length; const avgDistance = sessions.reduce((s, x) => s + (x.distanceMeters ?? 0), 0) / sessions.length; if (avgDuration > 7200 && avgDistance > 15000) return 'advanced'; if (avgDuration > 3600 || avgDistance > 8000) return 'intermediate'; return 'beginner'; } // ── Pipeline runner ──────────────────────────────────────────────────────────── async function runGoalCoachingPipeline( activityType: string | null, lookback: number, req: McpToolRequest ): Promise { const runId = randomUUID(); const opts = { token: req.headers.authorization?.slice(7), requestId: req.id }; req.log.info({ runId, stepId: 'history', activityType, lookback }, 'SessionHistoryAgent start'); const sessions = await fetchSessionHistory(activityType, lookback, opts); req.log.info({ runId, stepId: 'history', count: sessions.length }, 'SessionHistoryAgent done'); req.log.info({ runId, stepId: 'analyze' }, 'GoalAnalysisAgent start'); const stats = await peakpulseGetStats(opts).catch(() => ({}) as Record); const trends = buildTrends(sessions); const suggestions = suggestGoals(trends, stats as Record); const overallLevel = classifyLevel(sessions); req.log.info( { runId, stepId: 'analyze', trendCount: trends.length, suggestionCount: suggestions.length }, 'GoalAnalysisAgent done' ); req.log.info({ runId, stepId: 'report' }, 'GoalReportAgent start'); const summary = sessions.length < 2 ? 'Insufficient session history for goal coaching. Complete at least 2 sessions first.' : `Analyzed ${sessions.length} ${activityType ?? 'activity'} session(s). Level: ${overallLevel}. ${suggestions.length} goal suggestion(s) generated.`; req.log.info({ runId, stepId: 'report', summary }, 'GoalReportAgent done'); return { runId, productId: 'peakpulse', activityType, sessionsAnalyzed: sessions.length, trends, suggestions, overallLevel, summary, generatedAt: new Date().toISOString(), }; } // ── MCP tool registration ───────────────────────────────────────────────────── registerTool({ name: 'peakpulse.goals.coach', description: 'A2A pipeline: analyzes PeakPulse session history to identify performance trends (duration, distance, elevation, speed) and proposes concrete next goals. Classifies user as beginner/intermediate/advanced. Optionally filter by activityType (hiking/skiing/cycling/running). Requires admin role.', requiredRole: 'admin', inputSchema: z.object({ activityType: z .enum(['hiking', 'skiing', 'cycling', 'running']) .optional() .describe('Filter sessions by activity type (omit for all types)'), lookback: z.coerce .number() .int() .min(3) .max(50) .default(15) .describe('Number of recent sessions to analyze (default 15)'), }), async execute(args, req) { return runGoalCoachingPipeline(args.activityType ?? null, args.lookback, req); }, });