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.
- package/dist/assets/{board-DKxKOwax.js → board-D1HbyD4u.js} +1 -1
- package/dist/assets/index-5w2YJv5G.css +1 -0
- package/dist/assets/{index-BNvUaA6y.js → index-lOGIgdyP.js} +47 -47
- 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/src/app.js +65 -2
- package/dist/server/server/src/health.js +355 -5
- package/dist/server/server/src/openapi.js +59 -0
- package/dist/server/server/src/web.js +6 -0
- package/dist/server/src/lib/api.js +6 -0
- package/openclaw.plugin.json +2 -1
- package/package.json +1 -1
- package/skills/forge-openclaw/SKILL.md +14 -9
- package/skills/forge-openclaw/entity_conversation_playbooks.md +105 -12
- 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";
|
|
@@ -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 =
|
|
2763
|
+
const captureHeartRateCount = summaryOnlyExport
|
|
2764
|
+
? null
|
|
2765
|
+
: finiteNumberFromUnknown(captureQuality.heartRateSamples);
|
|
2758
2766
|
const syncRoutePointCount = finiteNumberFromUnknown(syncCursor.routePointCount);
|
|
2759
|
-
const captureRoutePointCount =
|
|
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
|
-
?
|
|
2833
|
+
? evidenceCounts.hasCurrentRawEvidenceVersion &&
|
|
2834
|
+
actualTimeSeriesCount >= evidenceCounts.expectedTimeSeriesSamples &&
|
|
2835
|
+
actualHeartRateCount >= evidenceCounts.expectedHeartRateSamples &&
|
|
2808
2836
|
actualRoutePointCount >= evidenceCounts.expectedRoutePoints
|
|
2809
|
-
:
|
|
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()}`);
|
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.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