forge-openclaw-plugin 0.2.92 → 0.2.94

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.
Files changed (28) hide show
  1. package/dist/assets/{board-DKxKOwax.js → board-D1HbyD4u.js} +1 -1
  2. package/dist/assets/{index-BNvUaA6y.js → index-DWZd3qT-.js} +44 -44
  3. package/dist/assets/index-PA_Ih223.css +1 -0
  4. package/dist/assets/{motion-CM4AfIqo.js → motion-D2OqILg_.js} +1 -1
  5. package/dist/assets/{table-BUeQ9wzR.js → table-YWWjPjC_.js} +1 -1
  6. package/dist/assets/{ui-3Wd4pVaA.js → ui-DikPZj8S.js} +1 -1
  7. package/dist/assets/vendor-BS9OPVNh.js +2181 -0
  8. package/dist/companion-iroh/darwin-arm64/forge-companion-iroh +0 -0
  9. package/dist/companion-iroh/darwin-x64/forge-companion-iroh +0 -0
  10. package/dist/companion-iroh/linux-x64/forge-companion-iroh +0 -0
  11. package/dist/index.html +7 -7
  12. package/dist/openclaw/parity.js +1 -0
  13. package/dist/openclaw/routes.js +5 -0
  14. package/dist/openclaw/tools.js +7 -0
  15. package/dist/server/server/migrations/064_health_workout_time_series_identity.sql +161 -0
  16. package/dist/server/server/src/app.js +72 -2
  17. package/dist/server/server/src/health-workout-analytics.js +8 -2
  18. package/dist/server/server/src/health.js +337 -2
  19. package/dist/server/server/src/openapi.js +59 -0
  20. package/dist/server/src/lib/api.js +6 -0
  21. package/openclaw.plugin.json +2 -1
  22. package/package.json +1 -1
  23. package/server/migrations/064_health_workout_time_series_identity.sql +161 -0
  24. package/skills/forge-openclaw/SKILL.md +25 -7
  25. package/skills/forge-openclaw/entity_conversation_playbooks.md +88 -12
  26. package/skills/forge-openclaw/psyche_entity_playbooks.md +35 -0
  27. package/dist/assets/index-NqIbz_lv.css +0 -1
  28. package/dist/assets/vendor-BVU0cZC9.js +0 -2171
@@ -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";
@@ -2758,9 +2758,11 @@ function expectedWorkoutEvidenceCounts(derived) {
2758
2758
  const syncRoutePointCount = finiteNumberFromUnknown(syncCursor.routePointCount);
2759
2759
  const captureRoutePointCount = finiteNumberFromUnknown(captureQuality.routePoints);
2760
2760
  const expectedTimeSeriesSamples = Math.max(0, Math.ceil(Math.max(syncTimeSeriesCount ?? 0, captureHeartRateCount ?? 0)));
2761
+ const expectedHeartRateSamples = Math.max(0, Math.ceil(captureHeartRateCount ?? 0));
2761
2762
  const expectedRoutePoints = Math.max(0, Math.ceil(Math.max(syncRoutePointCount ?? 0, captureRoutePointCount ?? 0)));
2762
2763
  return {
2763
2764
  expectedTimeSeriesSamples,
2765
+ expectedHeartRateSamples,
2764
2766
  expectedRoutePoints,
2765
2767
  hasEvidenceMetadata: syncTimeSeriesCount !== null ||
2766
2768
  captureHeartRateCount !== null ||
@@ -2775,6 +2777,12 @@ function mobileHealthWorkoutImportState(userId) {
2775
2777
  FROM health_workout_time_series
2776
2778
  GROUP BY workout_id
2777
2779
  ),
2780
+ heart_rate_counts AS (
2781
+ SELECT workout_id, COUNT(*) AS heart_rate_count
2782
+ FROM health_workout_time_series
2783
+ WHERE metric_key = 'heart_rate'
2784
+ GROUP BY workout_id
2785
+ ),
2778
2786
  route_counts AS (
2779
2787
  SELECT workout_id, COUNT(*) AS route_point_count
2780
2788
  FROM health_workout_routes
@@ -2784,9 +2792,11 @@ function mobileHealthWorkoutImportState(userId) {
2784
2792
  w.external_uid,
2785
2793
  w.derived_json,
2786
2794
  COALESCE(time_series_counts.time_series_count, 0) AS time_series_count,
2795
+ COALESCE(heart_rate_counts.heart_rate_count, 0) AS heart_rate_count,
2787
2796
  COALESCE(route_counts.route_point_count, 0) AS route_point_count
2788
2797
  FROM health_workout_sessions w
2789
2798
  LEFT JOIN time_series_counts ON time_series_counts.workout_id = w.id
2799
+ LEFT JOIN heart_rate_counts ON heart_rate_counts.workout_id = w.id
2790
2800
  LEFT JOIN route_counts ON route_counts.workout_id = w.id
2791
2801
  WHERE w.user_id = ?
2792
2802
  AND w.source = 'apple_health'
@@ -2797,19 +2807,23 @@ function mobileHealthWorkoutImportState(userId) {
2797
2807
  const alreadyUploadedWorkoutExternalUids = [];
2798
2808
  let incompleteWorkoutCount = 0;
2799
2809
  let timeSeriesSampleCount = 0;
2810
+ let heartRateSampleCount = 0;
2800
2811
  let routePointCount = 0;
2801
2812
  for (const row of rows) {
2802
2813
  const derived = safeJsonParse(row.derived_json, {});
2803
2814
  const evidenceCounts = expectedWorkoutEvidenceCounts(derived);
2804
2815
  const actualTimeSeriesCount = Math.max(0, row.time_series_count ?? 0);
2816
+ const actualHeartRateCount = Math.max(0, row.heart_rate_count ?? 0);
2805
2817
  const actualRoutePointCount = Math.max(0, row.route_point_count ?? 0);
2806
2818
  const evidenceComplete = evidenceCounts.hasEvidenceMetadata
2807
2819
  ? actualTimeSeriesCount >= evidenceCounts.expectedTimeSeriesSamples &&
2820
+ actualHeartRateCount >= evidenceCounts.expectedHeartRateSamples &&
2808
2821
  actualRoutePointCount >= evidenceCounts.expectedRoutePoints
2809
- : actualTimeSeriesCount + actualRoutePointCount > 0;
2822
+ : false;
2810
2823
  if (evidenceComplete) {
2811
2824
  alreadyUploadedWorkoutExternalUids.push(row.external_uid.toLowerCase());
2812
2825
  timeSeriesSampleCount += actualTimeSeriesCount;
2826
+ heartRateSampleCount += actualHeartRateCount;
2813
2827
  routePointCount += actualRoutePointCount;
2814
2828
  }
2815
2829
  else {
@@ -2822,6 +2836,7 @@ function mobileHealthWorkoutImportState(userId) {
2822
2836
  existingWorkoutCount: rows.length,
2823
2837
  incompleteWorkoutCount,
2824
2838
  timeSeriesSampleCount,
2839
+ heartRateSampleCount,
2825
2840
  routePointCount,
2826
2841
  capturedAt: nowIso()
2827
2842
  };
@@ -4062,6 +4077,326 @@ function buildFitnessVitalsTrend(rows) {
4062
4077
  vo2Max: values.vo2Max.length > 0 ? round(average(values.vo2Max), 2) : null
4063
4078
  }));
4064
4079
  }
4080
+ function isoWeekKey(value) {
4081
+ const date = new Date(value);
4082
+ const day = date.getUTCDay() || 7;
4083
+ date.setUTCDate(date.getUTCDate() + 4 - day);
4084
+ const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
4085
+ const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
4086
+ return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
4087
+ }
4088
+ function addDays(date, days) {
4089
+ const next = new Date(date);
4090
+ next.setUTCDate(next.getUTCDate() + days);
4091
+ return next;
4092
+ }
4093
+ function dateKeyFromDate(date) {
4094
+ return date.toISOString().slice(0, 10);
4095
+ }
4096
+ function standardDeviation(values) {
4097
+ if (values.length <= 1) {
4098
+ return 0;
4099
+ }
4100
+ const mean = average(values);
4101
+ const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length;
4102
+ return Math.sqrt(variance);
4103
+ }
4104
+ function zoneSeconds(session, keys) {
4105
+ const zones = session.analytics
4106
+ ?.zoneDurations ?? [];
4107
+ return zones
4108
+ .filter((zone) => keys.includes(zone.key))
4109
+ .reduce((sum, zone) => sum + zone.seconds, 0);
4110
+ }
4111
+ function workoutLoad(session) {
4112
+ return (session.analytics?.load
4113
+ ?.trimp ?? 0);
4114
+ }
4115
+ function workoutIntensity(session) {
4116
+ return (session.analytics
4117
+ ?.load?.intensity ?? null);
4118
+ }
4119
+ function workoutHrCoverage(session) {
4120
+ return (session.analytics?.dataQuality?.sampleCoverage ?? 0);
4121
+ }
4122
+ function workoutHrSampleCount(session) {
4123
+ return (session.analytics?.dataQuality?.heartRateSampleCount ?? 0);
4124
+ }
4125
+ function workoutAverageHr(session) {
4126
+ return (session.analytics
4127
+ ?.hrSummary?.averageHr ?? session.averageHeartRate);
4128
+ }
4129
+ function workoutMaxHr(session) {
4130
+ return (session.analytics
4131
+ ?.hrSummary?.maxHr ?? session.maxHeartRate);
4132
+ }
4133
+ function summarizeZoneDistribution(sessions) {
4134
+ const totals = new Map();
4135
+ let totalSeconds = 0;
4136
+ for (const session of sessions) {
4137
+ const zones = session.analytics?.zoneDurations ?? [];
4138
+ for (const zone of zones) {
4139
+ const current = totals.get(zone.key) ?? { label: zone.label, seconds: 0 };
4140
+ current.seconds += zone.seconds;
4141
+ totalSeconds += zone.seconds;
4142
+ totals.set(zone.key, current);
4143
+ }
4144
+ }
4145
+ return WORKOUT_ZONE_ORDER.map((key) => {
4146
+ const value = totals.get(key) ?? { label: key.replaceAll("_", " "), seconds: 0 };
4147
+ return {
4148
+ key,
4149
+ label: value.label,
4150
+ seconds: Math.round(value.seconds),
4151
+ percentage: totalSeconds > 0 ? Number((value.seconds / totalSeconds).toFixed(4)) : 0
4152
+ };
4153
+ });
4154
+ }
4155
+ function summarizeIntensityDistribution(sessions) {
4156
+ const lowSeconds = sessions.reduce((sum, session) => sum + zoneSeconds(session, ["below_z1", "zone_1"]), 0);
4157
+ const moderateSeconds = sessions.reduce((sum, session) => sum + zoneSeconds(session, ["zone_2", "zone_3"]), 0);
4158
+ const highSeconds = sessions.reduce((sum, session) => sum + zoneSeconds(session, ["zone_4", "zone_5"]), 0);
4159
+ const totalSeconds = lowSeconds + moderateSeconds + highSeconds;
4160
+ return [
4161
+ {
4162
+ key: "low",
4163
+ label: "Low / base",
4164
+ seconds: Math.round(lowSeconds),
4165
+ percentage: totalSeconds > 0 ? Number((lowSeconds / totalSeconds).toFixed(4)) : 0,
4166
+ targetRange: [0.7, 0.85]
4167
+ },
4168
+ {
4169
+ key: "moderate",
4170
+ label: "Tempo / threshold",
4171
+ seconds: Math.round(moderateSeconds),
4172
+ percentage: totalSeconds > 0
4173
+ ? Number((moderateSeconds / totalSeconds).toFixed(4))
4174
+ : 0,
4175
+ targetRange: [0.05, 0.2]
4176
+ },
4177
+ {
4178
+ key: "high",
4179
+ label: "Severe / HIIT",
4180
+ seconds: Math.round(highSeconds),
4181
+ percentage: totalSeconds > 0 ? Number((highSeconds / totalSeconds).toFixed(4)) : 0,
4182
+ targetRange: [0.08, 0.18]
4183
+ }
4184
+ ];
4185
+ }
4186
+ function latestVitalValue(vitalsTrend, key) {
4187
+ return [...vitalsTrend].reverse().find((entry) => entry[key] != null)?.[key] ?? null;
4188
+ }
4189
+ export function getTrainingLoadViewData(userIds) {
4190
+ const workoutRows = listWorkoutRows(userIds);
4191
+ const sessions = workoutRows
4192
+ .slice(0, 2000)
4193
+ .map((row) => mapWorkoutSession(row, { includeAnalytics: true }))
4194
+ .sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
4195
+ const now = new Date();
4196
+ const start7 = addDays(now, -6);
4197
+ const start28 = addDays(now, -27);
4198
+ const recent7 = sessions.filter((session) => Date.parse(session.startedAt) >= start7.getTime());
4199
+ const recent28 = sessions.filter((session) => Date.parse(session.startedAt) >= start28.getTime());
4200
+ const vitalsTrend = buildFitnessVitalsTrend(listDailySummaryRows("vitals", userIds));
4201
+ const dailyMap = new Map();
4202
+ for (const session of sessions) {
4203
+ const key = dayKey(session.startedAt);
4204
+ const current = dailyMap.get(key) ?? {
4205
+ dateKey: key,
4206
+ sessionCount: 0,
4207
+ durationSeconds: 0,
4208
+ trainingLoad: 0,
4209
+ highIntensitySeconds: 0,
4210
+ moderateIntensitySeconds: 0,
4211
+ lowIntensitySeconds: 0
4212
+ };
4213
+ current.sessionCount += 1;
4214
+ current.durationSeconds += session.durationSeconds;
4215
+ current.trainingLoad += workoutLoad(session);
4216
+ current.highIntensitySeconds += zoneSeconds(session, ["zone_4", "zone_5"]);
4217
+ current.moderateIntensitySeconds += zoneSeconds(session, ["zone_2", "zone_3"]);
4218
+ current.lowIntensitySeconds += zoneSeconds(session, ["below_z1", "zone_1"]);
4219
+ dailyMap.set(key, current);
4220
+ }
4221
+ const dailyLoad = [...dailyMap.values()]
4222
+ .sort((left, right) => left.dateKey.localeCompare(right.dateKey))
4223
+ .map((day) => ({
4224
+ ...day,
4225
+ durationMinutes: Math.round(day.durationSeconds / 60),
4226
+ trainingLoad: round(day.trainingLoad, 1),
4227
+ highIntensityMinutes: round(day.highIntensitySeconds / 60, 1),
4228
+ moderateIntensityMinutes: round(day.moderateIntensitySeconds / 60, 1),
4229
+ lowIntensityMinutes: round(day.lowIntensitySeconds / 60, 1)
4230
+ }));
4231
+ const weekMap = new Map();
4232
+ for (const session of sessions) {
4233
+ const weekKey = isoWeekKey(session.startedAt);
4234
+ const date = new Date(session.startedAt);
4235
+ const day = date.getUTCDay() || 7;
4236
+ const start = addDays(date, 1 - day);
4237
+ const end = addDays(start, 6);
4238
+ const current = weekMap.get(weekKey) ?? {
4239
+ weekKey,
4240
+ startDate: dateKeyFromDate(start),
4241
+ endDate: dateKeyFromDate(end),
4242
+ sessionCount: 0,
4243
+ durationSeconds: 0,
4244
+ trainingLoad: 0,
4245
+ highIntensitySeconds: 0,
4246
+ moderateIntensitySeconds: 0,
4247
+ lowIntensitySeconds: 0
4248
+ };
4249
+ current.sessionCount += 1;
4250
+ current.durationSeconds += session.durationSeconds;
4251
+ current.trainingLoad += workoutLoad(session);
4252
+ current.highIntensitySeconds += zoneSeconds(session, ["zone_4", "zone_5"]);
4253
+ current.moderateIntensitySeconds += zoneSeconds(session, ["zone_2", "zone_3"]);
4254
+ current.lowIntensitySeconds += zoneSeconds(session, ["below_z1", "zone_1"]);
4255
+ weekMap.set(weekKey, current);
4256
+ }
4257
+ const weeklyLoad = [...weekMap.values()]
4258
+ .sort((left, right) => left.weekKey.localeCompare(right.weekKey))
4259
+ .slice(-26)
4260
+ .map((week) => {
4261
+ const totalZoneSeconds = week.lowIntensitySeconds +
4262
+ week.moderateIntensitySeconds +
4263
+ week.highIntensitySeconds;
4264
+ const hours = week.durationSeconds / 3600;
4265
+ return {
4266
+ ...week,
4267
+ durationHours: round(hours, 2),
4268
+ trainingLoad: round(week.trainingLoad, 1),
4269
+ loadPerHour: hours > 0 ? round(week.trainingLoad / hours, 1) : 0,
4270
+ lowPercentage: totalZoneSeconds > 0 ? round(week.lowIntensitySeconds / totalZoneSeconds, 3) : 0,
4271
+ moderatePercentage: totalZoneSeconds > 0
4272
+ ? round(week.moderateIntensitySeconds / totalZoneSeconds, 3)
4273
+ : 0,
4274
+ highPercentage: totalZoneSeconds > 0 ? round(week.highIntensitySeconds / totalZoneSeconds, 3) : 0,
4275
+ highIntensityMinutes: round(week.highIntensitySeconds / 60, 1)
4276
+ };
4277
+ });
4278
+ const activityBreakdown = [...new Set(sessions.map((session) => session.workoutType))]
4279
+ .map((workoutType) => {
4280
+ const group = sessions.filter((session) => session.workoutType === workoutType);
4281
+ const durationSeconds = group.reduce((sum, session) => sum + session.durationSeconds, 0);
4282
+ const trainingLoad = group.reduce((sum, session) => sum + workoutLoad(session), 0);
4283
+ const highSeconds = group.reduce((sum, session) => sum + zoneSeconds(session, ["zone_4", "zone_5"]), 0);
4284
+ const totalZoneSeconds = group.reduce((sum, session) => sum +
4285
+ zoneSeconds(session, [
4286
+ "below_z1",
4287
+ "zone_1",
4288
+ "zone_2",
4289
+ "zone_3",
4290
+ "zone_4",
4291
+ "zone_5"
4292
+ ]), 0);
4293
+ return {
4294
+ workoutType,
4295
+ workoutTypeLabel: group.at(-1)?.workoutTypeLabel ?? workoutType,
4296
+ activityFamily: group.at(-1)?.activityFamily ?? "other",
4297
+ activityFamilyLabel: group.at(-1)?.activityFamilyLabel ?? "Other",
4298
+ sessionCount: group.length,
4299
+ durationHours: round(durationSeconds / 3600, 2),
4300
+ trainingLoad: round(trainingLoad, 1),
4301
+ loadPerHour: durationSeconds > 0 ? round(trainingLoad / (durationSeconds / 3600), 1) : 0,
4302
+ highPercentage: totalZoneSeconds > 0 ? round(highSeconds / totalZoneSeconds, 3) : 0,
4303
+ averageHrCoverage: round(average(group.map((session) => workoutHrCoverage(session))), 3)
4304
+ };
4305
+ })
4306
+ .sort((left, right) => right.trainingLoad - left.trainingLoad);
4307
+ const last7Keys = Array.from({ length: 7 }, (_, index) => dateKeyFromDate(addDays(start7, index)));
4308
+ const last7Loads = last7Keys.map((key) => dailyMap.get(key)?.trainingLoad ?? 0);
4309
+ const acuteLoad7d = recent7.reduce((sum, session) => sum + workoutLoad(session), 0);
4310
+ const chronicWeeklyLoad28d = recent28.reduce((sum, session) => sum + workoutLoad(session), 0) / 4;
4311
+ const loadSd7d = standardDeviation(last7Loads);
4312
+ const monotony7d = loadSd7d > 0 ? average(last7Loads) / loadSd7d : recent7.length > 0 ? null : 0;
4313
+ const strain7d = monotony7d != null ? acuteLoad7d * monotony7d : null;
4314
+ const highSeconds7d = recent7.reduce((sum, session) => sum + zoneSeconds(session, ["zone_4", "zone_5"]), 0);
4315
+ const moderateSeconds7d = recent7.reduce((sum, session) => sum + zoneSeconds(session, ["zone_2", "zone_3"]), 0);
4316
+ const lowSeconds7d = recent7.reduce((sum, session) => sum + zoneSeconds(session, ["below_z1", "zone_1"]), 0);
4317
+ const reliableSessions = sessions.filter((session) => workoutHrSampleCount(session) >= 100 && workoutHrCoverage(session) >= 0.8);
4318
+ const vo2Points = vitalsTrend.filter((entry) => entry.vo2Max != null);
4319
+ const vo2MaxLatest = latestVitalValue(vitalsTrend, "vo2Max");
4320
+ const vo2MaxDelta = vo2Points.length >= 2
4321
+ ? round((vo2Points.at(-1)?.vo2Max ?? 0) - (vo2Points[0]?.vo2Max ?? 0), 2)
4322
+ : null;
4323
+ const acwr = chronicWeeklyLoad28d > 0 ? round(acuteLoad7d / chronicWeeklyLoad28d, 2) : null;
4324
+ const readiness = acwr == null
4325
+ ? "insufficient_data"
4326
+ : acwr > 1.5 || (strain7d ?? 0) > 450
4327
+ ? "overload_watch"
4328
+ : acwr < 0.75
4329
+ ? "underloaded"
4330
+ : "productive";
4331
+ return {
4332
+ summary: {
4333
+ sessionCount: sessions.length,
4334
+ reliableSessionCount: reliableSessions.length,
4335
+ totalHours: round(sessions.reduce((sum, session) => sum + session.durationSeconds, 0) / 3600, 1),
4336
+ totalTrainingLoad: round(sessions.reduce((sum, session) => sum + workoutLoad(session), 0), 1),
4337
+ acuteLoad7d: round(acuteLoad7d, 1),
4338
+ chronicWeeklyLoad28d: round(chronicWeeklyLoad28d, 1),
4339
+ acuteChronicRatio: acwr,
4340
+ monotony7d: monotony7d != null ? round(monotony7d, 2) : null,
4341
+ strain7d: strain7d != null ? round(strain7d, 1) : null,
4342
+ highIntensityMinutes7d: round(highSeconds7d / 60, 1),
4343
+ thresholdMinutes7d: round(moderateSeconds7d / 60, 1),
4344
+ easyMinutes7d: round(lowSeconds7d / 60, 1),
4345
+ hardDayCount7d: last7Keys.filter((key) => (dailyMap.get(key)?.highIntensitySeconds ?? 0) >= 10 * 60).length,
4346
+ averageHeartRateCoverage: round(average(sessions.map((session) => workoutHrCoverage(session))), 3),
4347
+ vo2MaxLatest,
4348
+ vo2MaxDelta,
4349
+ latestRestingHeartRate: latestVitalValue(vitalsTrend, "restingHeartRate"),
4350
+ readiness
4351
+ },
4352
+ zoneTotals: summarizeZoneDistribution(sessions),
4353
+ recentZoneTotals: summarizeZoneDistribution(recent28),
4354
+ intensityDistribution: summarizeIntensityDistribution(sessions),
4355
+ recentIntensityDistribution: summarizeIntensityDistribution(recent28),
4356
+ dailyLoad: dailyLoad.slice(-90),
4357
+ weeklyLoad,
4358
+ activityBreakdown,
4359
+ vitalsTrend,
4360
+ sessionSignals: sessions
4361
+ .slice(-200)
4362
+ .reverse()
4363
+ .map((session) => {
4364
+ const highSeconds = zoneSeconds(session, ["zone_4", "zone_5"]);
4365
+ const totalZoneSeconds = zoneSeconds(session, WORKOUT_ZONE_ORDER);
4366
+ return {
4367
+ id: session.id,
4368
+ dateKey: dayKey(session.startedAt),
4369
+ startedAt: session.startedAt,
4370
+ workoutType: session.workoutType,
4371
+ workoutTypeLabel: session.workoutTypeLabel ?? session.workoutType,
4372
+ durationMinutes: round(session.durationSeconds / 60, 1),
4373
+ trainingLoad: round(workoutLoad(session), 1),
4374
+ intensity: workoutIntensity(session),
4375
+ averageHr: workoutAverageHr(session),
4376
+ maxHr: workoutMaxHr(session),
4377
+ highIntensityPercentage: totalZoneSeconds > 0 ? round(highSeconds / totalZoneSeconds, 3) : 0,
4378
+ highIntensityMinutes: round(highSeconds / 60, 1),
4379
+ heartRateCoverage: workoutHrCoverage(session),
4380
+ heartRateSampleCount: workoutHrSampleCount(session),
4381
+ confidence: session.analytics?.confidence ??
4382
+ "unavailable",
4383
+ detailRoute: `/api/v1/health/workouts/${session.id}/detail`
4384
+ };
4385
+ }),
4386
+ targetModel: {
4387
+ model: "forge-training-load-v1",
4388
+ lowIntensityTarget: "70-85% of total endurance time",
4389
+ moderateIntensityTarget: "5-20% depending on phase and sport specificity",
4390
+ highIntensityTarget: "8-18% unless in a short peaking block",
4391
+ monitoringNotes: [
4392
+ "Use Forge TRIMP as an internal-load trend, not a medical diagnosis.",
4393
+ "Treat kickboxing and sparring as high-intensity days when Z4+Z5 is material.",
4394
+ "Prefer added easy aerobic volume when high-intensity minutes are already high.",
4395
+ "Chest-strap HR is recommended for combat sports when exact zone decisions matter."
4396
+ ]
4397
+ }
4398
+ };
4399
+ }
4065
4400
  export function getFitnessViewData(userIds) {
4066
4401
  const workoutRows = listWorkoutRows(userIds);
4067
4402
  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",
@@ -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.92",
5
+ "version": "0.2.94",
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.92",
3
+ "version": "0.2.94",
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",