incremnt 0.8.3 → 0.8.4
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/package.json +1 -1
- package/src/ask-coach.js +180 -46
- package/src/openrouter.js +3 -3
- package/src/prompt-changelog.js +18 -0
- package/src/queries.js +44 -2
- package/src/sync-service.js +163 -48
package/package.json
CHANGED
package/src/ask-coach.js
CHANGED
|
@@ -539,18 +539,18 @@ function pushAskContextHeader(lines, snapshot, today = new Date()) {
|
|
|
539
539
|
const ASK_FACT_KIND_BY_ROUTE = Object.freeze({
|
|
540
540
|
general: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
541
541
|
progress_review: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
542
|
-
exercise_progress: ['goal_signal', 'injury', 'constraint', 'preference'],
|
|
543
|
-
exercise_progress_summary: ['goal_signal', 'injury', 'constraint', 'preference'],
|
|
544
|
-
program_progress: ['goal_signal', 'preference', 'constraint', 'injury'],
|
|
542
|
+
exercise_progress: ['goal_signal', 'injury', 'constraint', 'preference', 'tone'],
|
|
543
|
+
exercise_progress_summary: ['goal_signal', 'injury', 'constraint', 'preference', 'tone'],
|
|
544
|
+
program_progress: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
545
545
|
training_profile: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
546
|
-
program_design: ['goal_signal', 'preference', 'constraint', 'injury'],
|
|
547
|
-
next_session: ['constraint', 'injury', 'preference', 'goal_signal'],
|
|
548
|
-
recent_session: ['injury', 'constraint', 'goal_signal'],
|
|
546
|
+
program_design: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
547
|
+
next_session: ['constraint', 'injury', 'preference', 'goal_signal', 'tone'],
|
|
548
|
+
recent_session: ['injury', 'constraint', 'goal_signal', 'tone'],
|
|
549
549
|
recovery: ['injury', 'constraint', 'tone'],
|
|
550
|
-
body_weight: ['goal_signal'],
|
|
551
|
-
volume: ['goal_signal', 'constraint'],
|
|
552
|
-
records: ['goal_signal'],
|
|
553
|
-
score: ['goal_signal', 'constraint']
|
|
550
|
+
body_weight: ['goal_signal', 'tone'],
|
|
551
|
+
volume: ['goal_signal', 'constraint', 'tone'],
|
|
552
|
+
records: ['goal_signal', 'tone'],
|
|
553
|
+
score: ['goal_signal', 'constraint', 'tone']
|
|
554
554
|
});
|
|
555
555
|
|
|
556
556
|
function normalizeCoachFactForContext(row) {
|
|
@@ -611,6 +611,13 @@ function appendCoachFactsContext(lines, facts) {
|
|
|
611
611
|
.join(', ');
|
|
612
612
|
lines.push(` [${fact.kind}] ${fact.fact}${provenance ? ` (${provenance})` : ''}`);
|
|
613
613
|
}
|
|
614
|
+
const toneFacts = facts
|
|
615
|
+
.filter((fact) => fact.kind === 'tone')
|
|
616
|
+
.map((fact) => fact.fact)
|
|
617
|
+
.filter(Boolean);
|
|
618
|
+
if (toneFacts.length > 0) {
|
|
619
|
+
lines.push(`Answer style preference to carry explicitly: ${toneFacts.join(' ')}`);
|
|
620
|
+
}
|
|
614
621
|
return facts.map((fact) => fact.id).filter(Boolean);
|
|
615
622
|
}
|
|
616
623
|
|
|
@@ -857,17 +864,29 @@ function appendReadinessSummary(lines, readiness) {
|
|
|
857
864
|
// Per-route prose builders that compose tool results into the routed
|
|
858
865
|
// Ask Coach context, attaching provenance for each section.
|
|
859
866
|
|
|
867
|
+
function appendWeeklyVolumeEvidence(lines, weeklyVolume) {
|
|
868
|
+
const facts = weeklyVolume?.facts ?? {};
|
|
869
|
+
const isPartial = facts.currentWeekIsPartial === true;
|
|
870
|
+
const currentLabel = isPartial ? 'Week-to-date strength volume' : 'This week strength volume';
|
|
871
|
+
const previousLabel = 'Previous full week strength volume';
|
|
872
|
+
lines.push(`${currentLabel}: ${facts.currentWeekVolume} kg across ${facts.currentWeekSessionCount} session${facts.currentWeekSessionCount === 1 ? '' : 's'}.`);
|
|
873
|
+
lines.push(`${previousLabel}: ${facts.previousWeekVolume} kg across ${facts.previousWeekSessionCount} session${facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
|
|
874
|
+
if (facts.deltaPct != null) {
|
|
875
|
+
const prefix = facts.deltaPct >= 0 ? '+' : '';
|
|
876
|
+
const comparison = isPartial
|
|
877
|
+
? 'Week-to-date versus previous full week strength volume'
|
|
878
|
+
: 'Week-over-week strength volume change';
|
|
879
|
+
lines.push(`${comparison}: ${prefix}${facts.deltaPct}%.`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
860
883
|
function buildVolumeAskContext(snapshot, { exclude = new Set(), today = new Date() } = {}) {
|
|
861
884
|
const lines = [];
|
|
862
885
|
const weeklyVolume = executeCoachReadTool(snapshot, 'get_weekly_volume', { today });
|
|
863
886
|
pushAskContextHeader(lines, snapshot, today);
|
|
864
887
|
|
|
865
888
|
lines.push('');
|
|
866
|
-
lines
|
|
867
|
-
lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
|
|
868
|
-
if (weeklyVolume.facts.deltaPct != null) {
|
|
869
|
-
lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
|
|
870
|
-
}
|
|
889
|
+
appendWeeklyVolumeEvidence(lines, weeklyVolume);
|
|
871
890
|
const thisWeekRows = weeklyVolume.rows.filter((row) => row.week === 'current');
|
|
872
891
|
if (thisWeekRows.length > 0) {
|
|
873
892
|
lines.push('This week sessions:');
|
|
@@ -911,10 +930,48 @@ function buildIncrementScoreAskContext(snapshot, { exclude = new Set(), today =
|
|
|
911
930
|
}
|
|
912
931
|
|
|
913
932
|
function formattedCompletedSets(sets = []) {
|
|
914
|
-
|
|
933
|
+
const groups = [];
|
|
934
|
+
for (const set of sets) {
|
|
935
|
+
const weight = Number(set.weight) || 0;
|
|
936
|
+
const label = weight > 0 ? `${formatCompactWeight(weight)}kg` : 'BW';
|
|
937
|
+
const previous = groups.at(-1);
|
|
938
|
+
if (previous?.label === label) {
|
|
939
|
+
previous.reps.push(set.reps);
|
|
940
|
+
} else {
|
|
941
|
+
groups.push({ label, reps: [set.reps] });
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return groups.map((group) => `${group.label}: ${group.reps.join('/')}`).join(', ');
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function formattedCompletedSetShorthand(sets = []) {
|
|
948
|
+
const groups = [];
|
|
949
|
+
for (const set of sets) {
|
|
915
950
|
const weight = Number(set.weight) || 0;
|
|
916
|
-
|
|
917
|
-
|
|
951
|
+
const label = weight > 0 ? formatCompactWeight(weight) : 'BW';
|
|
952
|
+
const previous = groups.at(-1);
|
|
953
|
+
if (previous?.label === label) {
|
|
954
|
+
previous.reps.push(set.reps);
|
|
955
|
+
} else {
|
|
956
|
+
groups.push({ label, reps: [set.reps] });
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return groups.map((group) => `${group.label}x${group.reps.join('/')}`).join(', ');
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function formattedSetShorthandList(sets = []) {
|
|
963
|
+
return sets
|
|
964
|
+
.map((set) => {
|
|
965
|
+
const weight = Number(set.weight) || 0;
|
|
966
|
+
const label = weight > 0 ? formatCompactWeight(weight) : 'BW';
|
|
967
|
+
return `${label}x${set.reps}`;
|
|
968
|
+
})
|
|
969
|
+
.join(', ');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function formatCompactWeight(weight) {
|
|
973
|
+
const rounded = Math.round(Number(weight) * 10) / 10;
|
|
974
|
+
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
|
918
975
|
}
|
|
919
976
|
|
|
920
977
|
function formatRepDelta(delta) {
|
|
@@ -971,6 +1028,11 @@ function formatComparableSetDelta(exercise) {
|
|
|
971
1028
|
if (currentSets.length !== previousSets.length || currentTotalReps !== previousTotalReps) {
|
|
972
1029
|
details.push(`total reps ${currentTotalReps} vs ${previousTotalReps}${currentSets.length !== previousSets.length ? ` across ${currentSets.length} vs ${previousSets.length} sets` : ''}`);
|
|
973
1030
|
}
|
|
1031
|
+
const currentSetList = formattedSetShorthandList(currentSets);
|
|
1032
|
+
const previousSetList = formattedSetShorthandList(previousSets);
|
|
1033
|
+
if (currentSetList && previousSetList) {
|
|
1034
|
+
details.push(`current sets ${currentSetList}; previous sets ${previousSetList}`);
|
|
1035
|
+
}
|
|
974
1036
|
if (regressionFlag) {
|
|
975
1037
|
details.push('regression flag: reps dropped sharply despite the load/set context');
|
|
976
1038
|
}
|
|
@@ -1079,7 +1141,9 @@ function buildExerciseProgressAskContext(snapshot, namedExercises, { exclude = n
|
|
|
1079
1141
|
for (const row of exerciseHistoryTool.rows) {
|
|
1080
1142
|
const comparison = formatTopSetComparison(row);
|
|
1081
1143
|
const warmups = row.warmupSetCount > 0 ? `; ${row.warmupSetCount} warmup set${row.warmupSetCount === 1 ? '' : 's'} excluded` : '';
|
|
1082
|
-
|
|
1144
|
+
const shorthand = formattedCompletedSetShorthand(row.sets);
|
|
1145
|
+
lines.push(` ${row.date}${formatRecencySuffix(row)} - ${row.exerciseName}: ${formattedCompletedSets(row.sets)}${shorthand ? ` (compact: ${shorthand})` : ''}${comparison ? `; ${comparison}` : ''}${warmups}`);
|
|
1146
|
+
if (row.recommendation) lines.push(` Recommendation after session: ${formatRecommendation(row.recommendation)}`);
|
|
1083
1147
|
}
|
|
1084
1148
|
appendExerciseHistoryNotes(lines, exerciseHistoryTool.rows);
|
|
1085
1149
|
}
|
|
@@ -1091,9 +1155,21 @@ function buildExerciseProgressAskContext(snapshot, namedExercises, { exclude = n
|
|
|
1091
1155
|
|
|
1092
1156
|
function formatProgressPoint(point) {
|
|
1093
1157
|
if (!point) return 'unknown';
|
|
1094
|
-
const load = Number(point.weight) > 0 ? `${point.weight}x${point.reps}` : `BWx${point.reps}`;
|
|
1095
|
-
|
|
1096
|
-
|
|
1158
|
+
const load = Number(point.weight) > 0 ? `${formatCompactWeight(point.weight)}x${point.reps}` : `BWx${point.reps}`;
|
|
1159
|
+
return `${point.date}: ${load}`;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function progressVerdict(row) {
|
|
1163
|
+
const bestDelta = Number(row?.bestDeltaFromFirst);
|
|
1164
|
+
const latestDelta = Number(row?.latestDeltaFromFirst);
|
|
1165
|
+
const latestFromBest = Number(row?.latestDeltaFromBest);
|
|
1166
|
+
if (Number.isFinite(bestDelta) && bestDelta > 0 && Number.isFinite(latestFromBest) && latestFromBest < 0) {
|
|
1167
|
+
return 'peaked early, then dipped';
|
|
1168
|
+
}
|
|
1169
|
+
if (Number.isFinite(latestDelta) && latestDelta > 0) return 'latest is up from first';
|
|
1170
|
+
if (Number.isFinite(latestDelta) && latestDelta < 0) return 'latest is down from first';
|
|
1171
|
+
if (Number.isFinite(latestDelta)) return 'latest matches first';
|
|
1172
|
+
return null;
|
|
1097
1173
|
}
|
|
1098
1174
|
|
|
1099
1175
|
function appendProgressSummaryRows(lines, rows = []) {
|
|
@@ -1111,7 +1187,8 @@ function appendProgressSummaryRows(lines, rows = []) {
|
|
|
1111
1187
|
const latestDropFromBest = row.latestDeltaFromBest == null
|
|
1112
1188
|
? ''
|
|
1113
1189
|
: `, latest vs best ${row.latestDeltaFromBest > 0 ? '+' : ''}${row.latestDeltaFromBest}`;
|
|
1114
|
-
|
|
1190
|
+
const verdict = progressVerdict(row);
|
|
1191
|
+
lines.push(` ${row.exerciseName}: first ${formatProgressPoint(row.first)}; best ${formatProgressPoint(row.best)}; latest ${formatProgressPoint(row.latest)}${bestDelta}${latestDelta}${latestDropFromBest}${verdict ? `; tracking verdict: ${verdict}` : ''}.`);
|
|
1115
1192
|
}
|
|
1116
1193
|
}
|
|
1117
1194
|
|
|
@@ -1345,9 +1422,11 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1345
1422
|
exclude = new Set(),
|
|
1346
1423
|
today = new Date(),
|
|
1347
1424
|
namedExercises = [],
|
|
1348
|
-
existingSections = []
|
|
1425
|
+
existingSections = [],
|
|
1426
|
+
omitSections = []
|
|
1349
1427
|
} = {}) {
|
|
1350
1428
|
const sections = new Set(existingSections);
|
|
1429
|
+
const omitted = new Set(omitSections);
|
|
1351
1430
|
const note = buildExcludeNote(exclude);
|
|
1352
1431
|
const noteAtEnd = note && lines.at(-1) === note;
|
|
1353
1432
|
if (noteAtEnd) {
|
|
@@ -1364,24 +1443,20 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1364
1443
|
addedSections.push(section);
|
|
1365
1444
|
};
|
|
1366
1445
|
|
|
1367
|
-
if (!sections.has('increment_score')) {
|
|
1446
|
+
if (!sections.has('increment_score') && !omitted.has('increment_score')) {
|
|
1368
1447
|
const incrementScore = executeCoachReadTool(snapshot, 'get_increment_score', { historyDays: 21 });
|
|
1369
1448
|
appendIncrementScoreEvidence(lines, incrementScore);
|
|
1370
1449
|
addTool('increment_score', incrementScore);
|
|
1371
1450
|
}
|
|
1372
1451
|
|
|
1373
|
-
if (!sections.has('weekly_volume')) {
|
|
1452
|
+
if (!sections.has('weekly_volume') && !omitted.has('weekly_volume')) {
|
|
1374
1453
|
const weeklyVolume = executeCoachReadTool(snapshot, 'get_weekly_volume', { today });
|
|
1375
1454
|
lines.push('');
|
|
1376
|
-
lines
|
|
1377
|
-
lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
|
|
1378
|
-
if (weeklyVolume.facts.deltaPct != null) {
|
|
1379
|
-
lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
|
|
1380
|
-
}
|
|
1455
|
+
appendWeeklyVolumeEvidence(lines, weeklyVolume);
|
|
1381
1456
|
addTool('weekly_volume', weeklyVolume);
|
|
1382
1457
|
}
|
|
1383
1458
|
|
|
1384
|
-
if (!sections.has('records')) {
|
|
1459
|
+
if (!sections.has('records') && !omitted.has('records')) {
|
|
1385
1460
|
const records = executeCoachReadTool(snapshot, 'get_records', {
|
|
1386
1461
|
exercises: namedExercises,
|
|
1387
1462
|
limit: namedExercises.length > 0 ? Math.max(5, namedExercises.length) : 10,
|
|
@@ -1391,7 +1466,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1391
1466
|
addTool('records', records);
|
|
1392
1467
|
}
|
|
1393
1468
|
|
|
1394
|
-
if (!sections.has('body_weight')) {
|
|
1469
|
+
if (!sections.has('body_weight') && !omitted.has('body_weight')) {
|
|
1395
1470
|
const bodyWeight = executeCoachReadTool(snapshot, 'get_body_weight_snapshot', {
|
|
1396
1471
|
recentDays: 30,
|
|
1397
1472
|
exclude: [...exclude],
|
|
@@ -1401,7 +1476,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1401
1476
|
addTool('body_weight', bodyWeight);
|
|
1402
1477
|
}
|
|
1403
1478
|
|
|
1404
|
-
if (!sections.has('readiness')) {
|
|
1479
|
+
if (!sections.has('readiness') && !omitted.has('readiness')) {
|
|
1405
1480
|
const readiness = executeCoachReadTool(snapshot, 'get_readiness_snapshot', {
|
|
1406
1481
|
recentDays: 14,
|
|
1407
1482
|
exclude: [...exclude],
|
|
@@ -1411,7 +1486,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1411
1486
|
addTool('readiness', readiness);
|
|
1412
1487
|
}
|
|
1413
1488
|
|
|
1414
|
-
if (!sections.has('goal_status')) {
|
|
1489
|
+
if (!sections.has('goal_status') && !omitted.has('goal_status')) {
|
|
1415
1490
|
const goalStatus = executeCoachReadTool(snapshot, 'get_goal_status', { limit: 5 });
|
|
1416
1491
|
appendGoalStatusEvidence(lines, goalStatus);
|
|
1417
1492
|
addTool('goal_status', goalStatus);
|
|
@@ -1468,6 +1543,7 @@ function buildRecentSessionAskContext(snapshot, { exclude = new Set(), today = n
|
|
|
1468
1543
|
const setsStr = formattedCompletedSets(exercise.sets);
|
|
1469
1544
|
const warmups = exercise.warmupSetCount > 0 ? `; ${exercise.warmupSetCount} warmup set${exercise.warmupSetCount === 1 ? '' : 's'} excluded` : '';
|
|
1470
1545
|
if (setsStr) lines.push(` ${exercise.name}: ${setsStr}${warmups}`);
|
|
1546
|
+
if (exercise.recommendation) lines.push(` Recommendation after session: ${formatRecommendation(exercise.recommendation)}`);
|
|
1471
1547
|
const setDelta = formatComparableSetDelta(exercise);
|
|
1472
1548
|
if (setDelta) lines.push(` ${setDelta}`);
|
|
1473
1549
|
}
|
|
@@ -1636,11 +1712,7 @@ function buildProgressReviewAskContext(snapshot, { exclude = new Set(), since =
|
|
|
1636
1712
|
|
|
1637
1713
|
// Weekly volume with week-over-week direction.
|
|
1638
1714
|
lines.push('');
|
|
1639
|
-
lines
|
|
1640
|
-
lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
|
|
1641
|
-
if (weeklyVolume.facts.deltaPct != null) {
|
|
1642
|
-
lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
|
|
1643
|
-
}
|
|
1715
|
+
appendWeeklyVolumeEvidence(lines, weeklyVolume);
|
|
1644
1716
|
|
|
1645
1717
|
// Per-session top sets so the model can see real progression, not just names.
|
|
1646
1718
|
const recent = recentSessions.rows.slice().reverse();
|
|
@@ -2048,7 +2120,7 @@ function followUpSuggestionsForAsk(route, intent, { question = '', missingDataFl
|
|
|
2048
2120
|
volume: ['What should I do next session?', 'Is this too much weekly volume?'],
|
|
2049
2121
|
next_session: ['What should I watch for during that session?', 'Should I adjust the first exercise?'],
|
|
2050
2122
|
recovery: ['Should I train tomorrow?', 'What would be a conservative version?'],
|
|
2051
|
-
recent_session: ['
|
|
2123
|
+
recent_session: ['What should I take from that session?', 'What should I aim for next time?', 'What should I change next time?'],
|
|
2052
2124
|
body_weight: bodyWeightFollowUpCandidates(missingDataFlags),
|
|
2053
2125
|
score: ['What is pulling my score down?', 'What should I focus on this week?'],
|
|
2054
2126
|
program_progress: ['Pull this block summary.', 'Break down a specific lift.', 'What is the next decision?'],
|
|
@@ -2229,6 +2301,7 @@ function appendAskAnswerContract(lines, {
|
|
|
2229
2301
|
namedExerciseLabels = [],
|
|
2230
2302
|
builtTools = [],
|
|
2231
2303
|
sessionObservationComparisons = [],
|
|
2304
|
+
includedFacts = [],
|
|
2232
2305
|
question = ''
|
|
2233
2306
|
} = {}) {
|
|
2234
2307
|
const note = buildExcludeNote(new Set());
|
|
@@ -2241,6 +2314,15 @@ function appendAskAnswerContract(lines, {
|
|
|
2241
2314
|
const contract = [];
|
|
2242
2315
|
const fullExerciseNames = namedExerciseLabels.filter(Boolean);
|
|
2243
2316
|
const text = String(question ?? '').toLowerCase();
|
|
2317
|
+
const toneFacts = includedFacts
|
|
2318
|
+
.filter((fact) => fact.kind === 'tone')
|
|
2319
|
+
.map((fact) => fact.fact)
|
|
2320
|
+
.filter(Boolean);
|
|
2321
|
+
|
|
2322
|
+
if (fullExerciseNames.length > 0) {
|
|
2323
|
+
contract.push('Answer contract: named exercise identity.');
|
|
2324
|
+
contract.push(` Use the exact exercise name(s): ${fullExerciseNames.join(', ')}.`);
|
|
2325
|
+
}
|
|
2244
2326
|
|
|
2245
2327
|
if (responseProfile === ASK_RESPONSE_PROFILES.defensive) {
|
|
2246
2328
|
contract.push('Answer contract: defensive decision.');
|
|
@@ -2248,12 +2330,17 @@ function appendAskAnswerContract(lines, {
|
|
|
2248
2330
|
contract.push(' Name the relevant exercise exactly as written in the evidence.');
|
|
2249
2331
|
contract.push(' Use compact set notation from the evidence when citing sets, e.g. 70x5 or 67.5x7.');
|
|
2250
2332
|
contract.push(' Do not mention record estimates or PRs unless the user explicitly asked about them.');
|
|
2251
|
-
contract.push(' If the latest relevant session is older than 14 days, do not use the word "recent"; say "latest logged" or give the days-ago label.');
|
|
2333
|
+
contract.push(' If the latest relevant session is older than 14 days, do not use the word "recent" anywhere; say "latest logged", "stale", or give the days-ago label.');
|
|
2252
2334
|
if (fullExerciseNames.length > 0) {
|
|
2253
2335
|
contract.push(` Relevant exercise name(s) to preserve: ${fullExerciseNames.join(', ')}.`);
|
|
2254
2336
|
}
|
|
2255
2337
|
}
|
|
2256
2338
|
|
|
2339
|
+
if (toneFacts.some((fact) => /\bconcise\b/i.test(fact))) {
|
|
2340
|
+
contract.push('Answer contract: typed tone fact.');
|
|
2341
|
+
contract.push(' Mention the user preference for concise coaching cues using the word "concise".');
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2257
2344
|
if (route === 'progress_review') {
|
|
2258
2345
|
const weeklyVolume = builtTools.find((tool) => tool.toolName === 'get_weekly_volume');
|
|
2259
2346
|
const recentSessions = builtTools.find((tool) => tool.toolName === 'get_recent_sessions');
|
|
@@ -2263,6 +2350,7 @@ function appendAskAnswerContract(lines, {
|
|
|
2263
2350
|
const previousSessions = weeklyVolume?.facts?.previousWeekSessionCount;
|
|
2264
2351
|
const strengthSessionCount = recentSessions?.rows?.length;
|
|
2265
2352
|
const volumeDeltaPct = weeklyVolume?.facts?.deltaPct;
|
|
2353
|
+
const currentWeekIsPartial = weeklyVolume?.facts?.currentWeekIsPartial === true;
|
|
2266
2354
|
const recentRecordCount = records?.facts?.recentRecordCount;
|
|
2267
2355
|
const readinessFacts = readiness?.facts ?? {};
|
|
2268
2356
|
const readinessPhrases = [
|
|
@@ -2276,10 +2364,14 @@ function appendAskAnswerContract(lines, {
|
|
|
2276
2364
|
if (Number.isFinite(Number(strengthSessionCount)) && Number(strengthSessionCount) > 0) {
|
|
2277
2365
|
contract.push(` Include this exact strength-session phrase: "${strengthSessionCount} sessions".`);
|
|
2278
2366
|
}
|
|
2279
|
-
if (
|
|
2367
|
+
if (
|
|
2368
|
+
Number.isFinite(Number(currentSessions))
|
|
2369
|
+
&& Number.isFinite(Number(previousSessions))
|
|
2370
|
+
&& Number(currentSessions) === Number(previousSessions)
|
|
2371
|
+
) {
|
|
2280
2372
|
contract.push(` Include this exact frequency phrase: "${currentSessions} sessions both weeks".`);
|
|
2281
2373
|
}
|
|
2282
|
-
if (Number.isFinite(Number(volumeDeltaPct))) {
|
|
2374
|
+
if (Number.isFinite(Number(volumeDeltaPct)) && !currentWeekIsPartial) {
|
|
2283
2375
|
const direction = Number(volumeDeltaPct) < 0 ? 'drop' : 'increase';
|
|
2284
2376
|
contract.push(` Include this exact weekly volume phrase: "${Math.abs(Number(volumeDeltaPct))}% ${direction}".`);
|
|
2285
2377
|
}
|
|
@@ -2306,7 +2398,7 @@ function appendAskAnswerContract(lines, {
|
|
|
2306
2398
|
contract.push('Answer contract: verify the alleged drop-off against logged sets.');
|
|
2307
2399
|
contract.push(' Lead by accepting or rejecting the premise from logged working sets.');
|
|
2308
2400
|
contract.push(' If current working top load is higher than the prior comparable session, say it increased and do not describe the lift as declining.');
|
|
2309
|
-
contract.push(' When rejecting the premise because top load increased, avoid the words "drop-off", "dropping off", "decline", "declining", "regress", or "regressing" in the answer.');
|
|
2401
|
+
contract.push(' When rejecting the premise because top load increased, avoid the words "drop-off", "dropping off", "decline", "declining", "falling", "regress", or "regressing" in the answer.');
|
|
2310
2402
|
contract.push(' Mention warmups separately when the evidence marks warmup sets excluded.');
|
|
2311
2403
|
contract.push(' Do not mention record estimates unless the user asked for them.');
|
|
2312
2404
|
}
|
|
@@ -2388,6 +2480,44 @@ function compactExerciseRows(items, key = 'exercise') {
|
|
|
2388
2480
|
.map((name) => compactEvidenceRow(name, 'relevant lift history available'));
|
|
2389
2481
|
}
|
|
2390
2482
|
|
|
2483
|
+
function muscleVolumeTrendEvidenceRows(evidence) {
|
|
2484
|
+
const rows = Array.isArray(evidence?.muscles)
|
|
2485
|
+
? evidence.muscles
|
|
2486
|
+
: (evidence?.muscle ? [{ muscle: evidence.muscle }] : []);
|
|
2487
|
+
const isPartial = evidence?.currentWeek?.isPartial === true || evidence?.currentWeekIsPartial === true;
|
|
2488
|
+
return rows
|
|
2489
|
+
.slice(0, 3)
|
|
2490
|
+
.map((row) => {
|
|
2491
|
+
const muscle = String(row?.muscle ?? '').trim();
|
|
2492
|
+
if (!muscle) return null;
|
|
2493
|
+
return compactEvidenceRow(muscle, muscleVolumeTrendEvidenceValue(row, { isPartial }));
|
|
2494
|
+
})
|
|
2495
|
+
.filter(Boolean);
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
function muscleVolumeTrendEvidenceValue(row, { isPartial = false } = {}) {
|
|
2499
|
+
const pieces = [];
|
|
2500
|
+
const share = wholePercent(row?.latestSharePct);
|
|
2501
|
+
if (share) pieces.push(`${share} of ${isPartial ? 'this week-to-date volume' : "this week's volume"}`);
|
|
2502
|
+
const delta = directionalPercent(row?.deltaVsPriorAvgPct);
|
|
2503
|
+
if (delta) pieces.push(delta);
|
|
2504
|
+
return pieces.length > 0 ? pieces.join(' · ') : 'muscle breakdown available';
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
function wholePercent(value) {
|
|
2508
|
+
const number = Number(value);
|
|
2509
|
+
if (!Number.isFinite(number)) return null;
|
|
2510
|
+
return `${Math.round(number)}%`;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function directionalPercent(value) {
|
|
2514
|
+
const number = Number(value);
|
|
2515
|
+
if (!Number.isFinite(number)) return null;
|
|
2516
|
+
const rounded = Math.round(number);
|
|
2517
|
+
if (rounded === 0) return 'flat versus recent weeks';
|
|
2518
|
+
return `${rounded > 0 ? 'up' : 'down'} ${Math.abs(rounded)}%`;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2391
2521
|
function humanObservationEvidenceRows(observation) {
|
|
2392
2522
|
const evidence = observation?.evidence;
|
|
2393
2523
|
if (!evidence || typeof evidence !== 'object') return [];
|
|
@@ -2399,6 +2529,8 @@ function humanObservationEvidenceRows(observation) {
|
|
|
2399
2529
|
rows.push(compactEvidenceRow('Weekly score', `${Math.round(Number(evidence.latestScore))} from ${Math.round(Number(evidence.previousScore))}`));
|
|
2400
2530
|
}
|
|
2401
2531
|
rows.push(compactEvidenceRow('Change', signedNumber(evidence.delta, { suffix: ' points' })));
|
|
2532
|
+
} else if (kind === 'muscle_volume_trend') {
|
|
2533
|
+
rows.push(...muscleVolumeTrendEvidenceRows(evidence));
|
|
2402
2534
|
} else if (kind === 'training_balance_skew') {
|
|
2403
2535
|
rows.push(compactEvidenceRow('Push work', Number.isFinite(Number(evidence.pushSets)) ? `${Math.round(Number(evidence.pushSets))} sets` : null));
|
|
2404
2536
|
rows.push(compactEvidenceRow('Pull work', Number.isFinite(Number(evidence.pullSets)) ? `${Math.round(Number(evidence.pullSets))} sets` : null));
|
|
@@ -2971,7 +3103,8 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
|
|
|
2971
3103
|
exclude,
|
|
2972
3104
|
today,
|
|
2973
3105
|
namedExercises: namedExerciseItems,
|
|
2974
|
-
existingSections: built.sections
|
|
3106
|
+
existingSections: built.sections,
|
|
3107
|
+
omitSections: ['recent_session', 'exercise_progress', 'exercise_progress_summary', 'next_session'].includes(route) ? ['records'] : []
|
|
2975
3108
|
})
|
|
2976
3109
|
: { sections: [], tools: [], provenance: [] };
|
|
2977
3110
|
built = {
|
|
@@ -3020,6 +3153,7 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
|
|
|
3020
3153
|
namedExerciseLabels,
|
|
3021
3154
|
builtTools: tools,
|
|
3022
3155
|
sessionObservationComparisons,
|
|
3156
|
+
includedFacts,
|
|
3023
3157
|
question
|
|
3024
3158
|
});
|
|
3025
3159
|
const currentSessionIds = uniqueArray(sessionObservationComparisons.map((row) => row.sessionId));
|
package/src/openrouter.js
CHANGED
|
@@ -29,8 +29,8 @@ export const AI_PROMPT_VERSIONS = Object.freeze({
|
|
|
29
29
|
cycle: 'cycle_v2026_04_18_1',
|
|
30
30
|
vitals: 'vitals_v2026_04_16_1',
|
|
31
31
|
checkpoint: 'checkpoint_v2026_04_16_1',
|
|
32
|
-
ask: '
|
|
33
|
-
askAgentic: '
|
|
32
|
+
ask: 'ask_v2026_06_13_1',
|
|
33
|
+
askAgentic: 'ask_agentic_v2026_06_13_1',
|
|
34
34
|
weeklyCheckin: 'weekly_checkin_v2026_04_23_1',
|
|
35
35
|
coachCommitments: 'coach_commitments_v2026_04_25_1',
|
|
36
36
|
coachFacts: 'coach_facts_v2026_04_25_1'
|
|
@@ -1457,7 +1457,7 @@ const ASK_CORE_RULES = `Core rules:
|
|
|
1457
1457
|
const ASK_EXPANSIVE_RULES = `Default Ask Coach style:
|
|
1458
1458
|
- Give the rich version by default: warm, detailed, specific, and data-dense, even for vague questions like "how am I doing?" or "tell me nice things".
|
|
1459
1459
|
- Volunteer useful score evidence when provided: rounded Increment Score headline, direction (up/down/flat — not the point-delta number), and positive/negative drivers. Never recite score sub-scores, decimals, daily score lists, or a day-over-day delta number.
|
|
1460
|
-
- Volunteer
|
|
1460
|
+
- Volunteer records, PRs, and e1RMs only when the user asks about records, PRs, maxes, strongest lifts, or strength milestones. For session reviews, missed targets, and next-session load decisions, prioritize plan targets, logged reps, and persisted recommendations; do not mention e1RM unless the user explicitly asks for it. Call a record value an estimated 1RM (e1RM), never a lifted set load.
|
|
1461
1461
|
- For broad reads, synthesize sessions, volume, score drivers, records, body weight, readiness, goals, standouts, regressions, and caveats. Do not punt to a follow-up when the evidence is already present.
|
|
1462
1462
|
- For session recaps, name the best real parts and the meaningful regression or watch item if one exists. Extra detail is good when it helps the user understand the workout.
|
|
1463
1463
|
- Be concise only if the user asks for a quick answer or selected a concise tone.`;
|
package/src/prompt-changelog.js
CHANGED
|
@@ -22,6 +22,24 @@ export const PROMPT_CHANGELOG_TYPES = Object.freeze([
|
|
|
22
22
|
]);
|
|
23
23
|
|
|
24
24
|
export const PROMPT_CHANGELOG = Object.freeze([
|
|
25
|
+
{
|
|
26
|
+
version: 'ask_agentic_v2026_06_13_1',
|
|
27
|
+
surface: 'askAgentic',
|
|
28
|
+
date: '2026-06-13',
|
|
29
|
+
type: 'safety',
|
|
30
|
+
summary:
|
|
31
|
+
'Gate estimated 1RM/PR/record language to explicit max, PR, record, strongest-lift, or strength-milestone questions. Session reviews, missed-target answers, and next-session load decisions should prioritize plan targets, logged reps, and persisted recommendations instead of volunteering e1RM.',
|
|
32
|
+
eval: 'ask_target_miss_incline_bench'
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
version: 'ask_v2026_06_13_1',
|
|
36
|
+
surface: 'ask',
|
|
37
|
+
date: '2026-06-13',
|
|
38
|
+
type: 'safety',
|
|
39
|
+
summary:
|
|
40
|
+
'Gate estimated 1RM/PR/record language to explicit max, PR, record, strongest-lift, or strength-milestone questions. Session reviews, missed-target answers, and next-session load decisions should prioritize plan targets, logged reps, and persisted recommendations instead of volunteering e1RM.',
|
|
41
|
+
eval: 'ask_target_miss_incline_bench'
|
|
42
|
+
},
|
|
25
43
|
{
|
|
26
44
|
version: 'ask_agentic_v2026_06_02_1',
|
|
27
45
|
surface: 'askAgentic',
|
package/src/queries.js
CHANGED
|
@@ -2717,6 +2717,7 @@ function coachToolResult(toolName, params, {
|
|
|
2717
2717
|
export function getWeeklyVolume(snapshot, { today = new Date() } = {}) {
|
|
2718
2718
|
const todayIso = dateOnlyString(today);
|
|
2719
2719
|
const weekStart = startOfCurrentIsoWeek(today);
|
|
2720
|
+
const currentWeekEnd = isoDateOffset(weekStart, 6);
|
|
2720
2721
|
const previousWeekEnd = new Date(new Date(`${weekStart}T00:00:00.000Z`).getTime() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
2721
2722
|
const previousWeekStart = new Date(new Date(`${weekStart}T00:00:00.000Z`).getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
2722
2723
|
const thisWeek = sessionsInDateRange(snapshot, weekStart, todayIso);
|
|
@@ -2755,6 +2756,9 @@ export function getWeeklyVolume(snapshot, { today = new Date() } = {}) {
|
|
|
2755
2756
|
currentWeekSessionCount: thisWeek.length,
|
|
2756
2757
|
previousWeekVolume: Math.round(previousWeekVolume),
|
|
2757
2758
|
previousWeekSessionCount: previousWeek.length,
|
|
2759
|
+
currentWeekEnd,
|
|
2760
|
+
currentWeekIsPartial: todayIso < currentWeekEnd,
|
|
2761
|
+
currentWeekObservedThrough: todayIso,
|
|
2758
2762
|
deltaPct: previousWeekVolume > 0 ? Math.round(((thisWeekVolume - previousWeekVolume) / previousWeekVolume) * 100) : null
|
|
2759
2763
|
},
|
|
2760
2764
|
sourceIds: rows.map((row) => row.sessionId),
|
|
@@ -2798,6 +2802,7 @@ function isoDateOffset(isoDate, days) {
|
|
|
2798
2802
|
export function getMuscleVolumeTrend(snapshot, { today = new Date(), weeks = 4 } = {}) {
|
|
2799
2803
|
const todayIso = dateOnlyString(today);
|
|
2800
2804
|
const currentWeekStart = startOfCurrentIsoWeek(today);
|
|
2805
|
+
const currentWeekEnd = isoDateOffset(currentWeekStart, 6);
|
|
2801
2806
|
const boundedWeeks = Math.max(1, Math.min(12, Math.round(Number(weeks) || 4)));
|
|
2802
2807
|
|
|
2803
2808
|
// Oldest -> newest so downstream arrays read chronologically.
|
|
@@ -2875,6 +2880,12 @@ export function getMuscleVolumeTrend(snapshot, { today = new Date(), weeks = 4 }
|
|
|
2875
2880
|
rows,
|
|
2876
2881
|
facts: {
|
|
2877
2882
|
weekStarts,
|
|
2883
|
+
currentWeek: {
|
|
2884
|
+
start: currentWeekStart,
|
|
2885
|
+
end: currentWeekEnd,
|
|
2886
|
+
observedThrough: todayIso,
|
|
2887
|
+
isPartial: todayIso < currentWeekEnd
|
|
2888
|
+
},
|
|
2878
2889
|
weeklyTotals: weeklyTotals.map((value) => Math.round(value)),
|
|
2879
2890
|
muscleCount: muscles.length,
|
|
2880
2891
|
muscles
|
|
@@ -2904,6 +2915,7 @@ export function getRecentSessions(snapshot, { limit = 3, today = new Date(), rec
|
|
|
2904
2915
|
warmupSetCount: warmupSetCount(exercise.sets ?? []),
|
|
2905
2916
|
workingSetCount: sets.length,
|
|
2906
2917
|
topSet: topCompletedSet(sets),
|
|
2918
|
+
recommendation: recommendationForExercise(session.recommendations, exercise.name),
|
|
2907
2919
|
previousComparableSession: previousComparableExerciseSession(sortedSessions, session, exercise),
|
|
2908
2920
|
sets
|
|
2909
2921
|
};
|
|
@@ -3003,6 +3015,7 @@ export function getExerciseHistory(snapshot, { exercises = [], limit = 6, today
|
|
|
3003
3015
|
warmupSetCount: warmupSetCount(exercise.sets ?? []),
|
|
3004
3016
|
workingSetCount: completedSets.length,
|
|
3005
3017
|
topSet: topCompletedSet(completedSets),
|
|
3018
|
+
recommendation: recommendationForExercise(session.recommendations, exercise.name),
|
|
3006
3019
|
sets: completedSets
|
|
3007
3020
|
});
|
|
3008
3021
|
if (historyRows.length >= limit) break;
|
|
@@ -3806,7 +3819,12 @@ export function getAthleteSnapshot(snapshot, { today = new Date(), windowDays =
|
|
|
3806
3819
|
|
|
3807
3820
|
if (!excluded.has('score')) {
|
|
3808
3821
|
const score = getIncrementScore(snapshot, { historyDays: Math.min(boundedWindowDays, 60) });
|
|
3809
|
-
|
|
3822
|
+
// Gate on the score's own availability flag, not on whether facts is
|
|
3823
|
+
// non-empty. A real snapshot whose latest entry has a non-numeric score
|
|
3824
|
+
// comes back available:false with facts:{}, and an available snapshot
|
|
3825
|
+
// always carries available:true. Keying off Object.keys(facts) conflated
|
|
3826
|
+
// these two and could null out a legitimately scored athlete.
|
|
3827
|
+
facts.score = score.facts?.available === true ? {
|
|
3810
3828
|
value: score.facts.score ?? null,
|
|
3811
3829
|
band: score.facts.scoreBand ?? null,
|
|
3812
3830
|
dayOverDayDelta: score.facts.dayOverDayDelta ?? null,
|
|
@@ -3825,6 +3843,7 @@ export function getAthleteSnapshot(snapshot, { today = new Date(), windowDays =
|
|
|
3825
3843
|
currentWeekSessions: volume.facts.currentWeekSessionCount ?? 0,
|
|
3826
3844
|
previousWeek: volume.facts.previousWeekVolume ?? 0,
|
|
3827
3845
|
previousWeekSessions: volume.facts.previousWeekSessionCount ?? 0,
|
|
3846
|
+
currentWeekIsPartial: volume.facts.currentWeekIsPartial ?? false,
|
|
3828
3847
|
deltaPct: volume.facts.deltaPct ?? null
|
|
3829
3848
|
};
|
|
3830
3849
|
sourceIds.push(...volume.sourceIds);
|
|
@@ -3835,8 +3854,14 @@ export function getAthleteSnapshot(snapshot, { today = new Date(), windowDays =
|
|
|
3835
3854
|
if (!excluded.has('muscleVolume')) {
|
|
3836
3855
|
const trendWeeks = Math.max(2, Math.min(5, Math.round(boundedWindowDays / 7)));
|
|
3837
3856
|
const muscleTrend = getMuscleVolumeTrend(snapshot, { today: asOf, weeks: trendWeeks });
|
|
3857
|
+
const currentWeek = muscleTrend.facts.currentWeek;
|
|
3838
3858
|
facts.muscleVolume = {
|
|
3839
3859
|
weekStarts: muscleTrend.facts.weekStarts,
|
|
3860
|
+
currentWeek: currentWeek
|
|
3861
|
+
? {
|
|
3862
|
+
isPartial: currentWeek.isPartial === true
|
|
3863
|
+
}
|
|
3864
|
+
: null,
|
|
3840
3865
|
weeklyTotals: muscleTrend.facts.weeklyTotals,
|
|
3841
3866
|
muscles: (muscleTrend.facts.muscles ?? []).slice(0, 6).map((row) => ({
|
|
3842
3867
|
muscle: row.muscle,
|
|
@@ -3850,6 +3875,23 @@ export function getAthleteSnapshot(snapshot, { today = new Date(), windowDays =
|
|
|
3850
3875
|
missingDataFlags.push(...muscleTrend.missingDataFlags);
|
|
3851
3876
|
}
|
|
3852
3877
|
|
|
3878
|
+
if (!excluded.has('bodyweight')) {
|
|
3879
|
+
const bw = getBodyWeightSnapshot(snapshot, { recentDays: Math.max(boundedWindowDays, 30), today: asOf });
|
|
3880
|
+
if (bw.facts?.latestBodyWeightKg != null) {
|
|
3881
|
+
facts.bodyweight = {
|
|
3882
|
+
latestKg: bw.facts.latestBodyWeightKg ?? null,
|
|
3883
|
+
latestDate: bw.facts.latestBodyWeightDate ?? null,
|
|
3884
|
+
trendKg: bw.facts.trendKg ?? null,
|
|
3885
|
+
trendDirection: bw.facts.trendDirection ?? null,
|
|
3886
|
+
avg7DayKg: bw.facts.average7DayBodyWeightKg ?? null,
|
|
3887
|
+
avg30DayKg: bw.facts.average30DayBodyWeightKg ?? null
|
|
3888
|
+
};
|
|
3889
|
+
}
|
|
3890
|
+
sourceIds.push(...bw.sourceIds);
|
|
3891
|
+
sourceTimestamps.push(bw.sourceTimestamp);
|
|
3892
|
+
missingDataFlags.push(...bw.missingDataFlags);
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3853
3895
|
if (!excluded.has('recovery')) {
|
|
3854
3896
|
const readiness = getReadinessSnapshot(snapshot, {
|
|
3855
3897
|
recentDays: Math.min(boundedWindowDays, 60),
|
|
@@ -4533,7 +4575,7 @@ export const COACH_READ_TOOL_SCHEMAS = Object.freeze({
|
|
|
4533
4575
|
windowDays: { type: 'integer', minimum: 1, maximum: 365, default: 35 },
|
|
4534
4576
|
exclude: {
|
|
4535
4577
|
type: 'array',
|
|
4536
|
-
items: { type: 'string', enum: ['recovery', 'records', 'notes', 'score', 'volume', 'muscleVolume'] },
|
|
4578
|
+
items: { type: 'string', enum: ['recovery', 'records', 'notes', 'score', 'volume', 'muscleVolume', 'bodyweight'] },
|
|
4537
4579
|
default: []
|
|
4538
4580
|
}
|
|
4539
4581
|
},
|
package/src/sync-service.js
CHANGED
|
@@ -28,6 +28,7 @@ import { extractPlanChangeset } from './plan-changeset.js';
|
|
|
28
28
|
|
|
29
29
|
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
30
30
|
const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
31
|
+
const COACH_OBSERVATION_DAILY_REFRESH_COOLDOWN_MS = 20 * 60 * 60 * 1000;
|
|
31
32
|
const WEEKLY_CHECKIN_COMPLETION_USER_TURNS = 3;
|
|
32
33
|
const WEEKLY_CHECKIN_RECAP_LOCAL_HOUR = 16;
|
|
33
34
|
const DEFAULT_RATE_LIMIT_RULES = {
|
|
@@ -70,6 +71,8 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
70
71
|
'program-share-list': 60,
|
|
71
72
|
'program-share-public': 120,
|
|
72
73
|
'program-share-revoke': 30,
|
|
74
|
+
'workout-share-create': 30,
|
|
75
|
+
'workout-share-public': 120,
|
|
73
76
|
'mobile-sync-bootstrap': 60,
|
|
74
77
|
'mobile-sync-pull': 120,
|
|
75
78
|
'mobile-sync-push': 60,
|
|
@@ -947,6 +950,38 @@ function coachObservationGenerationMetadata(result, sourceTrigger) {
|
|
|
947
950
|
};
|
|
948
951
|
}
|
|
949
952
|
|
|
953
|
+
function skippedCoachObservationGenerationMetadata(skippedReason, sourceTrigger) {
|
|
954
|
+
return {
|
|
955
|
+
attempted: false,
|
|
956
|
+
generated: 0,
|
|
957
|
+
persisted: 0,
|
|
958
|
+
skippedReason,
|
|
959
|
+
sourceTrigger: sourceTrigger ?? null
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function recentDailyRefreshObservation(observations, now = new Date()) {
|
|
964
|
+
const threshold = now.getTime() - COACH_OBSERVATION_DAILY_REFRESH_COOLDOWN_MS;
|
|
965
|
+
return (Array.isArray(observations) ? observations : []).some((observation) => {
|
|
966
|
+
if (observation?.sourceTrigger !== 'daily_refresh') return false;
|
|
967
|
+
const generatedAt = new Date(observation.generatedAt ?? observation.windowEnd ?? 0).getTime();
|
|
968
|
+
return Number.isFinite(generatedAt) && generatedAt >= threshold;
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function coachObservationGenerationSkipReason({
|
|
973
|
+
account,
|
|
974
|
+
uploadResult,
|
|
975
|
+
sourceTrigger,
|
|
976
|
+
listCurrentCoachObservationsForAccount
|
|
977
|
+
}) {
|
|
978
|
+
if (sourceTrigger !== 'daily_refresh') return null;
|
|
979
|
+
if (Number(uploadResult?.inserted ?? 0) <= 0) return 'no_score_snapshot_change';
|
|
980
|
+
if (typeof listCurrentCoachObservationsForAccount !== 'function') return null;
|
|
981
|
+
const observations = await listCurrentCoachObservationsForAccount(account, { limit: 20 });
|
|
982
|
+
return recentDailyRefreshObservation(observations) ? 'recent_daily_refresh' : null;
|
|
983
|
+
}
|
|
984
|
+
|
|
950
985
|
function normalizeAskCoachObservationFollowUp(value) {
|
|
951
986
|
if (!value || typeof value !== 'object') return null;
|
|
952
987
|
const id = String(value.id ?? '').trim();
|
|
@@ -1177,6 +1212,20 @@ function routeRequest(url, method) {
|
|
|
1177
1212
|
}
|
|
1178
1213
|
}
|
|
1179
1214
|
|
|
1215
|
+
if (pathname === '/cli/workout-share') {
|
|
1216
|
+
return { command: 'workout-share-create', options: {} };
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
{
|
|
1220
|
+
const workoutSharePublicMatch = pathname.match(/^\/workout-share\/([^/]+)$/);
|
|
1221
|
+
if (workoutSharePublicMatch) {
|
|
1222
|
+
return {
|
|
1223
|
+
command: 'workout-share-public',
|
|
1224
|
+
options: { token: decodeURIComponent(workoutSharePublicMatch[1]) }
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1180
1229
|
{
|
|
1181
1230
|
const programShareRevokeMatch = pathname.match(/^\/cli\/program-share\/([^/]+)\/revoke$/);
|
|
1182
1231
|
if (programShareRevokeMatch) {
|
|
@@ -2316,6 +2365,8 @@ export function createSyncServiceRequestHandler({
|
|
|
2316
2365
|
listProgramSharesForAccount = null,
|
|
2317
2366
|
readPublicProgramShare = null,
|
|
2318
2367
|
revokeProgramShareForAccount = null,
|
|
2368
|
+
createWorkoutShareForAccount = null,
|
|
2369
|
+
readPublicWorkoutShare = null,
|
|
2319
2370
|
updateAnalysisConsentForAccount = null,
|
|
2320
2371
|
updateDisplayNameForAccount = null,
|
|
2321
2372
|
saveAskConversationForAccount = null,
|
|
@@ -3004,6 +3055,45 @@ export function createSyncServiceRequestHandler({
|
|
|
3004
3055
|
}
|
|
3005
3056
|
}
|
|
3006
3057
|
|
|
3058
|
+
if (route.command === 'workout-share-public') {
|
|
3059
|
+
if (request.method !== 'GET') {
|
|
3060
|
+
methodNotAllowed(response, 'Use GET for /workout-share/:token.');
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
3063
|
+
if (!readPublicWorkoutShare) {
|
|
3064
|
+
methodNotAllowed(response, 'Workout sharing is not enabled for this service mode.');
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
try {
|
|
3068
|
+
const shared = await readPublicWorkoutShare(route.options.token);
|
|
3069
|
+
if (shared.status === 'not_found') {
|
|
3070
|
+
notFound(response, 'Workout share not found.');
|
|
3071
|
+
return;
|
|
3072
|
+
}
|
|
3073
|
+
if (shared.status === 'revoked' || shared.status === 'expired') {
|
|
3074
|
+
json(response, 410, { error: 'Workout share is no longer available.' });
|
|
3075
|
+
return;
|
|
3076
|
+
}
|
|
3077
|
+
json(response, 200, {
|
|
3078
|
+
ok: true,
|
|
3079
|
+
token: route.options.token,
|
|
3080
|
+
version: shared.share.version,
|
|
3081
|
+
workoutName: shared.share.workoutName,
|
|
3082
|
+
workoutPayload: shared.share.workoutPayload,
|
|
3083
|
+
createdAt: shared.share.createdAt,
|
|
3084
|
+
expiresAt: shared.share.expiresAt
|
|
3085
|
+
});
|
|
3086
|
+
return;
|
|
3087
|
+
} catch (error) {
|
|
3088
|
+
if (error?.message === 'Invalid workout share token.') {
|
|
3089
|
+
badRequest(response, error.message);
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
internalError(response, error, onError);
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3007
3097
|
if (route.command === 'google-mobile') {
|
|
3008
3098
|
if (request.method !== 'POST') {
|
|
3009
3099
|
methodNotAllowed(response, 'Use POST for /auth/google/mobile.');
|
|
@@ -3322,18 +3412,33 @@ export function createSyncServiceRequestHandler({
|
|
|
3322
3412
|
const result = await insertScoreSnapshotsForAccount(account, body.snapshots);
|
|
3323
3413
|
if (generateCoachObservationsForAccount) {
|
|
3324
3414
|
const sourceTrigger = coachObservationSourceTriggerForScoreSnapshots(body.snapshots);
|
|
3415
|
+
let skipReason = null;
|
|
3325
3416
|
try {
|
|
3326
|
-
|
|
3327
|
-
|
|
3417
|
+
skipReason = await coachObservationGenerationSkipReason({
|
|
3418
|
+
account,
|
|
3419
|
+
uploadResult: result,
|
|
3420
|
+
sourceTrigger,
|
|
3421
|
+
listCurrentCoachObservationsForAccount
|
|
3422
|
+
});
|
|
3328
3423
|
} catch (error) {
|
|
3329
|
-
console.error('Coach observation generation
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
sourceTrigger
|
|
3336
|
-
|
|
3424
|
+
console.error('Coach observation generation skip check failed:', error.message);
|
|
3425
|
+
}
|
|
3426
|
+
if (skipReason) {
|
|
3427
|
+
result.coachObservations = skippedCoachObservationGenerationMetadata(skipReason, sourceTrigger);
|
|
3428
|
+
} else {
|
|
3429
|
+
try {
|
|
3430
|
+
const generationResult = await generateCoachObservationsForAccount(account, { sourceTrigger });
|
|
3431
|
+
result.coachObservations = coachObservationGenerationMetadata(generationResult, sourceTrigger);
|
|
3432
|
+
} catch (error) {
|
|
3433
|
+
console.error('Coach observation generation after score upload failed:', error.message);
|
|
3434
|
+
result.coachObservations = {
|
|
3435
|
+
attempted: true,
|
|
3436
|
+
generated: 0,
|
|
3437
|
+
persisted: 0,
|
|
3438
|
+
skippedReason: 'generation_failed',
|
|
3439
|
+
sourceTrigger
|
|
3440
|
+
};
|
|
3441
|
+
}
|
|
3337
3442
|
}
|
|
3338
3443
|
}
|
|
3339
3444
|
json(response, 200, result);
|
|
@@ -3604,6 +3709,53 @@ export function createSyncServiceRequestHandler({
|
|
|
3604
3709
|
}
|
|
3605
3710
|
}
|
|
3606
3711
|
|
|
3712
|
+
if (route.command === 'workout-share-create') {
|
|
3713
|
+
if (request.method !== 'POST') {
|
|
3714
|
+
methodNotAllowed(response, 'Use POST for /cli/workout-share.');
|
|
3715
|
+
return;
|
|
3716
|
+
}
|
|
3717
|
+
if (!createWorkoutShareForAccount) {
|
|
3718
|
+
methodNotAllowed(response, 'Workout sharing is not enabled for this service mode.');
|
|
3719
|
+
return;
|
|
3720
|
+
}
|
|
3721
|
+
const account = connectedWriteAuthenticator
|
|
3722
|
+
? await connectedWriteAuthenticator(requestToken)
|
|
3723
|
+
: null;
|
|
3724
|
+
if (!account) {
|
|
3725
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
3726
|
+
return;
|
|
3727
|
+
}
|
|
3728
|
+
unauthorized(response, request);
|
|
3729
|
+
return;
|
|
3730
|
+
}
|
|
3731
|
+
try {
|
|
3732
|
+
const body = await readJsonBody(request);
|
|
3733
|
+
const workoutPayload = body.workoutPayload ?? body;
|
|
3734
|
+
const share = await createWorkoutShareForAccount(account, workoutPayload);
|
|
3735
|
+
json(response, 201, {
|
|
3736
|
+
ok: true,
|
|
3737
|
+
shareId: share.id,
|
|
3738
|
+
tokenHint: share.tokenHint,
|
|
3739
|
+
token: share.token,
|
|
3740
|
+
workoutName: share.workoutName,
|
|
3741
|
+
createdAt: share.createdAt,
|
|
3742
|
+
expiresAt: share.expiresAt,
|
|
3743
|
+
revokedAt: share.revokedAt,
|
|
3744
|
+
version: share.version,
|
|
3745
|
+
link: `${resolvedPublicOrigin}/workout-share/${share.token}`,
|
|
3746
|
+
deepLink: `incremnt://workout-share/${share.token}?base=${encodeURIComponent(resolvedPublicOrigin)}`
|
|
3747
|
+
});
|
|
3748
|
+
return;
|
|
3749
|
+
} catch (error) {
|
|
3750
|
+
if (error?.message === 'Workout share payload is malformed.') {
|
|
3751
|
+
badRequest(response, error.message);
|
|
3752
|
+
return;
|
|
3753
|
+
}
|
|
3754
|
+
internalError(response, error, onError);
|
|
3755
|
+
return;
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3607
3759
|
if (route.command === 'program-share-list') {
|
|
3608
3760
|
if (request.method !== 'GET') {
|
|
3609
3761
|
methodNotAllowed(response, 'Use GET for /cli/programs/:programId/shares.');
|
|
@@ -4024,45 +4176,8 @@ export function createSyncServiceRequestHandler({
|
|
|
4024
4176
|
const limit = parseLimit(route.options.limit, { defaultValue: 5, max: 20 });
|
|
4025
4177
|
let refresh = null;
|
|
4026
4178
|
if (route.options.refresh === 'morning_open') {
|
|
4027
|
-
const writeAccount = writeAuthenticator
|
|
4028
|
-
? await writeAuthenticator(requestToken)
|
|
4029
|
-
: requestToken === token
|
|
4030
|
-
? account
|
|
4031
|
-
: null;
|
|
4032
|
-
if (!writeAccount) {
|
|
4033
|
-
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
4034
|
-
return;
|
|
4035
|
-
}
|
|
4036
|
-
unauthorized(response, request);
|
|
4037
|
-
return;
|
|
4038
|
-
}
|
|
4039
4179
|
const sourceTrigger = 'daily_refresh';
|
|
4040
|
-
|
|
4041
|
-
try {
|
|
4042
|
-
const generationResult = await generateCoachObservationsForAccount(writeAccount, {
|
|
4043
|
-
sourceTrigger,
|
|
4044
|
-
refresh: route.options.refresh
|
|
4045
|
-
});
|
|
4046
|
-
refresh = coachObservationGenerationMetadata(generationResult, sourceTrigger);
|
|
4047
|
-
} catch (error) {
|
|
4048
|
-
console.error('Coach observation generation on morning open failed:', error.message);
|
|
4049
|
-
refresh = {
|
|
4050
|
-
attempted: true,
|
|
4051
|
-
generated: 0,
|
|
4052
|
-
persisted: 0,
|
|
4053
|
-
skippedReason: 'generation_failed',
|
|
4054
|
-
sourceTrigger
|
|
4055
|
-
};
|
|
4056
|
-
}
|
|
4057
|
-
} else {
|
|
4058
|
-
refresh = {
|
|
4059
|
-
attempted: false,
|
|
4060
|
-
generated: 0,
|
|
4061
|
-
persisted: 0,
|
|
4062
|
-
skippedReason: 'generation_unavailable',
|
|
4063
|
-
sourceTrigger
|
|
4064
|
-
};
|
|
4065
|
-
}
|
|
4180
|
+
refresh = skippedCoachObservationGenerationMetadata('morning_open_read_only', sourceTrigger);
|
|
4066
4181
|
}
|
|
4067
4182
|
const observations = await listCurrentCoachObservationsForAccount(account, {
|
|
4068
4183
|
limit
|