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.
- package/dist/assets/{board-DKxKOwax.js → board-D1HbyD4u.js} +1 -1
- package/dist/assets/{index-BNvUaA6y.js → index-DWZd3qT-.js} +44 -44
- package/dist/assets/index-PA_Ih223.css +1 -0
- package/dist/assets/{motion-CM4AfIqo.js → motion-D2OqILg_.js} +1 -1
- package/dist/assets/{table-BUeQ9wzR.js → table-YWWjPjC_.js} +1 -1
- package/dist/assets/{ui-3Wd4pVaA.js → ui-DikPZj8S.js} +1 -1
- package/dist/assets/vendor-BS9OPVNh.js +2181 -0
- package/dist/companion-iroh/darwin-arm64/forge-companion-iroh +0 -0
- package/dist/companion-iroh/darwin-x64/forge-companion-iroh +0 -0
- package/dist/companion-iroh/linux-x64/forge-companion-iroh +0 -0
- package/dist/index.html +7 -7
- package/dist/openclaw/parity.js +1 -0
- package/dist/openclaw/routes.js +5 -0
- package/dist/openclaw/tools.js +7 -0
- package/dist/server/server/migrations/064_health_workout_time_series_identity.sql +161 -0
- package/dist/server/server/src/app.js +72 -2
- package/dist/server/server/src/health-workout-analytics.js +8 -2
- package/dist/server/server/src/health.js +337 -2
- package/dist/server/server/src/openapi.js +59 -0
- package/dist/server/src/lib/api.js +6 -0
- package/openclaw.plugin.json +2 -1
- package/package.json +1 -1
- package/server/migrations/064_health_workout_time_series_identity.sql +161 -0
- package/skills/forge-openclaw/SKILL.md +25 -7
- package/skills/forge-openclaw/entity_conversation_playbooks.md +88 -12
- package/skills/forge-openclaw/psyche_entity_playbooks.md +35 -0
- package/dist/assets/index-NqIbz_lv.css +0 -1
- 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
|
-
:
|
|
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()}`);
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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