@trainheroic-unofficial/athlete-mcp 3.5.1 → 3.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/server.mjs +282 -87
  2. package/package.json +3 -3
package/dist/server.mjs CHANGED
@@ -830,12 +830,16 @@ function resultBudget() {
830
830
  function isPlainObject(value) {
831
831
  return typeof value === "object" && value !== null && !Array.isArray(value);
832
832
  }
833
- /** Largest count k such that the JSON of the first k pre-serialized pieces fits. O(n). */
834
- function largestPrefixCount(pieces, charBudget) {
833
+ /**
834
+ * Largest count k such that the JSON of the first k elements fits. Elements are serialized one
835
+ * at a time and the walk stops at the first overflow, so an oversized 10k-row result costs the
836
+ * serialization of the rows that fit, not of every row.
837
+ */
838
+ function largestPrefixCount(elements, charBudget) {
835
839
  let used = 2;
836
840
  let k = 0;
837
- for (const piece of pieces) {
838
- const add = piece.length + (k > 0 ? 1 : 0);
841
+ for (const element of elements) {
842
+ const add = (JSON.stringify(element) ?? "null").length + (k > 0 ? 1 : 0);
839
843
  if (used + add > charBudget) break;
840
844
  used += add;
841
845
  k += 1;
@@ -843,11 +847,12 @@ function largestPrefixCount(pieces, charBudget) {
843
847
  return k;
844
848
  }
845
849
  function largestArrayValuedKey(obj) {
850
+ const arrayKeys = Object.keys(obj).filter((key) => Array.isArray(obj[key]));
851
+ if (arrayKeys.length <= 1) return arrayKeys[0] ?? null;
846
852
  let best = null;
847
853
  let bestLen = -1;
848
- for (const [key, value] of Object.entries(obj)) {
849
- if (!Array.isArray(value)) continue;
850
- const len = (JSON.stringify(value) ?? "[]").length;
854
+ for (const key of arrayKeys) {
855
+ const len = (JSON.stringify(obj[key]) ?? "[]").length;
851
856
  if (len > bestLen) {
852
857
  best = key;
853
858
  bestLen = len;
@@ -855,9 +860,6 @@ function largestArrayValuedKey(obj) {
855
860
  }
856
861
  return best;
857
862
  }
858
- function jsonValue(data) {
859
- return JSON.parse(JSON.stringify(data) ?? "null");
860
- }
861
863
  function previewEnvelope(source, budget, hint) {
862
864
  const total = source.length;
863
865
  const makeValue = (preview, markerHint) => ({
@@ -884,8 +886,8 @@ function boundedResult(data, budget, hint) {
884
886
  text: data,
885
887
  value: data
886
888
  };
887
- const value = jsonValue(data);
888
- const compact = JSON.stringify(value);
889
+ const compact = JSON.stringify(data) ?? "null";
890
+ const value = JSON.parse(compact);
889
891
  if (compact.length <= budget) {
890
892
  const pretty = JSON.stringify(value, null, 2);
891
893
  return {
@@ -894,7 +896,7 @@ function boundedResult(data, budget, hint) {
894
896
  };
895
897
  }
896
898
  if (Array.isArray(value)) {
897
- const truncated = clipArray(value, largestPrefixCount(value.map((element) => JSON.stringify(element)), budget - MARKER_RESERVE), hint);
899
+ const truncated = clipArray(value, largestPrefixCount(value, budget - MARKER_RESERVE), hint);
898
900
  const text = JSON.stringify(truncated);
899
901
  if (text.length <= budget) return {
900
902
  text,
@@ -904,7 +906,7 @@ function boundedResult(data, budget, hint) {
904
906
  const key = largestArrayValuedKey(value);
905
907
  if (key !== null) {
906
908
  const array = value[key];
907
- const truncated = clipArray(array, largestPrefixCount(array.map((element) => JSON.stringify(element)), budget - MARKER_RESERVE), hint ?? DEFAULT_OBJECT_HINT, key);
909
+ const truncated = clipArray(array, largestPrefixCount(array, budget - MARKER_RESERVE), hint ?? DEFAULT_OBJECT_HINT, key);
908
910
  const text = JSON.stringify(truncated);
909
911
  if (text.length <= budget) return {
910
912
  text,
@@ -1223,6 +1225,9 @@ function unitLabel(paramType) {
1223
1225
  function exerciseUnits(param1, param2) {
1224
1226
  return [unitLabel(param1), unitLabel(param2)];
1225
1227
  }
1228
+ function buildSearchText(title) {
1229
+ return title.trim().toLowerCase();
1230
+ }
1226
1231
  function isRecord(x) {
1227
1232
  return typeof x === "object" && x !== null && !Array.isArray(x);
1228
1233
  }
@@ -1277,6 +1282,82 @@ function rankSearch(rows, query, limit) {
1277
1282
  };
1278
1283
  }).sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.row);
1279
1284
  }
1285
+ /**
1286
+ * Map over items with a bounded number of concurrent workers. Used to fan out upstream
1287
+ * fetches (per-exercise history, the CLI export) without bursting the host all at once or,
1288
+ * on workerd, blowing the subrequest budget.
1289
+ */
1290
+ async function mapPool(items, limit, fn) {
1291
+ assertPositiveInteger(limit, "Concurrency limit");
1292
+ const out = Array.from({ length: items.length });
1293
+ let next = 0;
1294
+ const state = { failure: null };
1295
+ const worker = async () => {
1296
+ while (next < items.length && state.failure === null) {
1297
+ const i = next;
1298
+ next += 1;
1299
+ try {
1300
+ out[i] = await fn(items[i], i);
1301
+ } catch (error) {
1302
+ state.failure ??= { error };
1303
+ }
1304
+ }
1305
+ };
1306
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
1307
+ if (state.failure !== null) throw state.failure.error;
1308
+ return out;
1309
+ }
1310
+ function createLimiter(max) {
1311
+ assertPositiveInteger(max, "Maximum concurrency");
1312
+ let active = 0;
1313
+ let cancelled = null;
1314
+ const waiting = [];
1315
+ const acquire = (finalizer) => new Promise((resolve, reject) => {
1316
+ if (cancelled !== null && !finalizer) reject(cancelled.error);
1317
+ else if (active < max) {
1318
+ active += 1;
1319
+ resolve();
1320
+ } else waiting.push({
1321
+ start: () => {
1322
+ active += 1;
1323
+ resolve();
1324
+ },
1325
+ abort: reject,
1326
+ finalizer
1327
+ });
1328
+ });
1329
+ const release = () => {
1330
+ active -= 1;
1331
+ waiting.shift()?.start();
1332
+ };
1333
+ return {
1334
+ async run(task) {
1335
+ await acquire(false);
1336
+ try {
1337
+ return await task();
1338
+ } finally {
1339
+ release();
1340
+ }
1341
+ },
1342
+ async runFinalizer(task) {
1343
+ await acquire(true);
1344
+ try {
1345
+ return await task();
1346
+ } finally {
1347
+ release();
1348
+ }
1349
+ },
1350
+ cancel(error) {
1351
+ cancelled ??= { error };
1352
+ const queued = waiting.splice(0);
1353
+ for (const entry of queued) if (entry.finalizer) waiting.push(entry);
1354
+ else entry.abort(error);
1355
+ }
1356
+ };
1357
+ }
1358
+ function assertPositiveInteger(value, label) {
1359
+ if (!Number.isInteger(value) || value < 1) throw new RangeError(`${label} must be a positive integer; received ${value}.`);
1360
+ }
1280
1361
  //#endregion
1281
1362
  //#region ../js/src/athlete.ts
1282
1363
  async function getJson(client, path, label) {
@@ -1314,9 +1395,19 @@ function fetchAthleteCircuits(client, kind = "recent") {
1314
1395
  function fetchAthleteProgrammingPrograms(client) {
1315
1396
  return getArray(client, "/1.0/athlete/programming/programs", "athlete programming programs");
1316
1397
  }
1317
- /** Free-text search over the athlete's logged exercises (FTS replacement via rankSearch). */
1398
+ /**
1399
+ * Free-text search over the athlete's logged exercises (FTS replacement via rankSearch). Only
1400
+ * rows whose title carries every query token are candidates; rankSearch scores but never drops a
1401
+ * row, so ranking the whole catalog would pad a no-match query with the shortest titles up to
1402
+ * `limit`. Mirrors the coach `ExerciseLibrary.search` filter, and a blank query returns nothing.
1403
+ */
1318
1404
  async function searchExerciseHistory(client, query, limit = 20) {
1319
- return rankSearch(await fetchExerciseHistoryList(client), query, limit);
1405
+ const tokens = buildSearchText(query).split(/\s+/u).filter((t) => t.length > 0);
1406
+ if (tokens.length === 0) return [];
1407
+ return rankSearch((await fetchExerciseHistoryList(client)).filter((row) => {
1408
+ const text = buildSearchText(row.title);
1409
+ return tokens.every((t) => text.includes(t));
1410
+ }), query, limit);
1320
1411
  }
1321
1412
  function fetchExerciseHistoryDetail(client, exerciseId, userId) {
1322
1413
  return getJson(client, `/v5/exercises/${exerciseId}/history?userId=${userId}`, "athlete exercise history");
@@ -1371,6 +1462,28 @@ function slotValues(ex, requireMade) {
1371
1462
  }
1372
1463
  return map;
1373
1464
  }
1465
+ /**
1466
+ * The slots carrying data that the athlete has NOT marked performed (`param_i_made !== 1`). On a
1467
+ * saved-copy row these are the row's own targets: the pre-filled prescription, an athlete- or
1468
+ * coach-level override written with `made = 0` (`prescribeAthleteSet`), or a personal session's
1469
+ * only prescription, since personal work has no template tree at all.
1470
+ */
1471
+ function unmadeSlotValues(ex) {
1472
+ const map = slotValues(ex, false);
1473
+ for (const i of map.keys()) if (coerceInt(ex[`param_${i}_made`]) === 1) map.delete(i);
1474
+ return map;
1475
+ }
1476
+ /**
1477
+ * The prescription for one exercise: the template row's slots, with any target the saved copy
1478
+ * still holds unperformed taking precedence per set index (that is what the athlete's app shows,
1479
+ * and where a per-athlete override lives). Without a template row the saved copy's unperformed
1480
+ * slots are the whole prescription.
1481
+ */
1482
+ function prescriptionSlots(template, saved) {
1483
+ const map = template ? slotValues(template, false) : /* @__PURE__ */ new Map();
1484
+ if (saved) for (const [i, v] of unmadeSlotValues(saved)) map.set(i, v);
1485
+ return map;
1486
+ }
1374
1487
  /** The joined display for a slot, kept verbatim (`"5 @ 225"`, `"AMRAP"`, `"@ 225"`). */
1375
1488
  function fmtSlot(slot) {
1376
1489
  const has1 = slot.p1 !== null;
@@ -1380,10 +1493,6 @@ function fmtSlot(slot) {
1380
1493
  if (has2) return `@ ${slot.p2}`;
1381
1494
  return "";
1382
1495
  }
1383
- /** Every slot carrying data, joined for display (the prescription reader): `["5 @ 225", "AMRAP"]`. */
1384
- function prescribedStrings(ex) {
1385
- return [...slotValues(ex, false).values()].map(fmtSlot);
1386
- }
1387
1496
  /** The slots the athlete logged (`param_i_made === 1`), joined for display (the performed reader). */
1388
1497
  function performedStrings(ex) {
1389
1498
  return [...slotValues(ex, true).values()].map(fmtSlot);
@@ -1419,6 +1528,15 @@ function performedSlotsByExerciseId(sets) {
1419
1528
  }
1420
1529
  return map;
1421
1530
  }
1531
+ /** Each saved-copy row keyed by the prescription exercise id it was copied from. */
1532
+ function savedRowsByTemplateId(sets) {
1533
+ const map = /* @__PURE__ */ new Map();
1534
+ for (const { exercises } of sets) for (const ex of exercises) {
1535
+ const id = coerceInt(ex.workout_set_exercise_id);
1536
+ if (id !== null && !map.has(id)) map.set(id, ex);
1537
+ }
1538
+ return map;
1539
+ }
1422
1540
  /** Per-exercise athlete notes keyed by the prescription template id (`workout_set_exercise_id`). */
1423
1541
  function notesByTemplateId(sets) {
1424
1542
  const map = /* @__PURE__ */ new Map();
@@ -1442,7 +1560,7 @@ function mergeExercise(prescribed, performed, meta) {
1442
1560
  };
1443
1561
  }
1444
1562
  /** A prescription block, each exercise aligned with the slots the athlete logged against it. */
1445
- function mergePrescriptionBlock(set, performedById, notesById) {
1563
+ function mergePrescriptionBlock(set, performedById, savedById, notesById) {
1446
1564
  const exercises = Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : [];
1447
1565
  return {
1448
1566
  order: coerceInt(set.order) ?? 0,
@@ -1452,7 +1570,7 @@ function mergePrescriptionBlock(set, performedById, notesById) {
1452
1570
  exercises: exercises.filter(isRecord).map((ex) => {
1453
1571
  const id = coerceInt(ex.id);
1454
1572
  const performed = (id !== null ? performedById.get(id) : void 0) ?? /* @__PURE__ */ new Map();
1455
- return mergeExercise(slotValues(ex, false), performed, {
1573
+ return mergeExercise(prescriptionSlots(ex, id !== null ? savedById.get(id) : void 0), performed, {
1456
1574
  exerciseId: coerceInt(ex.exercise_id),
1457
1575
  title: typeof ex.title === "string" ? ex.title : "",
1458
1576
  instruction: str(ex.instruction),
@@ -1462,14 +1580,18 @@ function mergePrescriptionBlock(set, performedById, notesById) {
1462
1580
  })
1463
1581
  };
1464
1582
  }
1465
- /** A logged block straight from the saved copy (athlete-added or personal work; no prescription). */
1583
+ /**
1584
+ * A block straight from the saved copy (athlete-added or personal work; no template row). Its
1585
+ * unperformed slots are the prescription — a personal session's targets live only here — and its
1586
+ * performed slots are what was logged.
1587
+ */
1466
1588
  function mergeSavedBlock(set, exercises) {
1467
1589
  return {
1468
1590
  order: coerceInt(set.order) ?? 0,
1469
1591
  title: str(set.title),
1470
1592
  instruction: str(set.instruction),
1471
1593
  isTest: coerceInt(set.is_test) === 1,
1472
- exercises: exercises.map((ex) => mergeExercise(/* @__PURE__ */ new Map(), slotValues(ex, true), {
1594
+ exercises: exercises.map((ex) => mergeExercise(prescriptionSlots(void 0, ex), slotValues(ex, true), {
1473
1595
  exerciseId: coerceInt(ex.exercise_id),
1474
1596
  title: typeof ex.exercise_title === "string" ? ex.exercise_title : "",
1475
1597
  instruction: str(ex.instruction),
@@ -1492,8 +1614,9 @@ function mergeAthleteWorkout(raw) {
1492
1614
  const prescriptionSets = (Array.isArray(workout.workoutSets) ? workout.workoutSets : []).filter(isRecord);
1493
1615
  const logged = savedSets(saved);
1494
1616
  const performedById = performedSlotsByExerciseId(logged);
1617
+ const savedById = savedRowsByTemplateId(logged);
1495
1618
  const notesById = notesByTemplateId(logged);
1496
- const blocks = prescriptionSets.map((s) => mergePrescriptionBlock(s, performedById, notesById)).sort((a, b) => a.order - b.order);
1619
+ const blocks = prescriptionSets.map((s) => mergePrescriptionBlock(s, performedById, savedById, notesById)).sort((a, b) => a.order - b.order);
1497
1620
  const prescribedIds = /* @__PURE__ */ new Set();
1498
1621
  for (const s of prescriptionSets) {
1499
1622
  const exs = Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : [];
@@ -1505,7 +1628,7 @@ function mergeAthleteWorkout(raw) {
1505
1628
  }
1506
1629
  for (const { set, exercises } of logged) {
1507
1630
  const extra = exercises.filter((ex) => {
1508
- if (slotValues(ex, true).size === 0) return false;
1631
+ if (slotValues(ex, false).size === 0) return false;
1509
1632
  const id = coerceInt(ex.workout_set_exercise_id);
1510
1633
  return id === null || !prescribedIds.has(id);
1511
1634
  });
@@ -1616,6 +1739,16 @@ function presentLogTargets(list) {
1616
1739
  const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
1617
1740
  const saved = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
1618
1741
  if (!saved) continue;
1742
+ const workout = isRecord(ssw.workout) ? ssw.workout : {};
1743
+ const templatesById = /* @__PURE__ */ new Map();
1744
+ for (const tSet of Array.isArray(workout.workoutSets) ? workout.workoutSets : []) {
1745
+ if (!isRecord(tSet)) continue;
1746
+ const tExs = Array.isArray(tSet.workoutSetExercises) ? tSet.workoutSetExercises : [];
1747
+ for (const tEx of tExs) {
1748
+ const tId = isRecord(tEx) ? coerceInt(tEx.id) : null;
1749
+ if (tId !== null && isRecord(tEx)) templatesById.set(tId, tEx);
1750
+ }
1751
+ }
1619
1752
  const date = str(rec.date) ?? "";
1620
1753
  const workoutTitle = str(rec.workout_title) ?? "";
1621
1754
  const program = str(rec.program_title);
@@ -1630,12 +1763,14 @@ function presentLogTargets(list) {
1630
1763
  const exercises = (Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : []).filter(isRecord).map((ex) => {
1631
1764
  const id = coerceInt(ex.id);
1632
1765
  if (id === null) return null;
1766
+ const templateId = coerceInt(ex.workout_set_exercise_id);
1767
+ const template = templateId === null ? void 0 : templatesById.get(templateId);
1633
1768
  return {
1634
1769
  savedWorkoutSetExerciseId: id,
1635
1770
  title: exerciseTitle(ex),
1636
1771
  units: exerciseUnits(ex.param_1_type, ex.param_2_type),
1637
1772
  notes: str(ex.notes),
1638
- prescribed: prescribedStrings(ex),
1773
+ prescribed: [...prescriptionSlots(template, ex).values()].map(fmtSlot),
1639
1774
  performed: performedStrings(ex)
1640
1775
  };
1641
1776
  }).filter((e) => e !== null);
@@ -1712,6 +1847,28 @@ function presentExerciseHistory(detail) {
1712
1847
  };
1713
1848
  }
1714
1849
  //#endregion
1850
+ //#region ../js/src/exercise-history.ts
1851
+ /**
1852
+ * Trim a presented exercise history's session time-series to an inclusive YYYY-MM-DD window.
1853
+ * The `liftPRs` board stays all-time (PRs are not a windowed concept). Dates compare as their
1854
+ * first 10 chars so both "YYYY-MM-DD" and "YYYY-MM-DDThh:mm" values filter correctly. The one
1855
+ * window rule shared by the athlete's own history tool, the coach's per-roster-athlete history
1856
+ * tool, and the CLI, so a timestamped session cannot fall out of an inclusive upper bound.
1857
+ */
1858
+ function historyInRange(presented, since, until) {
1859
+ if (since === void 0 && until === void 0) return presented;
1860
+ const sessions = presented.sessions.filter((s) => {
1861
+ const d = (s.date ?? "").slice(0, 10);
1862
+ if (since !== void 0 && d < since) return false;
1863
+ if (until !== void 0 && d > until) return false;
1864
+ return true;
1865
+ });
1866
+ return {
1867
+ ...presented,
1868
+ sessions
1869
+ };
1870
+ }
1871
+ //#endregion
1715
1872
  //#region ../js/src/exercise-set-payload.ts
1716
1873
  function slotData(exercise, key) {
1717
1874
  const value = exercise?.[key];
@@ -1782,6 +1939,7 @@ function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, w
1782
1939
  }
1783
1940
  //#endregion
1784
1941
  //#region ../js/src/athlete-set-write.ts
1942
+ const WRITE_CONCURRENCY = 4;
1785
1943
  /**
1786
1944
  * Coerce the loosely-typed `results` from a validated log/prescribe args object into the SDK's
1787
1945
  * {@link SetResult}[]. The dto schemas validate ids as a number or a numeric string and leave
@@ -1974,6 +2132,41 @@ async function swapAthleteExercise(client, args) {
1974
2132
  originalTeamExerciseId: coerceInt(template.exercise_id)
1975
2133
  };
1976
2134
  }
2135
+ function prepareSetWrite(target, workouts, savedWorkoutSetId, results, mode) {
2136
+ const { exercises, rawSet } = findSavedWorkoutSet(workouts, savedWorkoutSetId);
2137
+ assertUniqueExerciseResults(results);
2138
+ const suffix = target.role === "coach" ? `/${target.athleteId}` : "";
2139
+ const extra = target.role === "coach" ? { athleteId: target.athleteId } : {};
2140
+ return {
2141
+ savedWorkoutSetId,
2142
+ exercises,
2143
+ rawSet,
2144
+ suffix,
2145
+ extra,
2146
+ writes: results.map((result) => {
2147
+ const ex = exercises.find((candidate) => coerceInt(candidate.id) === result.savedWorkoutSetExerciseId);
2148
+ if (!ex) {
2149
+ const valid = exercises.map((candidate) => {
2150
+ const id = coerceInt(candidate.id);
2151
+ return id === null ? null : `${id} (${exerciseTitle(candidate, "exercise")})`;
2152
+ }).filter((label) => label !== null);
2153
+ throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${savedWorkoutSetId}. Exercises in this set: ${valid.join(", ") || "none"}.`);
2154
+ }
2155
+ const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
2156
+ if (!workoutSetExerciseId) throw new Error(`savedWorkoutSetExercise ${result.savedWorkoutSetExerciseId} is missing its workout_set_exercise_id (the prescription-template pointer the write needs). This is the savedWorkoutSetExerciseId, not an exercise_id — re-read the ids from athlete_saved_workouts.`);
2157
+ return {
2158
+ id: result.savedWorkoutSetExerciseId,
2159
+ body: {
2160
+ ...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode, ex),
2161
+ ...extra
2162
+ }
2163
+ };
2164
+ })
2165
+ };
2166
+ }
2167
+ function errorMessage(error) {
2168
+ return error instanceof Error ? error.message : String(error);
2169
+ }
1977
2170
  /**
1978
2171
  * Shared set-write behind {@link logAthleteSet}, {@link prescribeAthleteSet},
1979
2172
  * {@link logForAthlete}, and {@link prescribeForAthlete}. `target` selects the surface: `athlete`
@@ -1987,36 +2180,40 @@ async function swapAthleteExercise(client, args) {
1987
2180
  * the written ones); `"prescribe"` writes them as a prescription and skips Step 2, leaving the set
1988
2181
  * open.
1989
2182
  */
1990
- async function writeSetResults(client, target, workouts, savedWorkoutSetId, results, mode) {
1991
- const { exercises, rawSet } = findSavedWorkoutSet(workouts, savedWorkoutSetId);
1992
- assertUniqueExerciseResults(results);
1993
- const suffix = target.role === "coach" ? `/${target.athleteId}` : "";
1994
- const extra = target.role === "coach" ? { athleteId: target.athleteId } : {};
1995
- let exercisesWritten = 0;
1996
- const projectedCompletion = /* @__PURE__ */ new Map();
1997
- for (const result of results) {
1998
- const ex = exercises.find((e) => coerceInt(e.id) === result.savedWorkoutSetExerciseId);
1999
- if (!ex) {
2000
- const valid = exercises.map((e) => {
2001
- const id = coerceInt(e.id);
2002
- return id === null ? null : `${id} (${exerciseTitle(e, "exercise")})`;
2003
- }).filter((x) => x !== null);
2004
- throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${savedWorkoutSetId}. Exercises in this set: ${valid.join(", ") || "none"}.`);
2005
- }
2006
- const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
2007
- if (!workoutSetExerciseId) throw new Error(`savedWorkoutSetExercise ${result.savedWorkoutSetExerciseId} is missing its workout_set_exercise_id (the prescription-template pointer the write needs). This is the savedWorkoutSetExerciseId, not an exercise_id — re-read the ids from athlete_saved_workouts.`);
2008
- const body = {
2009
- ...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode, ex),
2010
- ...extra
2183
+ async function executeSetWrite(client, target, prepared, mode, limit, sessionProgress) {
2184
+ const { savedWorkoutSetId, exercises, rawSet, suffix, extra, writes } = prepared;
2185
+ const put = (path, body, failureMessage, finalizer = false) => {
2186
+ const request = async () => {
2187
+ try {
2188
+ const res = await client.request("PUT", path, { body });
2189
+ if (!res.ok) throw new Error(failureMessage(res.status));
2190
+ } catch (error) {
2191
+ limit.cancel(error);
2192
+ throw error;
2193
+ }
2011
2194
  };
2012
- const res = await client.request("PUT", `/1.0/${target.role}/savedworkoutsetexercise/${result.savedWorkoutSetExerciseId}${suffix}`, { body });
2013
- if (!res.ok) {
2014
- const readOnly = target.role === "coach" && (res.status === 401 || res.status === 403) ? ` Athlete ${target.athleteId} appears to be read-only for changes — TrainHeroic's seeded demo/sample athletes return ${res.status} here; writes only persist for real (invited) athletes.` : "";
2015
- throw new Error(`Failed to write exercise ${result.savedWorkoutSetExerciseId} (HTTP ${res.status}).${readOnly}`);
2016
- }
2017
- projectedCompletion.set(result.savedWorkoutSetExerciseId, body.completed === 1);
2018
- exercisesWritten += 1;
2195
+ return finalizer ? limit.runFinalizer(request) : limit.run(request);
2196
+ };
2197
+ const confirmed = [];
2198
+ try {
2199
+ await mapPool(writes, WRITE_CONCURRENCY, async ({ id, body }) => {
2200
+ await put(`/1.0/${target.role}/savedworkoutsetexercise/${id}${suffix}`, body, (status) => {
2201
+ return `Failed to write exercise ${id} (HTTP ${status}).${target.role === "coach" && (status === 401 || status === 403) ? ` Athlete ${target.athleteId} appears to be read-only for changes — TrainHeroic's seeded demo/sample athletes return ${status} here; writes only persist for real (invited) athletes.` : ""}`;
2202
+ });
2203
+ confirmed.push(id);
2204
+ if (sessionProgress) {
2205
+ const sessionConfirmed = sessionProgress.get(savedWorkoutSetId) ?? [];
2206
+ sessionConfirmed.push(id);
2207
+ sessionProgress.set(savedWorkoutSetId, sessionConfirmed);
2208
+ }
2209
+ });
2210
+ } catch (error) {
2211
+ if (sessionProgress || confirmed.length === 0) throw error;
2212
+ confirmed.sort((a, b) => a - b);
2213
+ throw new Error(`${errorMessage(error)} Confirmed exercise writes before the failure: ${confirmed.join(", ")}. The set was not marked complete; retry the same request to reconcile it.`, { cause: error });
2019
2214
  }
2215
+ const exercisesWritten = writes.length;
2216
+ const projectedCompletion = new Map(writes.map((w) => [w.id, w.body.completed === 1]));
2020
2217
  let setCompleted = false;
2021
2218
  if (mode === "log") {
2022
2219
  if (isSetFullyLogged(exercises, projectedCompletion)) {
@@ -2024,8 +2221,11 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
2024
2221
  ...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
2025
2222
  ...extra
2026
2223
  };
2027
- const setRes = await client.request("PUT", `/1.0/${target.role}/savedworkoutset/${savedWorkoutSetId}${suffix}`, { body: setBody });
2028
- if (!setRes.ok) throw new Error(`Failed to mark workout set ${savedWorkoutSetId} completed (HTTP ${setRes.status}).`);
2224
+ try {
2225
+ await put(`/1.0/${target.role}/savedworkoutset/${savedWorkoutSetId}${suffix}`, setBody, (status) => `Failed to mark workout set ${savedWorkoutSetId} completed (HTTP ${status}).`, true);
2226
+ } catch (error) {
2227
+ throw new Error(`${errorMessage(error)} Exercise values were written successfully; retry the same request to complete the set.`, { cause: error });
2228
+ }
2029
2229
  setCompleted = true;
2030
2230
  }
2031
2231
  }
@@ -2035,6 +2235,9 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
2035
2235
  setCompleted
2036
2236
  };
2037
2237
  }
2238
+ async function writeSetResults(client, target, workouts, savedWorkoutSetId, results, mode, limit = createLimiter(WRITE_CONCURRENCY)) {
2239
+ return executeSetWrite(client, target, prepareSetWrite(target, workouts, savedWorkoutSetId, results, mode), mode, limit);
2240
+ }
2038
2241
  /**
2039
2242
  * POST /v5/programWorkouts/personal — create a personal workout session for a given date.
2040
2243
  * Returns the key ids: workoutId (needed for addExercisesToWorkout), programWorkoutId,
@@ -2180,7 +2383,7 @@ function findScheduledMatches(workouts, exerciseIds) {
2180
2383
  return out;
2181
2384
  }
2182
2385
  /** Group resolved exercises by saved set and write each set via the given log target. */
2183
- async function logResolvedExercises(client, target, workouts, resolved) {
2386
+ async function logResolvedExercises(client, target, workouts, resolved, retryGuidance) {
2184
2387
  const bySet = /* @__PURE__ */ new Map();
2185
2388
  for (const r of resolved) {
2186
2389
  const list = bySet.get(r.savedWorkoutSetId) ?? [];
@@ -2190,15 +2393,28 @@ async function logResolvedExercises(client, target, workouts, resolved) {
2190
2393
  });
2191
2394
  bySet.set(r.savedWorkoutSetId, list);
2192
2395
  }
2193
- const out = [];
2194
- for (const [savedWorkoutSetId, results] of bySet) {
2195
- const written = await writeSetResults(client, target, workouts, savedWorkoutSetId, results, "log");
2196
- out.push({
2197
- savedWorkoutSetId: written.savedWorkoutSetId,
2198
- exercisesLogged: written.exercisesWritten
2396
+ const prepared = [...bySet].map(([savedWorkoutSetId, results]) => prepareSetWrite(target, workouts, savedWorkoutSetId, results, "log"));
2397
+ const limit = createLimiter(WRITE_CONCURRENCY);
2398
+ const confirmedBySet = /* @__PURE__ */ new Map();
2399
+ const succeeded = [];
2400
+ try {
2401
+ return await mapPool(prepared, WRITE_CONCURRENCY, async (set) => {
2402
+ const written = await executeSetWrite(client, target, set, "log", limit, confirmedBySet);
2403
+ succeeded.push(written.savedWorkoutSetId);
2404
+ return {
2405
+ savedWorkoutSetId: written.savedWorkoutSetId,
2406
+ exercisesLogged: written.exercisesWritten
2407
+ };
2199
2408
  });
2409
+ } catch (error) {
2410
+ const completed = new Set(succeeded);
2411
+ const incomplete = [...confirmedBySet].filter(([savedWorkoutSetId]) => !completed.has(savedWorkoutSetId)).sort(([a], [b]) => a - b).map(([savedWorkoutSetId, ids]) => `set ${savedWorkoutSetId}: ${ids.toSorted((a, b) => a - b).join(", ")}`);
2412
+ if (incomplete.length === 0 && succeeded.length === 0) throw error;
2413
+ succeeded.sort((a, b) => a - b);
2414
+ const partial = incomplete.length === 0 ? "" : ` Confirmed exercise writes in incomplete sets before the failure: ${incomplete.join("; ")}.${retryGuidance ? ` ${retryGuidance}` : ""}`;
2415
+ const complete = succeeded.length === 0 ? "" : ` Set writes confirmed before the failure: ${succeeded.join(", ")}.`;
2416
+ throw new Error(`${errorMessage(error)}${partial}${complete}`, { cause: error });
2200
2417
  }
2201
- return out;
2202
2418
  }
2203
2419
  /**
2204
2420
  * Log a whole session for the logged-in athlete by exercise, with no pre-existing prescription
@@ -2312,27 +2528,6 @@ async function setAthleteExerciseNote(client, args) {
2312
2528
  notes: typeof data.notes === "string" ? data.notes : args.notes
2313
2529
  };
2314
2530
  }
2315
- //#endregion
2316
- //#region ../core/src/history.ts
2317
- /**
2318
- * Trim a presented exercise history's session time-series to an inclusive YYYY-MM-DD window.
2319
- * The `liftPRs` board stays all-time (PRs are not a windowed concept). Dates compare as their
2320
- * first 10 chars so both "YYYY-MM-DD" and "YYYY-MM-DDThh:mm" values filter correctly. Shared by
2321
- * the athlete's own history tool and the coach's per-roster-athlete history tool.
2322
- */
2323
- function historyInRange(presented, since, until) {
2324
- if (since === void 0 && until === void 0) return presented;
2325
- const sessions = presented.sessions.filter((s) => {
2326
- const d = (s.date ?? "").slice(0, 10);
2327
- if (since !== void 0 && d < since) return false;
2328
- if (until !== void 0 && d > until) return false;
2329
- return true;
2330
- });
2331
- return {
2332
- ...presented,
2333
- sessions
2334
- };
2335
- }
2336
2531
  z.number().int().positive().max(36).optional();
2337
2532
  //#endregion
2338
2533
  //#region ../core/src/tools/athlete-training.ts
@@ -2765,7 +2960,7 @@ function registerAthleteTrainingTools(server, ctx) {
2765
2960
  }
2766
2961
  //#endregion
2767
2962
  //#region package.json
2768
- var version = "3.5.1";
2963
+ var version = "3.5.2";
2769
2964
  //#endregion
2770
2965
  //#region src/server.ts
2771
2966
  function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/athlete-mcp",
3
- "version": "3.5.1",
3
+ "version": "3.5.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,8 +21,8 @@
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/server": "2.0.0",
23
23
  "zod": "^4.4.3",
24
- "@trainheroic-unofficial/core": "3.5.1",
25
- "@trainheroic-unofficial/js": "3.5.1"
24
+ "@trainheroic-unofficial/core": "3.5.2",
25
+ "@trainheroic-unofficial/js": "3.5.2"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^26.3.0",