goal-coaching-pipeline.ts: peakpulse.goals.coach - SessionHistoryAgent -> GoalAnalysisAgent -> GoalReportAgent - Computes duration/distance/elevation/speed trends across recent sessions - Classifies user as beginner/intermediate/advanced; generates metric-specific goal suggestions ski-run-analyst-pipeline.ts: peakpulse.ski.analyze - SkiSessionCollectorAgent -> RunQualityAnalystAgent -> SkiReportAgent - Computes runDensity, verticalPerRun, skiToLiftRatio per session - Flags: low_run_density, high_lift_ratio, short_session, vertical_drop vs baseline org-provisioning-pipeline.ts: lysnrai.orgs.provision - OrgInspectorAgent -> ProvisioningActionAgent -> OrgReportAgent - Checks API tokens + session activity; classifies as new_org/needs_attention/complete protocol-tuning-pipeline.ts: nomgap.protocols.tune - ProtocolStatsCollectorAgent -> AbandonmentAnalysisAgent -> TuningReportAgent - Finds protocols with >40% abandonment; runs extraction for pattern analysis - Proposes duration reduction, milestone notifications, or variant creation calendar-import-pipeline.ts: chronomind.calendar.import - EventValidatorAgent -> ConflictDetectorAgent -> ImportReportAgent - Validates dtstart/dtend; detects time overlaps vs existing timers - Creates alarm timers for conflict-free events; dryRun support MCP server total: 110 tools
235 lines
8.8 KiB
TypeScript
235 lines
8.8 KiB
TypeScript
/**
|
|
* 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<PeakSessionDoc[]> {
|
|
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<string, unknown>
|
|
): 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<GoalCoachingReport> {
|
|
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<string, unknown>);
|
|
const trends = buildTrends(sessions);
|
|
const suggestions = suggestGoals(trends, stats as Record<string, unknown>);
|
|
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);
|
|
},
|
|
});
|