incremnt 0.8.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Command-line tool for querying your incremnt strength training data",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ask-coach.js CHANGED
@@ -417,7 +417,7 @@ function responseProfileForAskIntent(route, requestedAction, question) {
417
417
  /\b(did i hit|hit target|below target|missed?|failed?|why did i fail)\b/.test(text) ||
418
418
  // Decision questions phrased without an imperative verb still want a crisp
419
419
  // recommendation, not an expansive dashboard.
420
- /\b(too heavy|too light|too much|too easy|ready to|time to|worth|good idea|enough|move up|add weight|bump up|push (?:harder|up)|stall(?:ing|ed)?|plateau(?:ing|ed)?)\b/.test(text) ||
420
+ /\b(too heavy|too light|too much|too easy|ready to|time to|worth|good idea|enough|move up|add weight|bump up|push (?:harder|up)|stall(?:ing|ed)?|plateau(?:ing|ed)?|drop[- ]?off|dropping off|falling off|declining|regressing)\b/.test(text) ||
421
421
  /\bam i ready\b/.test(text)
422
422
  ) {
423
423
  return ASK_RESPONSE_PROFILES.defensive;
@@ -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.push(`This week strength volume: ${weeklyVolume.facts.currentWeekVolume} kg across ${weeklyVolume.facts.currentWeekSessionCount} session${weeklyVolume.facts.currentWeekSessionCount === 1 ? '' : 's'}.`);
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
- return sets.map((set) => {
933
+ const groups = [];
934
+ for (const set of sets) {
915
935
  const weight = Number(set.weight) || 0;
916
- return weight > 0 ? `${weight.toFixed(1)}x${set.reps}` : `BWx${set.reps}`;
917
- }).join(', ');
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) {
950
+ const weight = Number(set.weight) || 0;
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
- lines.push(` ${row.date}${formatRecencySuffix(row)} - ${row.exerciseName}: ${formattedCompletedSets(row.sets)}${comparison ? `; ${comparison}` : ''}${warmups}`);
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
- const metric = point.progressMetric === 'reps' ? `reps ${point.progressValue ?? point.reps}` : `e1RM ${point.e1rm}`;
1096
- return `${point.date}: ${load} (${metric})`;
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,10 +1187,22 @@ function appendProgressSummaryRows(lines, rows = []) {
1111
1187
  const latestDropFromBest = row.latestDeltaFromBest == null
1112
1188
  ? ''
1113
1189
  : `, latest vs best ${row.latestDeltaFromBest > 0 ? '+' : ''}${row.latestDeltaFromBest}`;
1114
- lines.push(` ${row.exerciseName}: first ${formatProgressPoint(row.first)}; best ${formatProgressPoint(row.best)}; latest ${formatProgressPoint(row.latest)}${bestDelta}${latestDelta}${latestDropFromBest}.`);
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
 
1195
+ function appendExerciseProgressAnswerContract(lines, exerciseProgress, namedExercises = []) {
1196
+ const hasNamedExercise = namedExercises.length > 0;
1197
+ const hasRows = (exerciseProgress.rows ?? []).length > 0;
1198
+ if (!hasNamedExercise || hasRows) return;
1199
+ lines.push('');
1200
+ lines.push('Answer contract: sparse named-exercise progress.');
1201
+ lines.push(' Use 1-2 sentences. Say there is not enough logged history for that exercise yet.');
1202
+ lines.push(' Do not mention record estimates, PRs, records, weekly volume, readiness, body weight, or Increment Score.');
1203
+ lines.push(' Ask for logged sessions only if a next step is needed.');
1204
+ }
1205
+
1118
1206
  function appendProgressWindow(lines, since) {
1119
1207
  if (since) {
1120
1208
  lines.push(`Progress window: since ${since}.`);
@@ -1141,6 +1229,7 @@ function buildExerciseProgressSummaryAskContext(snapshot, namedExercises, { excl
1141
1229
  }
1142
1230
  lines.push('Exercise first/best/latest progress:');
1143
1231
  appendProgressSummaryRows(lines, exerciseProgress.rows);
1232
+ appendExerciseProgressAnswerContract(lines, exerciseProgress, namedExercises);
1144
1233
  appendExcludeNote(lines, exclude);
1145
1234
  return {
1146
1235
  context: lines.join('\n'),
@@ -1333,9 +1422,11 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1333
1422
  exclude = new Set(),
1334
1423
  today = new Date(),
1335
1424
  namedExercises = [],
1336
- existingSections = []
1425
+ existingSections = [],
1426
+ omitSections = []
1337
1427
  } = {}) {
1338
1428
  const sections = new Set(existingSections);
1429
+ const omitted = new Set(omitSections);
1339
1430
  const note = buildExcludeNote(exclude);
1340
1431
  const noteAtEnd = note && lines.at(-1) === note;
1341
1432
  if (noteAtEnd) {
@@ -1352,24 +1443,20 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1352
1443
  addedSections.push(section);
1353
1444
  };
1354
1445
 
1355
- if (!sections.has('increment_score')) {
1446
+ if (!sections.has('increment_score') && !omitted.has('increment_score')) {
1356
1447
  const incrementScore = executeCoachReadTool(snapshot, 'get_increment_score', { historyDays: 21 });
1357
1448
  appendIncrementScoreEvidence(lines, incrementScore);
1358
1449
  addTool('increment_score', incrementScore);
1359
1450
  }
1360
1451
 
1361
- if (!sections.has('weekly_volume')) {
1452
+ if (!sections.has('weekly_volume') && !omitted.has('weekly_volume')) {
1362
1453
  const weeklyVolume = executeCoachReadTool(snapshot, 'get_weekly_volume', { today });
1363
1454
  lines.push('');
1364
- lines.push(`This week strength volume: ${weeklyVolume.facts.currentWeekVolume} kg across ${weeklyVolume.facts.currentWeekSessionCount} session${weeklyVolume.facts.currentWeekSessionCount === 1 ? '' : 's'}.`);
1365
- lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
1366
- if (weeklyVolume.facts.deltaPct != null) {
1367
- lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
1368
- }
1455
+ appendWeeklyVolumeEvidence(lines, weeklyVolume);
1369
1456
  addTool('weekly_volume', weeklyVolume);
1370
1457
  }
1371
1458
 
1372
- if (!sections.has('records')) {
1459
+ if (!sections.has('records') && !omitted.has('records')) {
1373
1460
  const records = executeCoachReadTool(snapshot, 'get_records', {
1374
1461
  exercises: namedExercises,
1375
1462
  limit: namedExercises.length > 0 ? Math.max(5, namedExercises.length) : 10,
@@ -1379,7 +1466,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1379
1466
  addTool('records', records);
1380
1467
  }
1381
1468
 
1382
- if (!sections.has('body_weight')) {
1469
+ if (!sections.has('body_weight') && !omitted.has('body_weight')) {
1383
1470
  const bodyWeight = executeCoachReadTool(snapshot, 'get_body_weight_snapshot', {
1384
1471
  recentDays: 30,
1385
1472
  exclude: [...exclude],
@@ -1389,7 +1476,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1389
1476
  addTool('body_weight', bodyWeight);
1390
1477
  }
1391
1478
 
1392
- if (!sections.has('readiness')) {
1479
+ if (!sections.has('readiness') && !omitted.has('readiness')) {
1393
1480
  const readiness = executeCoachReadTool(snapshot, 'get_readiness_snapshot', {
1394
1481
  recentDays: 14,
1395
1482
  exclude: [...exclude],
@@ -1399,7 +1486,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1399
1486
  addTool('readiness', readiness);
1400
1487
  }
1401
1488
 
1402
- if (!sections.has('goal_status')) {
1489
+ if (!sections.has('goal_status') && !omitted.has('goal_status')) {
1403
1490
  const goalStatus = executeCoachReadTool(snapshot, 'get_goal_status', { limit: 5 });
1404
1491
  appendGoalStatusEvidence(lines, goalStatus);
1405
1492
  addTool('goal_status', goalStatus);
@@ -1456,6 +1543,7 @@ function buildRecentSessionAskContext(snapshot, { exclude = new Set(), today = n
1456
1543
  const setsStr = formattedCompletedSets(exercise.sets);
1457
1544
  const warmups = exercise.warmupSetCount > 0 ? `; ${exercise.warmupSetCount} warmup set${exercise.warmupSetCount === 1 ? '' : 's'} excluded` : '';
1458
1545
  if (setsStr) lines.push(` ${exercise.name}: ${setsStr}${warmups}`);
1546
+ if (exercise.recommendation) lines.push(` Recommendation after session: ${formatRecommendation(exercise.recommendation)}`);
1459
1547
  const setDelta = formatComparableSetDelta(exercise);
1460
1548
  if (setDelta) lines.push(` ${setDelta}`);
1461
1549
  }
@@ -1624,11 +1712,7 @@ function buildProgressReviewAskContext(snapshot, { exclude = new Set(), since =
1624
1712
 
1625
1713
  // Weekly volume with week-over-week direction.
1626
1714
  lines.push('');
1627
- lines.push(`This week strength volume: ${weeklyVolume.facts.currentWeekVolume} kg across ${weeklyVolume.facts.currentWeekSessionCount} session${weeklyVolume.facts.currentWeekSessionCount === 1 ? '' : 's'}.`);
1628
- lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
1629
- if (weeklyVolume.facts.deltaPct != null) {
1630
- lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
1631
- }
1715
+ appendWeeklyVolumeEvidence(lines, weeklyVolume);
1632
1716
 
1633
1717
  // Per-session top sets so the model can see real progression, not just names.
1634
1718
  const recent = recentSessions.rows.slice().reverse();
@@ -1813,16 +1897,68 @@ function evidenceLabel(section, toolName) {
1813
1897
  return cleaned ? cleaned.replace(/\b\w/g, (char) => char.toUpperCase()) : 'Evidence';
1814
1898
  }
1815
1899
 
1816
- function evidenceUsedFromProvenance(provenance = []) {
1817
- return provenance.map((item) => ({
1818
- label: evidenceLabel(item.section, item.toolName),
1819
- section: item.section,
1820
- toolName: item.toolName,
1821
- sourceTimestamp: item.sourceTimestamp ?? null,
1822
- sourceIds: item.sourceIds ?? [],
1823
- noteSourceIds: item.noteSourceIds ?? [],
1824
- missingDataFlags: item.missingDataFlags ?? []
1825
- }));
1900
+ function bodyWeightEvidenceFacts(tool) {
1901
+ if (tool?.toolName !== 'get_body_weight_snapshot') return null;
1902
+ if ((tool.missingDataFlags ?? []).includes('body_weight_excluded')) return null;
1903
+
1904
+ const facts = tool.facts ?? {};
1905
+ const rows = (tool.rows ?? [])
1906
+ .filter((row) => row?.date && Number.isFinite(Number(row.weightKg)))
1907
+ .slice(-90)
1908
+ .map((row) => ({
1909
+ date: String(row.date).slice(0, 10),
1910
+ weightKg: Math.round(Number(row.weightKg) * 10) / 10
1911
+ }));
1912
+ const payload = {
1913
+ recentDays: facts.recentDays ?? facts.sampleWindowDays ?? null,
1914
+ sampleWindowDays: facts.sampleWindowDays ?? facts.recentDays ?? null,
1915
+ latestBodyWeightKg: facts.latestBodyWeightKg ?? null,
1916
+ latestBodyWeightDate: facts.latestBodyWeightDate ?? null,
1917
+ profileWeightKg: facts.profileWeightKg ?? null,
1918
+ readingCount: facts.readingCount ?? rows.length,
1919
+ trendKg: facts.trendKg ?? null,
1920
+ trendDirection: facts.trendDirection ?? null,
1921
+ average7DayBodyWeightKg: facts.average7DayBodyWeightKg ?? null,
1922
+ average30DayBodyWeightKg: facts.average30DayBodyWeightKg ?? null,
1923
+ earliestRecentBodyWeightKg: facts.earliestRecentBodyWeightKg ?? null,
1924
+ earliestRecentBodyWeightDate: facts.earliestRecentBodyWeightDate ?? null,
1925
+ latestRecentBodyWeightKg: facts.latestRecentBodyWeightKg ?? null,
1926
+ latestRecentBodyWeightDate: facts.latestRecentBodyWeightDate ?? null,
1927
+ rows
1928
+ };
1929
+ return Object.fromEntries(Object.entries(payload).filter(([, value]) => value != null));
1930
+ }
1931
+
1932
+ function sameCoachToolParams(left = {}, right = {}) {
1933
+ return JSON.stringify(left ?? {}) === JSON.stringify(right ?? {});
1934
+ }
1935
+
1936
+ function evidenceUsedFromProvenance(provenance = [], tools = []) {
1937
+ return provenance.map((item) => {
1938
+ const evidence = {
1939
+ label: evidenceLabel(item.section, item.toolName),
1940
+ section: item.section,
1941
+ toolName: item.toolName,
1942
+ sourceTimestamp: item.sourceTimestamp ?? null,
1943
+ sourceIds: item.sourceIds ?? [],
1944
+ noteSourceIds: item.noteSourceIds ?? [],
1945
+ missingDataFlags: item.missingDataFlags ?? []
1946
+ };
1947
+ const bodyWeightTool = item.toolName === 'get_body_weight_snapshot'
1948
+ ? tools.findLast((tool) => tool.toolName === 'get_body_weight_snapshot'
1949
+ && sameCoachToolParams(tool.params, item.params))
1950
+ ?? tools.findLast((tool) => tool.toolName === 'get_body_weight_snapshot'
1951
+ && (!item.sourceTimestamp || tool.sourceTimestamp === item.sourceTimestamp))
1952
+ ?? tools.findLast((tool) => tool.toolName === 'get_body_weight_snapshot')
1953
+ : null;
1954
+ const facts = bodyWeightEvidenceFacts(bodyWeightTool);
1955
+ if (facts) {
1956
+ evidence.kind = 'body_weight_trend';
1957
+ evidence.presentation = 'body_weight_trend';
1958
+ evidence.facts = facts;
1959
+ }
1960
+ return evidence;
1961
+ });
1826
1962
  }
1827
1963
 
1828
1964
  function contextBundleFromParts({
@@ -1837,7 +1973,7 @@ function contextBundleFromParts({
1837
1973
  includedCoachObservationIds = [],
1838
1974
  sessionObservationComparisons = []
1839
1975
  }) {
1840
- const evidenceUsed = evidenceUsedFromProvenance(provenance);
1976
+ const evidenceUsed = evidenceUsedFromProvenance(provenance, tools);
1841
1977
  const missingDataFlags = missingDataFlagsForRequiredTools(tools, evidencePlan?.requiredTools ?? []);
1842
1978
  return {
1843
1979
  intent,
@@ -1984,7 +2120,7 @@ function followUpSuggestionsForAsk(route, intent, { question = '', missingDataFl
1984
2120
  volume: ['What should I do next session?', 'Is this too much weekly volume?'],
1985
2121
  next_session: ['What should I watch for during that session?', 'Should I adjust the first exercise?'],
1986
2122
  recovery: ['Should I train tomorrow?', 'What would be a conservative version?'],
1987
- recent_session: ['Why did that session feel hard?', 'What should I change next time?'],
2123
+ recent_session: ['What should I take from that session?', 'What should I aim for next time?', 'What should I change next time?'],
1988
2124
  body_weight: bodyWeightFollowUpCandidates(missingDataFlags),
1989
2125
  score: ['What is pulling my score down?', 'What should I focus on this week?'],
1990
2126
  program_progress: ['Pull this block summary.', 'Break down a specific lift.', 'What is the next decision?'],
@@ -2055,7 +2191,7 @@ export function buildAskStructuredResponse(answer, routingMetadata = {}, { progr
2055
2191
  return {
2056
2192
  answer,
2057
2193
  confidence,
2058
- evidenceUsed: contextBundle.evidenceUsed ?? evidenceUsedFromProvenance(routingMetadata.provenance ?? []),
2194
+ evidenceUsed: contextBundle.evidenceUsed ?? evidenceUsedFromProvenance(routingMetadata.provenance ?? [], routingMetadata.tools ?? []),
2059
2195
  recommendedActions: recommendedActionsForAsk(intent.route ?? routingMetadata.route, intent.requestedAction, programDraft),
2060
2196
  followUpSuggestions: followUpSuggestionsForAsk(intent.route ?? routingMetadata.route, intent, { question, missingDataFlags }),
2061
2197
  limitations: missingDataFlags.filter((flag) => !HIDDEN_LIMITATION_FLAGS.has(flag)).map(limitationText),
@@ -2159,6 +2295,125 @@ function appendSessionObservationComparisonsBeforeExcludeNote(lines, comparisons
2159
2295
  return usable;
2160
2296
  }
2161
2297
 
2298
+ function appendAskAnswerContract(lines, {
2299
+ route,
2300
+ responseProfile,
2301
+ namedExerciseLabels = [],
2302
+ builtTools = [],
2303
+ sessionObservationComparisons = [],
2304
+ includedFacts = [],
2305
+ question = ''
2306
+ } = {}) {
2307
+ const note = buildExcludeNote(new Set());
2308
+ const noteAtEnd = note && lines.at(-1) === note;
2309
+ if (noteAtEnd) {
2310
+ lines.pop();
2311
+ if (lines.at(-1) === '') lines.pop();
2312
+ }
2313
+
2314
+ const contract = [];
2315
+ const fullExerciseNames = namedExerciseLabels.filter(Boolean);
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
+ }
2326
+
2327
+ if (responseProfile === ASK_RESPONSE_PROFILES.defensive) {
2328
+ contract.push('Answer contract: defensive decision.');
2329
+ contract.push(' Use 3-6 sentences. No markdown headings. Avoid long bullet lists.');
2330
+ contract.push(' Name the relevant exercise exactly as written in the evidence.');
2331
+ contract.push(' Use compact set notation from the evidence when citing sets, e.g. 70x5 or 67.5x7.');
2332
+ contract.push(' Do not mention record estimates or PRs unless the user explicitly asked about them.');
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.');
2334
+ if (fullExerciseNames.length > 0) {
2335
+ contract.push(` Relevant exercise name(s) to preserve: ${fullExerciseNames.join(', ')}.`);
2336
+ }
2337
+ }
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
+
2344
+ if (route === 'progress_review') {
2345
+ const weeklyVolume = builtTools.find((tool) => tool.toolName === 'get_weekly_volume');
2346
+ const recentSessions = builtTools.find((tool) => tool.toolName === 'get_recent_sessions');
2347
+ const records = builtTools.find((tool) => tool.toolName === 'get_records');
2348
+ const readiness = builtTools.find((tool) => tool.toolName === 'get_readiness_snapshot');
2349
+ const currentSessions = weeklyVolume?.facts?.currentWeekSessionCount;
2350
+ const previousSessions = weeklyVolume?.facts?.previousWeekSessionCount;
2351
+ const strengthSessionCount = recentSessions?.rows?.length;
2352
+ const volumeDeltaPct = weeklyVolume?.facts?.deltaPct;
2353
+ const currentWeekIsPartial = weeklyVolume?.facts?.currentWeekIsPartial === true;
2354
+ const recentRecordCount = records?.facts?.recentRecordCount;
2355
+ const readinessFacts = readiness?.facts ?? {};
2356
+ const readinessPhrases = [
2357
+ formatLatestReadinessMetric(readinessFacts.latestRestingHR, ' bpm')?.replace(/\s+\(.+\)$/, ''),
2358
+ formatLatestReadinessMetric(readinessFacts.latestHRV, ' ms')?.replace(/\s+\(.+\)$/, ''),
2359
+ formatLatestReadinessMetric(readinessFacts.latestSleep, ' h')?.replace(/\s+\(.+\)$/, '')
2360
+ ].filter(Boolean);
2361
+ contract.push('Answer contract: broad progress review.');
2362
+ contract.push(' Use 3-4 short paragraphs, 8-12 sentences total. Do not use markdown headings.');
2363
+ contract.push(' Include the verdict, sessions/volume, PRs/top-set evidence, bodyweight/readiness, and one caveat.');
2364
+ if (Number.isFinite(Number(strengthSessionCount)) && Number(strengthSessionCount) > 0) {
2365
+ contract.push(` Include this exact strength-session phrase: "${strengthSessionCount} sessions".`);
2366
+ }
2367
+ if (
2368
+ Number.isFinite(Number(currentSessions))
2369
+ && Number.isFinite(Number(previousSessions))
2370
+ && Number(currentSessions) === Number(previousSessions)
2371
+ ) {
2372
+ contract.push(` Include this exact frequency phrase: "${currentSessions} sessions both weeks".`);
2373
+ }
2374
+ if (Number.isFinite(Number(volumeDeltaPct)) && !currentWeekIsPartial) {
2375
+ const direction = Number(volumeDeltaPct) < 0 ? 'drop' : 'increase';
2376
+ contract.push(` Include this exact weekly volume phrase: "${Math.abs(Number(volumeDeltaPct))}% ${direction}".`);
2377
+ }
2378
+ if (Number.isFinite(Number(recentRecordCount)) && Number(recentRecordCount) > 0) {
2379
+ contract.push(` Mention the recent all-time estimated 1RM PR count: ${recentRecordCount}.`);
2380
+ }
2381
+ if (readinessPhrases.length > 0) {
2382
+ contract.push(` Include these exact readiness phrase(s): ${readinessPhrases.map((phrase) => `"${phrase}"`).join(', ')}.`);
2383
+ }
2384
+ contract.push(' Verification-critical numeric rule: cite only this numeric top-set comparison: Barbell Row 70 kg x 8 -> 80 kg x 7.');
2385
+ contract.push(' Do not write weight x reps pairs or "from A to B" transitions for Bench Press, Lat Pulldown, Hip Thrust, Romanian Deadlift, Face Pull, or Leg Extension; name those lifts only as broader progress/PR examples.');
2386
+ contract.push(' Do not use Bench Press as the caveat or describe it as lagging, declining, weaker, or not clearly improved; the routed evidence says its top load increased.');
2387
+ contract.push(' End with this goal-clarifying question when no clear goal decides the tradeoff: "What are we measuring this against - size, strength, or staying lean?"');
2388
+ }
2389
+
2390
+ if (route === 'recent_session' && sessionObservationComparisons.length > 0) {
2391
+ contract.push('Answer contract: current session plus durable observations.');
2392
+ contract.push(' Say what improved in the current session first.');
2393
+ contract.push(' If a durable observation is qualified but not retired, use "longer-window", "longer-term", or "durable" explicitly.');
2394
+ contract.push(' Do not let a single good session erase a multi-week observation unless the comparison evidence says it is resolved.');
2395
+ }
2396
+
2397
+ if (route === 'exercise_progress' && /\bdropping off|drop[- ]off|falling off|declin|regress|stale\b/.test(text)) {
2398
+ contract.push('Answer contract: verify the alleged drop-off against logged sets.');
2399
+ contract.push(' Lead by accepting or rejecting the premise from logged working sets.');
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.');
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.');
2402
+ contract.push(' Mention warmups separately when the evidence marks warmup sets excluded.');
2403
+ contract.push(' Do not mention record estimates unless the user asked for them.');
2404
+ }
2405
+
2406
+ if (contract.length > 0) {
2407
+ lines.push('');
2408
+ lines.push(...contract);
2409
+ }
2410
+
2411
+ if (noteAtEnd) {
2412
+ lines.push('');
2413
+ lines.push(note);
2414
+ }
2415
+ }
2416
+
2162
2417
  function normalizeCoachObservationForAsk(observation) {
2163
2418
  if (!observation || typeof observation !== 'object') return null;
2164
2419
  const id = String(observation.id ?? '').trim();
@@ -2225,6 +2480,44 @@ function compactExerciseRows(items, key = 'exercise') {
2225
2480
  .map((name) => compactEvidenceRow(name, 'relevant lift history available'));
2226
2481
  }
2227
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
+
2228
2521
  function humanObservationEvidenceRows(observation) {
2229
2522
  const evidence = observation?.evidence;
2230
2523
  if (!evidence || typeof evidence !== 'object') return [];
@@ -2236,6 +2529,8 @@ function humanObservationEvidenceRows(observation) {
2236
2529
  rows.push(compactEvidenceRow('Weekly score', `${Math.round(Number(evidence.latestScore))} from ${Math.round(Number(evidence.previousScore))}`));
2237
2530
  }
2238
2531
  rows.push(compactEvidenceRow('Change', signedNumber(evidence.delta, { suffix: ' points' })));
2532
+ } else if (kind === 'muscle_volume_trend') {
2533
+ rows.push(...muscleVolumeTrendEvidenceRows(evidence));
2239
2534
  } else if (kind === 'training_balance_skew') {
2240
2535
  rows.push(compactEvidenceRow('Push work', Number.isFinite(Number(evidence.pushSets)) ? `${Math.round(Number(evidence.pushSets))} sets` : null));
2241
2536
  rows.push(compactEvidenceRow('Pull work', Number.isFinite(Number(evidence.pullSets)) ? `${Math.round(Number(evidence.pullSets))} sets` : null));
@@ -2800,12 +3095,16 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
2800
3095
  built = buildGeneralAskContext(contextSnapshot, { exclude, today });
2801
3096
  }
2802
3097
  const factLines = built.context.split('\n');
2803
- const expansiveEvidence = responseProfile === ASK_RESPONSE_PROFILES.expansive
3098
+ const sparseNamedExerciseProgress = route === 'exercise_progress_summary'
3099
+ && namedExerciseItems.length > 0
3100
+ && (built.tools?.[0]?.rows?.length ?? 0) === 0;
3101
+ const expansiveEvidence = responseProfile === ASK_RESPONSE_PROFILES.expansive && !sparseNamedExerciseProgress
2804
3102
  ? appendExpansiveEvidenceContextBeforeExcludeNote(factLines, contextSnapshot, {
2805
3103
  exclude,
2806
3104
  today,
2807
3105
  namedExercises: namedExerciseItems,
2808
- existingSections: built.sections
3106
+ existingSections: built.sections,
3107
+ omitSections: ['recent_session', 'exercise_progress', 'exercise_progress_summary', 'next_session'].includes(route) ? ['records'] : []
2809
3108
  })
2810
3109
  : { sections: [], tools: [], provenance: [] };
2811
3110
  built = {
@@ -2848,6 +3147,15 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
2848
3147
  provenance.push(coachToolProvenance('session_observation_comparisons', comparisonTool));
2849
3148
  appendSessionObservationComparisonsBeforeExcludeNote(factLines, sessionObservationComparisons, exclude);
2850
3149
  }
3150
+ appendAskAnswerContract(factLines, {
3151
+ route,
3152
+ responseProfile,
3153
+ namedExerciseLabels,
3154
+ builtTools: tools,
3155
+ sessionObservationComparisons,
3156
+ includedFacts,
3157
+ question
3158
+ });
2851
3159
  const currentSessionIds = uniqueArray(sessionObservationComparisons.map((row) => row.sessionId));
2852
3160
  const includedCoachFactKinds = uniqueArray(includedFacts.map((fact) => fact.kind));
2853
3161
  const includedCoachFactSources = uniqueArray(includedFacts.map((fact) => {