forge-openclaw-plugin 0.2.96 → 0.2.97

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/index.html CHANGED
@@ -13,14 +13,14 @@
13
13
  />
14
14
  <link rel="icon" type="image/png" href="/forge/assets/favicon-BCHm9dUV.ico" />
15
15
  <link rel="alternate icon" href="/forge/assets/favicon-BCHm9dUV.ico" />
16
- <script type="module" crossorigin src="/forge/assets/index-lOGIgdyP.js"></script>
17
- <link rel="modulepreload" crossorigin href="/forge/assets/vendor-BS9OPVNh.js">
18
- <link rel="modulepreload" crossorigin href="/forge/assets/board-D1HbyD4u.js">
19
- <link rel="modulepreload" crossorigin href="/forge/assets/ui-DikPZj8S.js">
20
- <link rel="modulepreload" crossorigin href="/forge/assets/motion-D2OqILg_.js">
21
- <link rel="modulepreload" crossorigin href="/forge/assets/table-YWWjPjC_.js">
16
+ <script type="module" crossorigin src="/forge/assets/index-CwvGs8n4.js"></script>
17
+ <link rel="modulepreload" crossorigin href="/forge/assets/vendor-DL2K5ayT.js">
18
+ <link rel="modulepreload" crossorigin href="/forge/assets/board-Ju0h0SeG.js">
19
+ <link rel="modulepreload" crossorigin href="/forge/assets/ui-C2IvSrAz.js">
20
+ <link rel="modulepreload" crossorigin href="/forge/assets/motion-DRPJkN3a.js">
21
+ <link rel="modulepreload" crossorigin href="/forge/assets/table-DewbFlTh.js">
22
22
  <link rel="stylesheet" crossorigin href="/forge/assets/vendor-B-Lq_OG3.css">
23
- <link rel="stylesheet" crossorigin href="/forge/assets/index-5w2YJv5G.css">
23
+ <link rel="stylesheet" crossorigin href="/forge/assets/index-Cn5Wpwau.css">
24
24
  </head>
25
25
  <body class="bg-canvas text-ink antialiased">
26
26
  <div id="root"></div>
@@ -755,7 +755,7 @@ export function registerForgePluginTools(api, config) {
755
755
  registerReadTool(api, config, {
756
756
  name: "forge_get_training_load_overview",
757
757
  label: "Forge Training Load Overview",
758
- description: "Read the cardiovascular training-load surface with acute/chronic load, HR zone distribution, weekly intensity targets, and data-quality flags.",
758
+ description: "Read the cardiovascular training-load surface with acute/chronic load, HR zone-time buckets, smart training modes, weekly targets, next-workout guidance, and data-quality flags.",
759
759
  parameters: scopedReadSchema,
760
760
  path: (params) => withUserIds("/api/v1/health/training-load", params.userIds)
761
761
  });
@@ -2247,7 +2247,7 @@ function buildPreferredMutationPath(entityType) {
2247
2247
  case "sports_overview":
2248
2248
  return "Read-only surface. Use batch CRUD for workout_session records or the review enrichment route for reflective notes.";
2249
2249
  case "training_load":
2250
- return "Read-only surface. Use it for cardiovascular load, HR zones, acute/chronic stress, VO2max context, and target analysis; use batch CRUD for underlying workout_session records.";
2250
+ return "Read-only surface. Use it for cardiovascular load, HR zones, zone-time buckets, smart training modes, acute/chronic stress, VO2max context, next-workout guidance, and target analysis; use batch CRUD for underlying workout_session records.";
2251
2251
  default:
2252
2252
  return "Read-only surface.";
2253
2253
  }
@@ -3044,14 +3044,14 @@ const AGENT_ONBOARDING_ENTITY_CATALOG = [
3044
3044
  }),
3045
3045
  enrichOnboardingEntityGuide({
3046
3046
  entityType: "training_load",
3047
- purpose: "The read-model cardiovascular training-load workspace for acute/chronic load, HR zone distribution, intensity targets, and VO2max context.",
3047
+ purpose: "The read-model cardiovascular training-load workspace for acute/chronic load, HR zone distribution, zone-time buckets, smart training modes, next-workout guidance, intensity targets, and VO2max context.",
3048
3048
  minimumCreateFields: [],
3049
3049
  relationshipRules: [
3050
3050
  "Use this surface for review and interpretation.",
3051
3051
  "Create, update, delete, or search the underlying workout_session records through batch CRUD by default."
3052
3052
  ],
3053
3053
  searchHints: [
3054
- "Read this surface before advising on high-intensity balance, recovery load, or cardiovascular training targets."
3054
+ "Read this surface before advising on high-intensity balance, recovery load, Zone 2/base work, 4x4 suitability, next-workout guidance, or cardiovascular training targets."
3055
3055
  ],
3056
3056
  fieldGuide: []
3057
3057
  })
@@ -4262,14 +4262,14 @@ const AGENT_ONBOARDING_TOOL_INPUT_CATALOG = [
4262
4262
  },
4263
4263
  {
4264
4264
  toolName: "forge_get_training_load_overview",
4265
- summary: "Read the cardiovascular training-load surface with acute/chronic load, HR zone distribution, weekly intensity targets, and data-quality flags.",
4265
+ summary: "Read the cardiovascular training-load surface with acute/chronic load, HR zone distribution, zone-time buckets, smart training modes, weekly targets, next-workout guidance, and data-quality flags.",
4266
4266
  whenToUse: "Use when the operator wants to analyze training stress, zone balance, VO2max context, combat-sport load, or what to optimize next.",
4267
4267
  inputShape: "{ userIds?: string[] }",
4268
4268
  requiredFields: [],
4269
4269
  notes: [
4270
4270
  "The API path is /api/v1/health/training-load and the UI route is /training-load.",
4271
4271
  "This is a read-model-only surface. Workout records remain ordinary workout_session entities for batch CRUD.",
4272
- "Forge uses HRR zone analytics and TRIMP-like internal load from stored workout evidence."
4272
+ "Forge uses HRR zone analytics, TRIMP-like internal load, per-bucket load rate, personal-baseline comparisons, and deterministic training-intelligence modes from stored workout evidence."
4273
4273
  ],
4274
4274
  example: '{"userIds":["user_operator"]}'
4275
4275
  },
@@ -6263,6 +6263,23 @@ function compactTrainingLoad(trainingLoad) {
6263
6263
  summary: trainingLoad.summary,
6264
6264
  intensityDistribution: trainingLoad.recentIntensityDistribution,
6265
6265
  weeklyLoad: trainingLoad.weeklyLoad.slice(-8),
6266
+ latestZoneTime: trainingLoad.zoneTimeSeries.weekly.at(-1) ??
6267
+ trainingLoad.zoneTimeSeries.daily.at(-1) ??
6268
+ null,
6269
+ trainingIntelligence: {
6270
+ defaultMode: trainingLoad.trainingIntelligence.defaultMode,
6271
+ modes: trainingLoad.trainingIntelligence.modes.map((mode) => ({
6272
+ key: mode.key,
6273
+ label: mode.label,
6274
+ score: mode.score,
6275
+ status: mode.status,
6276
+ confidence: mode.confidence,
6277
+ summary: mode.summary,
6278
+ loadBalance: mode.loadBalance,
6279
+ nextWorkout: mode.nextWorkout,
6280
+ nextWeekTargets: mode.nextWeekTargets
6281
+ }))
6282
+ },
6266
6283
  topActivities: trainingLoad.activityBreakdown.slice(0, 6),
6267
6284
  targetModel: trainingLoad.targetModel,
6268
6285
  detailRoute: "/api/v1/health/training-load"
@@ -4123,6 +4123,176 @@ function zoneSeconds(session, keys) {
4123
4123
  .filter((zone) => keys.includes(zone.key))
4124
4124
  .reduce((sum, zone) => sum + zone.seconds, 0);
4125
4125
  }
4126
+ const TRAINING_DOMAIN_KEYS = ["low", "moderate", "high"];
4127
+ function clampNumber(value, min, max) {
4128
+ return Math.max(min, Math.min(max, value));
4129
+ }
4130
+ function createZoneRecord() {
4131
+ return Object.fromEntries(WORKOUT_ZONE_ORDER.map((key) => [key, 0]));
4132
+ }
4133
+ function createDomainRecord() {
4134
+ return { low: 0, moderate: 0, high: 0 };
4135
+ }
4136
+ function addSessionZones(target, session) {
4137
+ const zones = session.analytics
4138
+ ?.zoneDurations ?? [];
4139
+ for (const zone of zones) {
4140
+ if (WORKOUT_ZONE_ORDER.includes(zone.key)) {
4141
+ target[zone.key] += zone.seconds;
4142
+ }
4143
+ }
4144
+ }
4145
+ function zoneRecordTotal(record) {
4146
+ return WORKOUT_ZONE_ORDER.reduce((sum, key) => sum + record[key], 0);
4147
+ }
4148
+ function domainSecondsFromZones(zones) {
4149
+ return {
4150
+ low: zones.below_z1 + zones.zone_1,
4151
+ moderate: zones.zone_2 + zones.zone_3,
4152
+ high: zones.zone_4 + zones.zone_5
4153
+ };
4154
+ }
4155
+ function percentageRecord(record, total) {
4156
+ return Object.fromEntries(Object.entries(record).map(([key, value]) => [
4157
+ key,
4158
+ total > 0 ? round(value / total, 4) : 0
4159
+ ]));
4160
+ }
4161
+ function minuteRecord(record) {
4162
+ return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, round(value / 60, 1)]));
4163
+ }
4164
+ function bucketConfidence(input) {
4165
+ if (input.sessionCount <= 0 || input.heartRateSampleCount <= 0) {
4166
+ return "unavailable";
4167
+ }
4168
+ if (input.averageHrCoverage >= 0.8 &&
4169
+ input.heartRateSampleCount >= input.sessionCount * 100) {
4170
+ return "high";
4171
+ }
4172
+ if (input.averageHrCoverage >= 0.45) {
4173
+ return "medium";
4174
+ }
4175
+ return "low";
4176
+ }
4177
+ function monthKey(value) {
4178
+ return value.slice(0, 7);
4179
+ }
4180
+ function monthEndDateKey(key) {
4181
+ const [year, month] = key.split("-").map((part) => Number(part));
4182
+ return dateKeyFromDate(new Date(Date.UTC(year, month, 0)));
4183
+ }
4184
+ function bucketDates(interval, session) {
4185
+ if (interval === "daily") {
4186
+ const key = dayKey(session.startedAt);
4187
+ return { bucketKey: key, startDate: key, endDate: key };
4188
+ }
4189
+ if (interval === "weekly") {
4190
+ const weekKey = isoWeekKey(session.startedAt);
4191
+ const date = new Date(session.startedAt);
4192
+ const day = date.getUTCDay() || 7;
4193
+ const start = addDays(date, 1 - day);
4194
+ const end = addDays(start, 6);
4195
+ return {
4196
+ bucketKey: weekKey,
4197
+ startDate: dateKeyFromDate(start),
4198
+ endDate: dateKeyFromDate(end)
4199
+ };
4200
+ }
4201
+ const key = monthKey(session.startedAt);
4202
+ return {
4203
+ bucketKey: key,
4204
+ startDate: `${key}-01`,
4205
+ endDate: monthEndDateKey(key)
4206
+ };
4207
+ }
4208
+ function buildZoneTimeBuckets(sessions, interval, limit) {
4209
+ const buckets = new Map();
4210
+ for (const session of sessions) {
4211
+ const dates = bucketDates(interval, session);
4212
+ const current = buckets.get(dates.bucketKey) ?? {
4213
+ ...dates,
4214
+ sessionCount: 0,
4215
+ durationSeconds: 0,
4216
+ trainingLoad: 0,
4217
+ zoneSeconds: createZoneRecord(),
4218
+ dayHighSeconds: new Map(),
4219
+ heartRateCoverageValues: [],
4220
+ heartRateSampleCount: 0
4221
+ };
4222
+ current.sessionCount += 1;
4223
+ current.durationSeconds += session.durationSeconds;
4224
+ current.trainingLoad += workoutLoad(session);
4225
+ addSessionZones(current.zoneSeconds, session);
4226
+ const sessionDayKey = dayKey(session.startedAt);
4227
+ current.dayHighSeconds.set(sessionDayKey, (current.dayHighSeconds.get(sessionDayKey) ?? 0) +
4228
+ zoneSeconds(session, ["zone_4", "zone_5"]));
4229
+ current.heartRateCoverageValues.push(workoutHrCoverage(session));
4230
+ current.heartRateSampleCount += workoutHrSampleCount(session);
4231
+ buckets.set(dates.bucketKey, current);
4232
+ }
4233
+ const rows = [...buckets.values()]
4234
+ .sort((left, right) => left.bucketKey.localeCompare(right.bucketKey))
4235
+ .slice(-limit)
4236
+ .map((bucket) => {
4237
+ const hrCoveredSeconds = zoneRecordTotal(bucket.zoneSeconds);
4238
+ const domains = domainSecondsFromZones(bucket.zoneSeconds);
4239
+ const durationMinutes = bucket.durationSeconds / 60;
4240
+ const loadPerMinute = durationMinutes > 0 ? round(bucket.trainingLoad / durationMinutes, 2) : 0;
4241
+ const loadPerHour = bucket.durationSeconds > 0
4242
+ ? round(bucket.trainingLoad / (bucket.durationSeconds / 3600), 1)
4243
+ : 0;
4244
+ const averageHrCoverage = round(average(bucket.heartRateCoverageValues), 3);
4245
+ return {
4246
+ bucketKey: bucket.bucketKey,
4247
+ startDate: bucket.startDate,
4248
+ endDate: bucket.endDate,
4249
+ sessionCount: bucket.sessionCount,
4250
+ durationSeconds: Math.round(bucket.durationSeconds),
4251
+ durationMinutes: round(bucket.durationSeconds / 60, 1),
4252
+ hrCoveredSeconds: Math.round(hrCoveredSeconds),
4253
+ trainingLoad: round(bucket.trainingLoad, 1),
4254
+ loadPerHour,
4255
+ loadPerMinute,
4256
+ baselineLoadRatio: null,
4257
+ baselineIntensityRatio: null,
4258
+ zoneSeconds: Object.fromEntries(WORKOUT_ZONE_ORDER.map((key) => [key, Math.round(bucket.zoneSeconds[key])])),
4259
+ zoneMinutes: minuteRecord(bucket.zoneSeconds),
4260
+ zonePercentages: percentageRecord(bucket.zoneSeconds, hrCoveredSeconds),
4261
+ domainSeconds: Object.fromEntries(TRAINING_DOMAIN_KEYS.map((key) => [key, Math.round(domains[key])])),
4262
+ domainMinutes: minuteRecord(domains),
4263
+ domainPercentages: percentageRecord(domains, hrCoveredSeconds),
4264
+ hardDayCount: [...bucket.dayHighSeconds.values()].filter((seconds) => seconds >= 10 * 60).length,
4265
+ averageHrCoverage,
4266
+ heartRateSampleCount: bucket.heartRateSampleCount,
4267
+ confidence: bucketConfidence({
4268
+ sessionCount: bucket.sessionCount,
4269
+ averageHrCoverage,
4270
+ heartRateSampleCount: bucket.heartRateSampleCount
4271
+ })
4272
+ };
4273
+ });
4274
+ return rows.map((row, index) => {
4275
+ const baselineWindow = interval === "daily"
4276
+ ? rows.slice(Math.max(0, index - 28), index)
4277
+ : interval === "weekly"
4278
+ ? rows.slice(Math.max(0, index - 4), index)
4279
+ : rows.slice(Math.max(0, index - 3), index);
4280
+ const baselineLoad = average(baselineWindow.map((bucket) => bucket.trainingLoad).filter((value) => value > 0));
4281
+ const baselineIntensity = average(baselineWindow.map((bucket) => bucket.loadPerMinute).filter((value) => value > 0));
4282
+ return {
4283
+ ...row,
4284
+ baselineLoadRatio: baselineLoad > 0 ? round(row.trainingLoad / baselineLoad, 2) : null,
4285
+ baselineIntensityRatio: baselineIntensity > 0 ? round(row.loadPerMinute / baselineIntensity, 2) : null
4286
+ };
4287
+ });
4288
+ }
4289
+ function buildZoneTimeSeries(sessions) {
4290
+ return {
4291
+ daily: buildZoneTimeBuckets(sessions, "daily", 90),
4292
+ weekly: buildZoneTimeBuckets(sessions, "weekly", 26),
4293
+ monthly: buildZoneTimeBuckets(sessions, "monthly", 12)
4294
+ };
4295
+ }
4126
4296
  function workoutLoad(session) {
4127
4297
  return (session.analytics?.load
4128
4298
  ?.trimp ?? 0);
@@ -4201,6 +4371,251 @@ function summarizeIntensityDistribution(sessions) {
4201
4371
  function latestVitalValue(vitalsTrend, key) {
4202
4372
  return [...vitalsTrend].reverse().find((entry) => entry[key] != null)?.[key] ?? null;
4203
4373
  }
4374
+ const MODE_DOMAIN_TARGETS = {
4375
+ combat_readiness: {
4376
+ label: "Combat readiness",
4377
+ statusLabel: "fight-ready balance",
4378
+ low: [0.62, 0.78],
4379
+ moderate: [0.1, 0.22],
4380
+ high: [0.06, 0.16],
4381
+ maxHardSessions: 2
4382
+ },
4383
+ aerobic_base: {
4384
+ label: "Aerobic base",
4385
+ statusLabel: "base-building balance",
4386
+ low: [0.72, 0.9],
4387
+ moderate: [0.05, 0.18],
4388
+ high: [0.02, 0.1],
4389
+ maxHardSessions: 1
4390
+ },
4391
+ endurance_pro: {
4392
+ label: "Endurance pro",
4393
+ statusLabel: "pyramidal/polarized balance",
4394
+ low: [0.7, 0.88],
4395
+ moderate: [0.04, 0.2],
4396
+ high: [0.06, 0.18],
4397
+ maxHardSessions: 2
4398
+ }
4399
+ };
4400
+ function targetMidpoint(range) {
4401
+ return (range[0] + range[1]) / 2;
4402
+ }
4403
+ function rangeScore(value, range) {
4404
+ if (value >= range[0] && value <= range[1]) {
4405
+ return 100;
4406
+ }
4407
+ const distance = value < range[0] ? range[0] - value : value - range[1];
4408
+ return clampNumber(100 - distance * 350, 0, 100);
4409
+ }
4410
+ function loadBalanceStatus(summary) {
4411
+ if (summary.readiness === "overload_watch" ||
4412
+ (summary.acuteChronicRatio ?? 0) > 1.35 ||
4413
+ (summary.strain7d ?? 0) > 450) {
4414
+ return "recover";
4415
+ }
4416
+ if ((summary.acuteChronicRatio ?? 1) < 0.8) {
4417
+ return "build";
4418
+ }
4419
+ if (summary.highIntensityMinutes7d >= 45) {
4420
+ return "maintain";
4421
+ }
4422
+ return "sharpen";
4423
+ }
4424
+ function scoreStatus(score, balance) {
4425
+ if (balance === "recover") {
4426
+ return "recovery_priority";
4427
+ }
4428
+ if (score >= 82) {
4429
+ return "on_target";
4430
+ }
4431
+ if (score >= 65) {
4432
+ return "watch";
4433
+ }
4434
+ return "needs_adjustment";
4435
+ }
4436
+ function scoreConfidence(summary, latestBucket) {
4437
+ const coverage = latestBucket?.averageHrCoverage ?? summary.averageHeartRateCoverage;
4438
+ if (coverage >= 0.85 && (latestBucket?.heartRateSampleCount ?? 0) >= 300) {
4439
+ return "high";
4440
+ }
4441
+ if (coverage >= 0.5) {
4442
+ return "medium";
4443
+ }
4444
+ if (coverage > 0) {
4445
+ return "low";
4446
+ }
4447
+ return "unavailable";
4448
+ }
4449
+ function targetMinutes(totalMinutes, range) {
4450
+ return [Math.round(totalMinutes * range[0]), Math.round(totalMinutes * range[1])];
4451
+ }
4452
+ function buildZoneMinuteTargets(totalMinutes, target) {
4453
+ const low = totalMinutes * targetMidpoint(target.low);
4454
+ const moderate = totalMinutes * targetMidpoint(target.moderate);
4455
+ const high = totalMinutes * targetMidpoint(target.high);
4456
+ return {
4457
+ below_z1: Math.round(low * 0.35),
4458
+ zone_1: Math.round(low * 0.65),
4459
+ zone_2: Math.round(moderate * 0.65),
4460
+ zone_3: Math.round(moderate * 0.35),
4461
+ zone_4: Math.round(high * 0.75),
4462
+ zone_5: Math.round(high * 0.25)
4463
+ };
4464
+ }
4465
+ function buildTrainingIntelligence(input) {
4466
+ const latestWeek = input.zoneTimeSeries.weekly.at(-1);
4467
+ const priorWeeks = input.zoneTimeSeries.weekly.slice(-5, -1);
4468
+ const baselineMinutes = average(priorWeeks.map((week) => week.durationMinutes).filter((value) => value > 0)) ||
4469
+ latestWeek?.durationMinutes ||
4470
+ 180;
4471
+ const balance = loadBalanceStatus(input.summary);
4472
+ const lowPct = input.recentIntensityDistribution.find((entry) => entry.key === "low")
4473
+ ?.percentage ?? 0;
4474
+ const moderatePct = input.recentIntensityDistribution.find((entry) => entry.key === "moderate")
4475
+ ?.percentage ?? 0;
4476
+ const highPct = input.recentIntensityDistribution.find((entry) => entry.key === "high")
4477
+ ?.percentage ?? 0;
4478
+ const currentLoadRatio = input.summary.acuteChronicRatio ?? 1;
4479
+ const loadBalance = {
4480
+ status: balance,
4481
+ acuteLoad7d: input.summary.acuteLoad7d,
4482
+ chronicWeeklyLoad28d: input.summary.chronicWeeklyLoad28d,
4483
+ acuteChronicRatio: input.summary.acuteChronicRatio,
4484
+ monotony7d: input.summary.monotony7d,
4485
+ strain7d: input.summary.strain7d,
4486
+ latestWeekLoad: latestWeek?.trainingLoad ?? 0,
4487
+ latestWeekLoadPerMinute: latestWeek?.loadPerMinute ?? 0,
4488
+ latestWeekBaselineLoadRatio: latestWeek?.baselineLoadRatio ?? null,
4489
+ latestWeekBaselineIntensityRatio: latestWeek?.baselineIntensityRatio ?? null
4490
+ };
4491
+ return {
4492
+ defaultMode: "combat_readiness",
4493
+ modes: Object.keys(MODE_DOMAIN_TARGETS).map((mode) => {
4494
+ const target = MODE_DOMAIN_TARGETS[mode];
4495
+ const lowScore = rangeScore(lowPct, target.low);
4496
+ const moderateScore = rangeScore(moderatePct, target.moderate);
4497
+ const highScore = rangeScore(highPct, target.high);
4498
+ const loadScore = balance === "recover"
4499
+ ? 45
4500
+ : clampNumber(100 - Math.abs(currentLoadRatio - 1) * 90, 0, 100);
4501
+ const hardDayScore = clampNumber(100 - Math.max(0, input.summary.hardDayCount7d - target.maxHardSessions) * 28, 0, 100);
4502
+ const qualityScore = clampNumber(input.summary.averageHeartRateCoverage * 100, 0, 100);
4503
+ const score = Math.round(lowScore * 0.22 +
4504
+ moderateScore * 0.14 +
4505
+ highScore * 0.2 +
4506
+ loadScore * 0.24 +
4507
+ hardDayScore * 0.12 +
4508
+ qualityScore * 0.08);
4509
+ const nextWeekScale = balance === "recover" ? 0.75 : balance === "build" ? 1.12 : 1;
4510
+ const nextWeekMinutes = Math.round(baselineMinutes * nextWeekScale);
4511
+ const shouldAllowFourByFour = balance !== "recover" &&
4512
+ highPct <= target.high[1] &&
4513
+ input.summary.hardDayCount7d < target.maxHardSessions &&
4514
+ scoreConfidence(input.summary, latestWeek) !== "low" &&
4515
+ scoreConfidence(input.summary, latestWeek) !== "unavailable";
4516
+ const drivers = [
4517
+ lowPct >= target.low[0]
4518
+ ? `Low/base work is ${Math.round(lowPct * 100)}% of recent HR-zone time.`
4519
+ : null,
4520
+ highPct <= target.high[1]
4521
+ ? `High-domain exposure is controlled at ${Math.round(highPct * 100)}%.`
4522
+ : null,
4523
+ currentLoadRatio >= 0.8 && currentLoadRatio <= 1.25
4524
+ ? `Acute load is close to chronic base at ${round(currentLoadRatio, 2)}x.`
4525
+ : null,
4526
+ latestWeek?.baselineLoadRatio != null
4527
+ ? `Latest week is ${latestWeek.baselineLoadRatio}x your recent weekly load baseline.`
4528
+ : null
4529
+ ].filter((entry) => Boolean(entry));
4530
+ const limitingFactors = [
4531
+ lowPct < target.low[0]
4532
+ ? `Low/base work is below the ${Math.round(target.low[0] * 100)}-${Math.round(target.low[1] * 100)}% target band.`
4533
+ : null,
4534
+ highPct > target.high[1]
4535
+ ? `High-domain work is above the ${Math.round(target.high[1] * 100)}% ceiling for this mode.`
4536
+ : null,
4537
+ balance === "recover"
4538
+ ? "Recent load balance points to recovery before another hard stimulus."
4539
+ : null,
4540
+ input.summary.hardDayCount7d > target.maxHardSessions
4541
+ ? `${input.summary.hardDayCount7d} hard days in 7 days exceeds this mode's target.`
4542
+ : null,
4543
+ input.summary.averageHeartRateCoverage < 0.5
4544
+ ? "Heart-rate evidence is thin, so zone decisions need caution."
4545
+ : null
4546
+ ].filter((entry) => Boolean(entry));
4547
+ return {
4548
+ key: mode,
4549
+ label: target.label,
4550
+ score,
4551
+ status: scoreStatus(score, balance),
4552
+ confidence: scoreConfidence(input.summary, latestWeek),
4553
+ summary: balance === "recover"
4554
+ ? `${target.label}: recover first, then rebuild the next hard stimulus.`
4555
+ : `${target.label}: ${target.statusLabel} is ${score >= 75 ? "mostly aligned" : "not yet aligned"}.`,
4556
+ drivers,
4557
+ limitingFactors,
4558
+ loadBalance,
4559
+ nextWeekTargets: {
4560
+ totalMinutesRange: balance === "recover"
4561
+ ? targetMinutes(baselineMinutes, [0.65, 0.85])
4562
+ : targetMinutes(nextWeekMinutes, [0.95, 1.08]),
4563
+ zoneMinuteTargets: buildZoneMinuteTargets(nextWeekMinutes, target),
4564
+ domainMinuteTargets: {
4565
+ low: targetMinutes(nextWeekMinutes, target.low),
4566
+ moderate: targetMinutes(nextWeekMinutes, target.moderate),
4567
+ high: targetMinutes(nextWeekMinutes, target.high)
4568
+ },
4569
+ maxHardSessions: balance === "recover" ? Math.max(0, target.maxHardSessions - 1) : target.maxHardSessions,
4570
+ minimumEasyMinutes: Math.round(nextWeekMinutes * target.low[0]),
4571
+ warning: balance === "recover"
4572
+ ? "Keep next week below baseline and avoid stacking hard sessions until load balance normalizes."
4573
+ : highPct > target.high[1]
4574
+ ? "Do not add another high-intensity session until the high-domain share drops."
4575
+ : null
4576
+ },
4577
+ nextWorkout: {
4578
+ recommendedType: balance === "recover"
4579
+ ? "recovery"
4580
+ : lowPct < target.low[0]
4581
+ ? "zone_2_base"
4582
+ : shouldAllowFourByFour
4583
+ ? "vo2max_4x4"
4584
+ : mode === "combat_readiness"
4585
+ ? "technical_kickboxing"
4586
+ : "easy_aerobic",
4587
+ intensityCeiling: balance === "recover"
4588
+ ? "Z1"
4589
+ : shouldAllowFourByFour
4590
+ ? "Z5 intervals with full recovery"
4591
+ : highPct > target.high[1]
4592
+ ? "Z2"
4593
+ : "Z3",
4594
+ durationMinutesRange: balance === "recover"
4595
+ ? [20, 40]
4596
+ : lowPct < target.low[0]
4597
+ ? [45, 75]
4598
+ : shouldAllowFourByFour
4599
+ ? [32, 48]
4600
+ : [35, 60],
4601
+ fourByFourAppropriate: shouldAllowFourByFour,
4602
+ reason: shouldAllowFourByFour
4603
+ ? "Load balance, hard-day count, high-domain share, and HR confidence can support one controlled VO2max stimulus."
4604
+ : balance === "recover"
4605
+ ? "Current load balance makes recovery more useful than another hard interval day."
4606
+ : highPct > target.high[1]
4607
+ ? "Recent high-domain exposure is already above this mode's target."
4608
+ : "A base or technical session fits the current distribution better than 4x4 work."
4609
+ },
4610
+ methodologyNotes: [
4611
+ "Scores are deterministic summaries of Forge HRR zones, TRIMP load, hard-day spacing, and evidence quality.",
4612
+ "ACWR, monotony, and strain are monitoring flags, not injury predictions.",
4613
+ "4x4 guidance is gated by freshness, recent high-intensity share, hard-day count, and HR confidence."
4614
+ ]
4615
+ };
4616
+ })
4617
+ };
4618
+ }
4204
4619
  export function getTrainingLoadViewData(userIds) {
4205
4620
  const workoutRows = listWorkoutRows(userIds);
4206
4621
  const sessions = workoutRows
@@ -4343,33 +4758,42 @@ export function getTrainingLoadViewData(userIds) {
4343
4758
  : acwr < 0.75
4344
4759
  ? "underloaded"
4345
4760
  : "productive";
4761
+ const summary = {
4762
+ sessionCount: sessions.length,
4763
+ reliableSessionCount: reliableSessions.length,
4764
+ totalHours: round(sessions.reduce((sum, session) => sum + session.durationSeconds, 0) / 3600, 1),
4765
+ totalTrainingLoad: round(sessions.reduce((sum, session) => sum + workoutLoad(session), 0), 1),
4766
+ acuteLoad7d: round(acuteLoad7d, 1),
4767
+ chronicWeeklyLoad28d: round(chronicWeeklyLoad28d, 1),
4768
+ acuteChronicRatio: acwr,
4769
+ monotony7d: monotony7d != null ? round(monotony7d, 2) : null,
4770
+ strain7d: strain7d != null ? round(strain7d, 1) : null,
4771
+ highIntensityMinutes7d: round(highSeconds7d / 60, 1),
4772
+ thresholdMinutes7d: round(moderateSeconds7d / 60, 1),
4773
+ easyMinutes7d: round(lowSeconds7d / 60, 1),
4774
+ hardDayCount7d: last7Keys.filter((key) => (dailyMap.get(key)?.highIntensitySeconds ?? 0) >= 10 * 60).length,
4775
+ averageHeartRateCoverage: round(average(sessions.map((session) => workoutHrCoverage(session))), 3),
4776
+ vo2MaxLatest,
4777
+ vo2MaxDelta,
4778
+ latestRestingHeartRate: latestVitalValue(vitalsTrend, "restingHeartRate"),
4779
+ readiness
4780
+ };
4781
+ const recentIntensityDistribution = summarizeIntensityDistribution(recent28);
4782
+ const zoneTimeSeries = buildZoneTimeSeries(sessions);
4346
4783
  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
- },
4784
+ summary,
4367
4785
  zoneTotals: summarizeZoneDistribution(sessions),
4368
4786
  recentZoneTotals: summarizeZoneDistribution(recent28),
4369
4787
  intensityDistribution: summarizeIntensityDistribution(sessions),
4370
- recentIntensityDistribution: summarizeIntensityDistribution(recent28),
4788
+ recentIntensityDistribution,
4371
4789
  dailyLoad: dailyLoad.slice(-90),
4372
4790
  weeklyLoad,
4791
+ zoneTimeSeries,
4792
+ trainingIntelligence: buildTrainingIntelligence({
4793
+ summary,
4794
+ recentIntensityDistribution,
4795
+ zoneTimeSeries
4796
+ }),
4373
4797
  activityBreakdown,
4374
4798
  vitalsTrend,
4375
4799
  sessionSignals: sessions
@@ -5178,6 +5178,8 @@ export function buildOpenApiDocument() {
5178
5178
  "recentIntensityDistribution",
5179
5179
  "dailyLoad",
5180
5180
  "weeklyLoad",
5181
+ "zoneTimeSeries",
5182
+ "trainingIntelligence",
5181
5183
  "activityBreakdown",
5182
5184
  "vitalsTrend",
5183
5185
  "sessionSignals",
@@ -5197,6 +5199,17 @@ export function buildOpenApiDocument() {
5197
5199
  }),
5198
5200
  dailyLoad: arrayOf({ type: "object", additionalProperties: true }),
5199
5201
  weeklyLoad: arrayOf({ type: "object", additionalProperties: true }),
5202
+ zoneTimeSeries: {
5203
+ type: "object",
5204
+ additionalProperties: false,
5205
+ required: ["daily", "weekly", "monthly"],
5206
+ properties: {
5207
+ daily: arrayOf({ type: "object", additionalProperties: true }),
5208
+ weekly: arrayOf({ type: "object", additionalProperties: true }),
5209
+ monthly: arrayOf({ type: "object", additionalProperties: true })
5210
+ }
5211
+ },
5212
+ trainingIntelligence: { type: "object", additionalProperties: true },
5200
5213
  activityBreakdown: arrayOf({ type: "object", additionalProperties: true }),
5201
5214
  vitalsTrend: arrayOf({ type: "object", additionalProperties: true }),
5202
5215
  sessionSignals: arrayOf({ type: "object", additionalProperties: true }),
@@ -3,9 +3,9 @@ import { useEffect, useId, useRef, useState } from "react";
3
3
  import { CircleHelp } from "lucide-react";
4
4
  import { cn } from "../../lib/utils.js";
5
5
  export function FieldHint({ children, className }) {
6
- return _jsx("div", { className: cn("text-sm leading-6 text-white/50", className), children: children });
6
+ return (_jsx("div", { className: cn("text-sm leading-6 text-white/50", className), children: children }));
7
7
  }
8
- export function InfoTooltip({ content, label = "Explain this field", className }) {
8
+ export function InfoTooltip({ content, title, label = "Explain this field", className, panelClassName }) {
9
9
  const [open, setOpen] = useState(false);
10
10
  const containerRef = useRef(null);
11
11
  const tooltipId = useId();
@@ -21,5 +21,9 @@ export function InfoTooltip({ content, label = "Explain this field", className }
21
21
  document.addEventListener("pointerdown", handlePointerDown);
22
22
  return () => document.removeEventListener("pointerdown", handlePointerDown);
23
23
  }, [open]);
24
- return (_jsxs("span", { ref: containerRef, className: cn("relative inline-flex items-center", className), onMouseEnter: () => setOpen(true), onMouseLeave: () => setOpen(false), children: [_jsx("button", { type: "button", "aria-label": label, "aria-describedby": open ? tooltipId : undefined, "aria-expanded": open, className: "inline-flex size-5 items-center justify-center rounded-full text-white/42 transition hover:bg-white/[0.06] hover:text-white/78 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[rgba(192,193,255,0.35)]", onFocus: () => setOpen(true), onBlur: () => setOpen(false), onClick: () => setOpen((current) => !current), children: _jsx(CircleHelp, { className: "size-3.5" }) }), _jsx("span", { id: tooltipId, role: "tooltip", className: cn("pointer-events-none absolute right-0 top-[calc(100%+0.55rem)] z-40 w-[min(16rem,calc(100vw-2.5rem))] max-w-[calc(100vw-2.5rem)] rounded-[18px] border border-white/8 bg-[rgba(12,17,30,0.96)] px-3 py-2.5 text-sm leading-6 text-white/74 shadow-[0_18px_48px_rgba(3,8,18,0.42)] transition", open ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0"), children: content })] }));
24
+ return (_jsxs("span", { ref: containerRef, className: cn("relative inline-flex items-center", className), onMouseEnter: () => setOpen(true), onMouseLeave: () => setOpen(false), children: [_jsx("button", { type: "button", "aria-label": label, "aria-describedby": open ? tooltipId : undefined, "aria-expanded": open, className: "inline-flex size-5 items-center justify-center rounded-full text-white/42 transition hover:bg-white/[0.06] hover:text-white/78 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[rgba(192,193,255,0.35)]", onFocus: () => setOpen(true), onBlur: () => setOpen(false), onClick: () => setOpen((current) => !current), onKeyDown: (event) => {
25
+ if (event.key === "Escape") {
26
+ setOpen(false);
27
+ }
28
+ }, children: _jsx(CircleHelp, { className: "size-3.5" }) }), _jsxs("span", { id: tooltipId, role: "tooltip", "aria-hidden": !open, "data-state": open ? "open" : "closed", className: cn("pointer-events-none absolute right-0 top-[calc(100%+0.55rem)] z-40 grid w-[min(20rem,calc(100vw-2.5rem))] max-w-[calc(100vw-2.5rem)] gap-1 rounded-[8px] border border-white/10 bg-[rgba(12,17,30,0.97)] px-3 py-2.5 text-left text-sm leading-6 text-white/74 shadow-[0_18px_48px_rgba(3,8,18,0.42)] transition", open ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0", panelClassName), children: [title ? (_jsx("span", { className: "text-[11px] font-semibold uppercase tracking-[0.14em] text-white/58", children: title })) : null, _jsx("span", { children: content })] })] }));
25
29
  }
@@ -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.96",
5
+ "version": "0.2.97",
6
6
  "activation": {
7
7
  "onStartup": true,
8
8
  "onCapabilities": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-openclaw-plugin",
3
- "version": "0.2.96",
3
+ "version": "0.2.97",
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",