forge-openclaw-plugin 0.2.93 → 0.2.96

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.
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { getDatabase, runInTransaction } from "./db.js";
5
5
  import { HttpError } from "./errors.js";
6
6
  import { buildWorkoutSessionPersistenceSeed, buildWorkoutSessionPresentation, workoutActivityDescriptorSchema, workoutDetailsSchema } from "./health-workout-adapters.js";
7
- import { getHealthZoneProfile, getStoredWorkoutAnalytics, getWorkoutRawEvidence, healthZoneProfilePatchSchema, recomputeAndStoreWorkoutAnalytics, upsertWorkoutRoutePoints, upsertWorkoutTimeSeries, workoutCaptureQualitySchema, workoutRoutePointSchema, workoutTimeSeriesSampleSchema, patchHealthZoneProfile } from "./health-workout-analytics.js";
7
+ import { WORKOUT_ZONE_ORDER, getHealthZoneProfile, getStoredWorkoutAnalytics, getWorkoutRawEvidence, healthZoneProfilePatchSchema, recomputeAndStoreWorkoutAnalytics, upsertWorkoutRoutePoints, upsertWorkoutTimeSeries, workoutCaptureQualitySchema, workoutRoutePointSchema, workoutTimeSeriesSampleSchema, patchHealthZoneProfile } from "./health-workout-analytics.js";
8
8
  import { getMovementMobileBootstrap, ingestMovementSync, movementSyncPayloadSchema } from "./movement.js";
9
9
  import { ingestScreenTimeSync, screenTimeSyncPayloadSchema } from "./screen-time.js";
10
10
  import { recordActivityEvent } from "./repositories/activity-events.js";
@@ -2750,18 +2750,34 @@ function nestedRecord(value) {
2750
2750
  ? value
2751
2751
  : {};
2752
2752
  }
2753
+ const CURRENT_WORKOUT_RAW_EVIDENCE_VERSION = "healthkit-workout-raw-bulk-v4";
2753
2754
  function expectedWorkoutEvidenceCounts(derived) {
2754
2755
  const syncCursor = nestedRecord(derived.syncCursor);
2755
2756
  const captureQuality = nestedRecord(derived.captureQuality);
2757
+ const captureQualityFlags = Array.isArray(captureQuality.flags)
2758
+ ? captureQuality.flags.filter((flag) => typeof flag === "string")
2759
+ : [];
2760
+ const summaryOnlyExport = captureQuality.status === "summary_exported" ||
2761
+ captureQualityFlags.includes("server_side_evidence_derivation");
2756
2762
  const syncTimeSeriesCount = finiteNumberFromUnknown(syncCursor.timeSeriesSampleCount);
2757
- const captureHeartRateCount = finiteNumberFromUnknown(captureQuality.heartRateSamples);
2763
+ const captureHeartRateCount = summaryOnlyExport
2764
+ ? null
2765
+ : finiteNumberFromUnknown(captureQuality.heartRateSamples);
2758
2766
  const syncRoutePointCount = finiteNumberFromUnknown(syncCursor.routePointCount);
2759
- const captureRoutePointCount = finiteNumberFromUnknown(captureQuality.routePoints);
2767
+ const captureRoutePointCount = summaryOnlyExport
2768
+ ? null
2769
+ : finiteNumberFromUnknown(captureQuality.routePoints);
2760
2770
  const expectedTimeSeriesSamples = Math.max(0, Math.ceil(Math.max(syncTimeSeriesCount ?? 0, captureHeartRateCount ?? 0)));
2771
+ const expectedHeartRateSamples = Math.max(0, Math.ceil(captureHeartRateCount ?? 0));
2761
2772
  const expectedRoutePoints = Math.max(0, Math.ceil(Math.max(syncRoutePointCount ?? 0, captureRoutePointCount ?? 0)));
2773
+ const rawEvidenceVersion = typeof syncCursor.rawEvidenceVersion === "string"
2774
+ ? syncCursor.rawEvidenceVersion
2775
+ : "";
2762
2776
  return {
2763
2777
  expectedTimeSeriesSamples,
2778
+ expectedHeartRateSamples,
2764
2779
  expectedRoutePoints,
2780
+ hasCurrentRawEvidenceVersion: rawEvidenceVersion === CURRENT_WORKOUT_RAW_EVIDENCE_VERSION,
2765
2781
  hasEvidenceMetadata: syncTimeSeriesCount !== null ||
2766
2782
  captureHeartRateCount !== null ||
2767
2783
  syncRoutePointCount !== null ||
@@ -2775,6 +2791,12 @@ function mobileHealthWorkoutImportState(userId) {
2775
2791
  FROM health_workout_time_series
2776
2792
  GROUP BY workout_id
2777
2793
  ),
2794
+ heart_rate_counts AS (
2795
+ SELECT workout_id, COUNT(*) AS heart_rate_count
2796
+ FROM health_workout_time_series
2797
+ WHERE metric_key = 'heart_rate'
2798
+ GROUP BY workout_id
2799
+ ),
2778
2800
  route_counts AS (
2779
2801
  SELECT workout_id, COUNT(*) AS route_point_count
2780
2802
  FROM health_workout_routes
@@ -2784,9 +2806,11 @@ function mobileHealthWorkoutImportState(userId) {
2784
2806
  w.external_uid,
2785
2807
  w.derived_json,
2786
2808
  COALESCE(time_series_counts.time_series_count, 0) AS time_series_count,
2809
+ COALESCE(heart_rate_counts.heart_rate_count, 0) AS heart_rate_count,
2787
2810
  COALESCE(route_counts.route_point_count, 0) AS route_point_count
2788
2811
  FROM health_workout_sessions w
2789
2812
  LEFT JOIN time_series_counts ON time_series_counts.workout_id = w.id
2813
+ LEFT JOIN heart_rate_counts ON heart_rate_counts.workout_id = w.id
2790
2814
  LEFT JOIN route_counts ON route_counts.workout_id = w.id
2791
2815
  WHERE w.user_id = ?
2792
2816
  AND w.source = 'apple_health'
@@ -2797,19 +2821,24 @@ function mobileHealthWorkoutImportState(userId) {
2797
2821
  const alreadyUploadedWorkoutExternalUids = [];
2798
2822
  let incompleteWorkoutCount = 0;
2799
2823
  let timeSeriesSampleCount = 0;
2824
+ let heartRateSampleCount = 0;
2800
2825
  let routePointCount = 0;
2801
2826
  for (const row of rows) {
2802
2827
  const derived = safeJsonParse(row.derived_json, {});
2803
2828
  const evidenceCounts = expectedWorkoutEvidenceCounts(derived);
2804
2829
  const actualTimeSeriesCount = Math.max(0, row.time_series_count ?? 0);
2830
+ const actualHeartRateCount = Math.max(0, row.heart_rate_count ?? 0);
2805
2831
  const actualRoutePointCount = Math.max(0, row.route_point_count ?? 0);
2806
2832
  const evidenceComplete = evidenceCounts.hasEvidenceMetadata
2807
- ? actualTimeSeriesCount >= evidenceCounts.expectedTimeSeriesSamples &&
2833
+ ? evidenceCounts.hasCurrentRawEvidenceVersion &&
2834
+ actualTimeSeriesCount >= evidenceCounts.expectedTimeSeriesSamples &&
2835
+ actualHeartRateCount >= evidenceCounts.expectedHeartRateSamples &&
2808
2836
  actualRoutePointCount >= evidenceCounts.expectedRoutePoints
2809
- : actualTimeSeriesCount + actualRoutePointCount > 0;
2837
+ : false;
2810
2838
  if (evidenceComplete) {
2811
2839
  alreadyUploadedWorkoutExternalUids.push(row.external_uid.toLowerCase());
2812
2840
  timeSeriesSampleCount += actualTimeSeriesCount;
2841
+ heartRateSampleCount += actualHeartRateCount;
2813
2842
  routePointCount += actualRoutePointCount;
2814
2843
  }
2815
2844
  else {
@@ -2822,6 +2851,7 @@ function mobileHealthWorkoutImportState(userId) {
2822
2851
  existingWorkoutCount: rows.length,
2823
2852
  incompleteWorkoutCount,
2824
2853
  timeSeriesSampleCount,
2854
+ heartRateSampleCount,
2825
2855
  routePointCount,
2826
2856
  capturedAt: nowIso()
2827
2857
  };
@@ -4062,6 +4092,326 @@ function buildFitnessVitalsTrend(rows) {
4062
4092
  vo2Max: values.vo2Max.length > 0 ? round(average(values.vo2Max), 2) : null
4063
4093
  }));
4064
4094
  }
4095
+ function isoWeekKey(value) {
4096
+ const date = new Date(value);
4097
+ const day = date.getUTCDay() || 7;
4098
+ date.setUTCDate(date.getUTCDate() + 4 - day);
4099
+ const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
4100
+ const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
4101
+ return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
4102
+ }
4103
+ function addDays(date, days) {
4104
+ const next = new Date(date);
4105
+ next.setUTCDate(next.getUTCDate() + days);
4106
+ return next;
4107
+ }
4108
+ function dateKeyFromDate(date) {
4109
+ return date.toISOString().slice(0, 10);
4110
+ }
4111
+ function standardDeviation(values) {
4112
+ if (values.length <= 1) {
4113
+ return 0;
4114
+ }
4115
+ const mean = average(values);
4116
+ const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length;
4117
+ return Math.sqrt(variance);
4118
+ }
4119
+ function zoneSeconds(session, keys) {
4120
+ const zones = session.analytics
4121
+ ?.zoneDurations ?? [];
4122
+ return zones
4123
+ .filter((zone) => keys.includes(zone.key))
4124
+ .reduce((sum, zone) => sum + zone.seconds, 0);
4125
+ }
4126
+ function workoutLoad(session) {
4127
+ return (session.analytics?.load
4128
+ ?.trimp ?? 0);
4129
+ }
4130
+ function workoutIntensity(session) {
4131
+ return (session.analytics
4132
+ ?.load?.intensity ?? null);
4133
+ }
4134
+ function workoutHrCoverage(session) {
4135
+ return (session.analytics?.dataQuality?.sampleCoverage ?? 0);
4136
+ }
4137
+ function workoutHrSampleCount(session) {
4138
+ return (session.analytics?.dataQuality?.heartRateSampleCount ?? 0);
4139
+ }
4140
+ function workoutAverageHr(session) {
4141
+ return (session.analytics
4142
+ ?.hrSummary?.averageHr ?? session.averageHeartRate);
4143
+ }
4144
+ function workoutMaxHr(session) {
4145
+ return (session.analytics
4146
+ ?.hrSummary?.maxHr ?? session.maxHeartRate);
4147
+ }
4148
+ function summarizeZoneDistribution(sessions) {
4149
+ const totals = new Map();
4150
+ let totalSeconds = 0;
4151
+ for (const session of sessions) {
4152
+ const zones = session.analytics?.zoneDurations ?? [];
4153
+ for (const zone of zones) {
4154
+ const current = totals.get(zone.key) ?? { label: zone.label, seconds: 0 };
4155
+ current.seconds += zone.seconds;
4156
+ totalSeconds += zone.seconds;
4157
+ totals.set(zone.key, current);
4158
+ }
4159
+ }
4160
+ return WORKOUT_ZONE_ORDER.map((key) => {
4161
+ const value = totals.get(key) ?? { label: key.replaceAll("_", " "), seconds: 0 };
4162
+ return {
4163
+ key,
4164
+ label: value.label,
4165
+ seconds: Math.round(value.seconds),
4166
+ percentage: totalSeconds > 0 ? Number((value.seconds / totalSeconds).toFixed(4)) : 0
4167
+ };
4168
+ });
4169
+ }
4170
+ function summarizeIntensityDistribution(sessions) {
4171
+ const lowSeconds = sessions.reduce((sum, session) => sum + zoneSeconds(session, ["below_z1", "zone_1"]), 0);
4172
+ const moderateSeconds = sessions.reduce((sum, session) => sum + zoneSeconds(session, ["zone_2", "zone_3"]), 0);
4173
+ const highSeconds = sessions.reduce((sum, session) => sum + zoneSeconds(session, ["zone_4", "zone_5"]), 0);
4174
+ const totalSeconds = lowSeconds + moderateSeconds + highSeconds;
4175
+ return [
4176
+ {
4177
+ key: "low",
4178
+ label: "Low / base",
4179
+ seconds: Math.round(lowSeconds),
4180
+ percentage: totalSeconds > 0 ? Number((lowSeconds / totalSeconds).toFixed(4)) : 0,
4181
+ targetRange: [0.7, 0.85]
4182
+ },
4183
+ {
4184
+ key: "moderate",
4185
+ label: "Tempo / threshold",
4186
+ seconds: Math.round(moderateSeconds),
4187
+ percentage: totalSeconds > 0
4188
+ ? Number((moderateSeconds / totalSeconds).toFixed(4))
4189
+ : 0,
4190
+ targetRange: [0.05, 0.2]
4191
+ },
4192
+ {
4193
+ key: "high",
4194
+ label: "Severe / HIIT",
4195
+ seconds: Math.round(highSeconds),
4196
+ percentage: totalSeconds > 0 ? Number((highSeconds / totalSeconds).toFixed(4)) : 0,
4197
+ targetRange: [0.08, 0.18]
4198
+ }
4199
+ ];
4200
+ }
4201
+ function latestVitalValue(vitalsTrend, key) {
4202
+ return [...vitalsTrend].reverse().find((entry) => entry[key] != null)?.[key] ?? null;
4203
+ }
4204
+ export function getTrainingLoadViewData(userIds) {
4205
+ const workoutRows = listWorkoutRows(userIds);
4206
+ const sessions = workoutRows
4207
+ .slice(0, 2000)
4208
+ .map((row) => mapWorkoutSession(row, { includeAnalytics: true }))
4209
+ .sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
4210
+ const now = new Date();
4211
+ const start7 = addDays(now, -6);
4212
+ const start28 = addDays(now, -27);
4213
+ const recent7 = sessions.filter((session) => Date.parse(session.startedAt) >= start7.getTime());
4214
+ const recent28 = sessions.filter((session) => Date.parse(session.startedAt) >= start28.getTime());
4215
+ const vitalsTrend = buildFitnessVitalsTrend(listDailySummaryRows("vitals", userIds));
4216
+ const dailyMap = new Map();
4217
+ for (const session of sessions) {
4218
+ const key = dayKey(session.startedAt);
4219
+ const current = dailyMap.get(key) ?? {
4220
+ dateKey: key,
4221
+ sessionCount: 0,
4222
+ durationSeconds: 0,
4223
+ trainingLoad: 0,
4224
+ highIntensitySeconds: 0,
4225
+ moderateIntensitySeconds: 0,
4226
+ lowIntensitySeconds: 0
4227
+ };
4228
+ current.sessionCount += 1;
4229
+ current.durationSeconds += session.durationSeconds;
4230
+ current.trainingLoad += workoutLoad(session);
4231
+ current.highIntensitySeconds += zoneSeconds(session, ["zone_4", "zone_5"]);
4232
+ current.moderateIntensitySeconds += zoneSeconds(session, ["zone_2", "zone_3"]);
4233
+ current.lowIntensitySeconds += zoneSeconds(session, ["below_z1", "zone_1"]);
4234
+ dailyMap.set(key, current);
4235
+ }
4236
+ const dailyLoad = [...dailyMap.values()]
4237
+ .sort((left, right) => left.dateKey.localeCompare(right.dateKey))
4238
+ .map((day) => ({
4239
+ ...day,
4240
+ durationMinutes: Math.round(day.durationSeconds / 60),
4241
+ trainingLoad: round(day.trainingLoad, 1),
4242
+ highIntensityMinutes: round(day.highIntensitySeconds / 60, 1),
4243
+ moderateIntensityMinutes: round(day.moderateIntensitySeconds / 60, 1),
4244
+ lowIntensityMinutes: round(day.lowIntensitySeconds / 60, 1)
4245
+ }));
4246
+ const weekMap = new Map();
4247
+ for (const session of sessions) {
4248
+ const weekKey = isoWeekKey(session.startedAt);
4249
+ const date = new Date(session.startedAt);
4250
+ const day = date.getUTCDay() || 7;
4251
+ const start = addDays(date, 1 - day);
4252
+ const end = addDays(start, 6);
4253
+ const current = weekMap.get(weekKey) ?? {
4254
+ weekKey,
4255
+ startDate: dateKeyFromDate(start),
4256
+ endDate: dateKeyFromDate(end),
4257
+ sessionCount: 0,
4258
+ durationSeconds: 0,
4259
+ trainingLoad: 0,
4260
+ highIntensitySeconds: 0,
4261
+ moderateIntensitySeconds: 0,
4262
+ lowIntensitySeconds: 0
4263
+ };
4264
+ current.sessionCount += 1;
4265
+ current.durationSeconds += session.durationSeconds;
4266
+ current.trainingLoad += workoutLoad(session);
4267
+ current.highIntensitySeconds += zoneSeconds(session, ["zone_4", "zone_5"]);
4268
+ current.moderateIntensitySeconds += zoneSeconds(session, ["zone_2", "zone_3"]);
4269
+ current.lowIntensitySeconds += zoneSeconds(session, ["below_z1", "zone_1"]);
4270
+ weekMap.set(weekKey, current);
4271
+ }
4272
+ const weeklyLoad = [...weekMap.values()]
4273
+ .sort((left, right) => left.weekKey.localeCompare(right.weekKey))
4274
+ .slice(-26)
4275
+ .map((week) => {
4276
+ const totalZoneSeconds = week.lowIntensitySeconds +
4277
+ week.moderateIntensitySeconds +
4278
+ week.highIntensitySeconds;
4279
+ const hours = week.durationSeconds / 3600;
4280
+ return {
4281
+ ...week,
4282
+ durationHours: round(hours, 2),
4283
+ trainingLoad: round(week.trainingLoad, 1),
4284
+ loadPerHour: hours > 0 ? round(week.trainingLoad / hours, 1) : 0,
4285
+ lowPercentage: totalZoneSeconds > 0 ? round(week.lowIntensitySeconds / totalZoneSeconds, 3) : 0,
4286
+ moderatePercentage: totalZoneSeconds > 0
4287
+ ? round(week.moderateIntensitySeconds / totalZoneSeconds, 3)
4288
+ : 0,
4289
+ highPercentage: totalZoneSeconds > 0 ? round(week.highIntensitySeconds / totalZoneSeconds, 3) : 0,
4290
+ highIntensityMinutes: round(week.highIntensitySeconds / 60, 1)
4291
+ };
4292
+ });
4293
+ const activityBreakdown = [...new Set(sessions.map((session) => session.workoutType))]
4294
+ .map((workoutType) => {
4295
+ const group = sessions.filter((session) => session.workoutType === workoutType);
4296
+ const durationSeconds = group.reduce((sum, session) => sum + session.durationSeconds, 0);
4297
+ const trainingLoad = group.reduce((sum, session) => sum + workoutLoad(session), 0);
4298
+ const highSeconds = group.reduce((sum, session) => sum + zoneSeconds(session, ["zone_4", "zone_5"]), 0);
4299
+ const totalZoneSeconds = group.reduce((sum, session) => sum +
4300
+ zoneSeconds(session, [
4301
+ "below_z1",
4302
+ "zone_1",
4303
+ "zone_2",
4304
+ "zone_3",
4305
+ "zone_4",
4306
+ "zone_5"
4307
+ ]), 0);
4308
+ return {
4309
+ workoutType,
4310
+ workoutTypeLabel: group.at(-1)?.workoutTypeLabel ?? workoutType,
4311
+ activityFamily: group.at(-1)?.activityFamily ?? "other",
4312
+ activityFamilyLabel: group.at(-1)?.activityFamilyLabel ?? "Other",
4313
+ sessionCount: group.length,
4314
+ durationHours: round(durationSeconds / 3600, 2),
4315
+ trainingLoad: round(trainingLoad, 1),
4316
+ loadPerHour: durationSeconds > 0 ? round(trainingLoad / (durationSeconds / 3600), 1) : 0,
4317
+ highPercentage: totalZoneSeconds > 0 ? round(highSeconds / totalZoneSeconds, 3) : 0,
4318
+ averageHrCoverage: round(average(group.map((session) => workoutHrCoverage(session))), 3)
4319
+ };
4320
+ })
4321
+ .sort((left, right) => right.trainingLoad - left.trainingLoad);
4322
+ const last7Keys = Array.from({ length: 7 }, (_, index) => dateKeyFromDate(addDays(start7, index)));
4323
+ const last7Loads = last7Keys.map((key) => dailyMap.get(key)?.trainingLoad ?? 0);
4324
+ const acuteLoad7d = recent7.reduce((sum, session) => sum + workoutLoad(session), 0);
4325
+ const chronicWeeklyLoad28d = recent28.reduce((sum, session) => sum + workoutLoad(session), 0) / 4;
4326
+ const loadSd7d = standardDeviation(last7Loads);
4327
+ const monotony7d = loadSd7d > 0 ? average(last7Loads) / loadSd7d : recent7.length > 0 ? null : 0;
4328
+ const strain7d = monotony7d != null ? acuteLoad7d * monotony7d : null;
4329
+ const highSeconds7d = recent7.reduce((sum, session) => sum + zoneSeconds(session, ["zone_4", "zone_5"]), 0);
4330
+ const moderateSeconds7d = recent7.reduce((sum, session) => sum + zoneSeconds(session, ["zone_2", "zone_3"]), 0);
4331
+ const lowSeconds7d = recent7.reduce((sum, session) => sum + zoneSeconds(session, ["below_z1", "zone_1"]), 0);
4332
+ const reliableSessions = sessions.filter((session) => workoutHrSampleCount(session) >= 100 && workoutHrCoverage(session) >= 0.8);
4333
+ const vo2Points = vitalsTrend.filter((entry) => entry.vo2Max != null);
4334
+ const vo2MaxLatest = latestVitalValue(vitalsTrend, "vo2Max");
4335
+ const vo2MaxDelta = vo2Points.length >= 2
4336
+ ? round((vo2Points.at(-1)?.vo2Max ?? 0) - (vo2Points[0]?.vo2Max ?? 0), 2)
4337
+ : null;
4338
+ const acwr = chronicWeeklyLoad28d > 0 ? round(acuteLoad7d / chronicWeeklyLoad28d, 2) : null;
4339
+ const readiness = acwr == null
4340
+ ? "insufficient_data"
4341
+ : acwr > 1.5 || (strain7d ?? 0) > 450
4342
+ ? "overload_watch"
4343
+ : acwr < 0.75
4344
+ ? "underloaded"
4345
+ : "productive";
4346
+ return {
4347
+ summary: {
4348
+ sessionCount: sessions.length,
4349
+ reliableSessionCount: reliableSessions.length,
4350
+ totalHours: round(sessions.reduce((sum, session) => sum + session.durationSeconds, 0) / 3600, 1),
4351
+ totalTrainingLoad: round(sessions.reduce((sum, session) => sum + workoutLoad(session), 0), 1),
4352
+ acuteLoad7d: round(acuteLoad7d, 1),
4353
+ chronicWeeklyLoad28d: round(chronicWeeklyLoad28d, 1),
4354
+ acuteChronicRatio: acwr,
4355
+ monotony7d: monotony7d != null ? round(monotony7d, 2) : null,
4356
+ strain7d: strain7d != null ? round(strain7d, 1) : null,
4357
+ highIntensityMinutes7d: round(highSeconds7d / 60, 1),
4358
+ thresholdMinutes7d: round(moderateSeconds7d / 60, 1),
4359
+ easyMinutes7d: round(lowSeconds7d / 60, 1),
4360
+ hardDayCount7d: last7Keys.filter((key) => (dailyMap.get(key)?.highIntensitySeconds ?? 0) >= 10 * 60).length,
4361
+ averageHeartRateCoverage: round(average(sessions.map((session) => workoutHrCoverage(session))), 3),
4362
+ vo2MaxLatest,
4363
+ vo2MaxDelta,
4364
+ latestRestingHeartRate: latestVitalValue(vitalsTrend, "restingHeartRate"),
4365
+ readiness
4366
+ },
4367
+ zoneTotals: summarizeZoneDistribution(sessions),
4368
+ recentZoneTotals: summarizeZoneDistribution(recent28),
4369
+ intensityDistribution: summarizeIntensityDistribution(sessions),
4370
+ recentIntensityDistribution: summarizeIntensityDistribution(recent28),
4371
+ dailyLoad: dailyLoad.slice(-90),
4372
+ weeklyLoad,
4373
+ activityBreakdown,
4374
+ vitalsTrend,
4375
+ sessionSignals: sessions
4376
+ .slice(-200)
4377
+ .reverse()
4378
+ .map((session) => {
4379
+ const highSeconds = zoneSeconds(session, ["zone_4", "zone_5"]);
4380
+ const totalZoneSeconds = zoneSeconds(session, WORKOUT_ZONE_ORDER);
4381
+ return {
4382
+ id: session.id,
4383
+ dateKey: dayKey(session.startedAt),
4384
+ startedAt: session.startedAt,
4385
+ workoutType: session.workoutType,
4386
+ workoutTypeLabel: session.workoutTypeLabel ?? session.workoutType,
4387
+ durationMinutes: round(session.durationSeconds / 60, 1),
4388
+ trainingLoad: round(workoutLoad(session), 1),
4389
+ intensity: workoutIntensity(session),
4390
+ averageHr: workoutAverageHr(session),
4391
+ maxHr: workoutMaxHr(session),
4392
+ highIntensityPercentage: totalZoneSeconds > 0 ? round(highSeconds / totalZoneSeconds, 3) : 0,
4393
+ highIntensityMinutes: round(highSeconds / 60, 1),
4394
+ heartRateCoverage: workoutHrCoverage(session),
4395
+ heartRateSampleCount: workoutHrSampleCount(session),
4396
+ confidence: session.analytics?.confidence ??
4397
+ "unavailable",
4398
+ detailRoute: `/api/v1/health/workouts/${session.id}/detail`
4399
+ };
4400
+ }),
4401
+ targetModel: {
4402
+ model: "forge-training-load-v1",
4403
+ lowIntensityTarget: "70-85% of total endurance time",
4404
+ moderateIntensityTarget: "5-20% depending on phase and sport specificity",
4405
+ highIntensityTarget: "8-18% unless in a short peaking block",
4406
+ monitoringNotes: [
4407
+ "Use Forge TRIMP as an internal-load trend, not a medical diagnosis.",
4408
+ "Treat kickboxing and sparring as high-intensity days when Z4+Z5 is material.",
4409
+ "Prefer added easy aerobic volume when high-intensity minutes are already high.",
4410
+ "Chest-strap HR is recommended for combat sports when exact zone decisions matter."
4411
+ ]
4412
+ }
4413
+ };
4414
+ }
4065
4415
  export function getFitnessViewData(userIds) {
4066
4416
  const workoutRows = listWorkoutRows(userIds);
4067
4417
  const recent = workoutRows
@@ -3165,6 +3165,7 @@ export function buildOpenApiDocument() {
3165
3165
  "notes",
3166
3166
  "sleep",
3167
3167
  "fitness",
3168
+ "trainingLoad",
3168
3169
  "vitals",
3169
3170
  "lifeForce",
3170
3171
  "domains",
@@ -3228,6 +3229,11 @@ export function buildOpenApiDocument() {
3228
3229
  additionalProperties: true,
3229
3230
  description: "Compact sports summary with recent workout IDs and the full fitness route."
3230
3231
  },
3232
+ trainingLoad: {
3233
+ type: "object",
3234
+ additionalProperties: true,
3235
+ description: "Compact cardiovascular training-load summary with acute/chronic load, intensity distribution, and the full training-load route."
3236
+ },
3231
3237
  vitals: {
3232
3238
  type: "object",
3233
3239
  additionalProperties: true,
@@ -5161,6 +5167,42 @@ export function buildOpenApiDocument() {
5161
5167
  sessions: arrayOf({ $ref: "#/components/schemas/WorkoutSession" })
5162
5168
  }
5163
5169
  };
5170
+ const trainingLoadViewData = {
5171
+ type: "object",
5172
+ additionalProperties: false,
5173
+ required: [
5174
+ "summary",
5175
+ "zoneTotals",
5176
+ "recentZoneTotals",
5177
+ "intensityDistribution",
5178
+ "recentIntensityDistribution",
5179
+ "dailyLoad",
5180
+ "weeklyLoad",
5181
+ "activityBreakdown",
5182
+ "vitalsTrend",
5183
+ "sessionSignals",
5184
+ "targetModel"
5185
+ ],
5186
+ properties: {
5187
+ summary: { type: "object", additionalProperties: true },
5188
+ zoneTotals: arrayOf({ type: "object", additionalProperties: true }),
5189
+ recentZoneTotals: arrayOf({ type: "object", additionalProperties: true }),
5190
+ intensityDistribution: arrayOf({
5191
+ type: "object",
5192
+ additionalProperties: true
5193
+ }),
5194
+ recentIntensityDistribution: arrayOf({
5195
+ type: "object",
5196
+ additionalProperties: true
5197
+ }),
5198
+ dailyLoad: arrayOf({ type: "object", additionalProperties: true }),
5199
+ weeklyLoad: arrayOf({ type: "object", additionalProperties: true }),
5200
+ activityBreakdown: arrayOf({ type: "object", additionalProperties: true }),
5201
+ vitalsTrend: arrayOf({ type: "object", additionalProperties: true }),
5202
+ sessionSignals: arrayOf({ type: "object", additionalProperties: true }),
5203
+ targetModel: { type: "object", additionalProperties: true }
5204
+ }
5205
+ };
5164
5206
  const document = {
5165
5207
  openapi: "3.1.0",
5166
5208
  info: {
@@ -5272,6 +5314,7 @@ export function buildOpenApiDocument() {
5272
5314
  WorkoutSession: workoutSession,
5273
5315
  SleepViewData: sleepViewData,
5274
5316
  FitnessViewData: fitnessViewData,
5317
+ TrainingLoadViewData: trainingLoadViewData,
5275
5318
  PsycheMetricsViewData: psycheMetricsViewData,
5276
5319
  PsycheOverviewPayload: psycheOverviewPayload,
5277
5320
  Insight: insight,
@@ -5563,6 +5606,22 @@ export function buildOpenApiDocument() {
5563
5606
  }
5564
5607
  }
5565
5608
  },
5609
+ "/api/v1/health/training-load": {
5610
+ get: {
5611
+ summary: "Read the Forge cardiovascular training load and target overview surface",
5612
+ responses: {
5613
+ "200": jsonResponse({
5614
+ type: "object",
5615
+ required: ["trainingLoad"],
5616
+ properties: {
5617
+ trainingLoad: {
5618
+ $ref: "#/components/schemas/TrainingLoadViewData"
5619
+ }
5620
+ }
5621
+ }, "Training load overview")
5622
+ }
5623
+ }
5624
+ },
5566
5625
  "/api/v1/health/workouts": {
5567
5626
  post: {
5568
5627
  summary: "Create one manual workout session",
@@ -270,6 +270,12 @@ async function proxyDevWebSocket(input) {
270
270
  }
271
271
  });
272
272
  proxyRequest.on("upgrade", (response, proxySocket, proxyHead) => {
273
+ const closeBothSockets = () => {
274
+ proxySocket.destroy();
275
+ input.socket.destroy();
276
+ };
277
+ proxySocket.on("error", closeBothSockets);
278
+ input.socket.on("error", closeBothSockets);
273
279
  writeProxyUpgradeResponse(input.socket, response);
274
280
  if (proxyHead.length > 0) {
275
281
  input.socket.write(proxyHead);
@@ -1734,6 +1734,12 @@ export function getFitnessView(userIds) {
1734
1734
  const suffix = search.size > 0 ? `?${search.toString()}` : "";
1735
1735
  return request(`/api/v1/health/fitness${suffix}`);
1736
1736
  }
1737
+ export function getTrainingLoadView(userIds) {
1738
+ const search = new URLSearchParams();
1739
+ appendUserIds(search, coerceUserIds(userIds));
1740
+ const suffix = search.size > 0 ? `?${search.toString()}` : "";
1741
+ return request(`/api/v1/health/training-load${suffix}`);
1742
+ }
1737
1743
  export function getWorkoutDetail(workoutId, resolution = "adaptive") {
1738
1744
  const search = new URLSearchParams({ resolution });
1739
1745
  return request(`/api/v1/health/workouts/${workoutId}/detail?${search.toString()}`);
@@ -2,7 +2,7 @@
2
2
  "id": "forge-openclaw-plugin",
3
3
  "name": "Forge",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
- "version": "0.2.93",
5
+ "version": "0.2.96",
6
6
  "activation": {
7
7
  "onStartup": true,
8
8
  "onCapabilities": [
@@ -51,6 +51,7 @@
51
51
  "forge_get_self_observation_calendar",
52
52
  "forge_get_sleep_overview",
53
53
  "forge_get_sports_overview",
54
+ "forge_get_training_load_overview",
54
55
  "forge_get_ui_entrypoint",
55
56
  "forge_get_user_directory",
56
57
  "forge_get_weekly_review",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-openclaw-plugin",
3
- "version": "0.2.93",
3
+ "version": "0.2.96",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",