incremnt 0.8.1 → 0.8.3

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/src/ask-coach.js CHANGED
@@ -399,6 +399,32 @@ function requestedActionForRoute(route, question, { isFollowUp = false, carriedP
399
399
  return byRoute[route] ?? 'answer_training_question';
400
400
  }
401
401
 
402
+ export const ASK_RESPONSE_PROFILES = Object.freeze({
403
+ expansive: 'expansive',
404
+ defensive: 'defensive',
405
+ structured: 'structured'
406
+ });
407
+
408
+ function responseProfileForAskIntent(route, requestedAction, question) {
409
+ if (route === 'program_design' || requestedAction === 'draft_plan') return ASK_RESPONSE_PROFILES.structured;
410
+ const text = String(question ?? '').toLowerCase();
411
+ if (
412
+ requestedAction === 'recommend_action' ||
413
+ requestedAction === 'recommend_next_session' ||
414
+ requestedAction === 'explain_cause' ||
415
+ /\b(should|can i|safe|increase|decrease|deload|swap|change|adjust)\b/.test(text) ||
416
+ /\b(what should i do|do next|next session|up next)\b/.test(text) ||
417
+ /\b(did i hit|hit target|below target|missed?|failed?|why did i fail)\b/.test(text) ||
418
+ // Decision questions phrased without an imperative verb still want a crisp
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)?|drop[- ]?off|dropping off|falling off|declining|regressing)\b/.test(text) ||
421
+ /\bam i ready\b/.test(text)
422
+ ) {
423
+ return ASK_RESPONSE_PROFILES.defensive;
424
+ }
425
+ return ASK_RESPONSE_PROFILES.expansive;
426
+ }
427
+
402
428
  function confidenceForIntent({ rawRoute, route, namedExercises, question, previousRoute, isFollowUp }) {
403
429
  let confidence = 0.72;
404
430
  if (route !== 'general') confidence += 0.1;
@@ -456,6 +482,8 @@ function classifyAskIntentWithPrevious(snapshot, question, { previousIntent = nu
456
482
  : current.namedExercises;
457
483
  const sessionLabel = current.sessionLabel ?? (carriedPreviousTopic ? previous?.sessionLabel ?? null : null);
458
484
  const sessionReference = current.sessionReference ?? (carriedPreviousTopic ? previous?.sessionReference ?? null : null);
485
+ const requestedAction = requestedActionForRoute(route, question, { isFollowUp, carriedPreviousTopic });
486
+ const responseProfile = responseProfileForAskIntent(route, requestedAction, question);
459
487
  return {
460
488
  route,
461
489
  effectiveRoute: route === 'exercise_progress' && namedExercises.length === 0 ? 'general' : route,
@@ -476,7 +504,8 @@ function classifyAskIntentWithPrevious(snapshot, question, { previousIntent = nu
476
504
  sessionReference
477
505
  },
478
506
  timeframe: since ? { since } : null,
479
- requestedAction: requestedActionForRoute(route, question, { isFollowUp, carriedPreviousTopic }),
507
+ requestedAction,
508
+ responseProfile,
480
509
  isFollowUp,
481
510
  previousRoute: previous?.route ?? null,
482
511
  ambiguityFlags: ambiguityFlagsForIntent({
@@ -623,6 +652,10 @@ const ASK_ROUTE_REQUIRED_TOOLS = Object.freeze({
623
652
  program_design: ['get_recent_sessions', 'get_goal_status']
624
653
  });
625
654
 
655
+ function requiredToolsForAskRoute(route) {
656
+ return ASK_ROUTE_REQUIRED_TOOLS[route] ?? ASK_ROUTE_REQUIRED_TOOLS.general;
657
+ }
658
+
626
659
  function askObservationCheckPlan({ exclude = new Set(), route } = {}) {
627
660
  if (exclude.has('coach_observations')) return [];
628
661
  const checks = [
@@ -727,7 +760,7 @@ export function planAskEvidence(snapshot, question, {
727
760
  const since = intent.timeframe?.since ?? null;
728
761
  const effectiveRoute = route === 'exercise_progress' && namedExerciseItems.length === 0 ? 'general' : route;
729
762
  const fallbackRoute = effectiveRoute === route ? null : effectiveRoute;
730
- const requiredTools = ASK_ROUTE_REQUIRED_TOOLS[effectiveRoute] ?? ASK_ROUTE_REQUIRED_TOOLS.general;
763
+ const requiredTools = requiredToolsForAskRoute(effectiveRoute);
731
764
  const observationChecks = askObservationCheckPlan({
732
765
  exclude,
733
766
  route: effectiveRoute
@@ -857,11 +890,17 @@ function buildIncrementScoreAskContext(snapshot, { exclude = new Set(), today =
857
890
  lines.push('Increment Score: no snapshots available yet.');
858
891
  } else {
859
892
  const delta = incrementScore.facts.dayOverDayDelta;
860
- const trend = !Number.isFinite(delta) ? 'unknown' : delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
861
- lines.push(`Increment Score: ${Math.round(incrementScore.facts.score)}, day-over-day trend ${trend}.`);
893
+ const scoreParts = [`Increment Score: ${Math.round(incrementScore.facts.score)}`];
894
+ if (Number.isFinite(delta)) {
895
+ const trend = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
896
+ scoreParts.push(`day-over-day trend ${trend}`);
897
+ }
898
+ lines.push(`${scoreParts.join(', ')}.`);
862
899
  // Direction word, not a raw daily-score list the model can dump back verbatim.
900
+ // Only steer on the multi-day trend when every point shares the current
901
+ // formula version; a formula change makes "rising/falling" a cross-ruler lie.
863
902
  const recentScores = (incrementScore.facts.recentScores ?? []).filter((s) => typeof s === 'number');
864
- if (recentScores.length > 1) {
903
+ if (incrementScore.facts.trendComparable && recentScores.length > 1) {
865
904
  const span = recentScores[0] - recentScores[recentScores.length - 1];
866
905
  const weekTrend = span > 2 ? 'rising' : span < -2 ? 'falling' : 'steady';
867
906
  lines.push(`Recent score trend (newest first): ${weekTrend}.`);
@@ -883,6 +922,12 @@ function formatRepDelta(delta) {
883
922
  return `${delta > 0 ? '+' : ''}${delta}`;
884
923
  }
885
924
 
925
+ function setWorkUnits(set) {
926
+ const reps = Number(set?.reps ?? 0);
927
+ const weight = Number(set?.weight ?? 0);
928
+ return (weight > 0 ? weight : 1) * reps;
929
+ }
930
+
886
931
  function formatComparableSetDelta(exercise) {
887
932
  const previous = exercise?.previousComparableSession;
888
933
  if (!previous || !Array.isArray(exercise?.sets) || !Array.isArray(previous.sets)) return null;
@@ -891,15 +936,46 @@ function formatComparableSetDelta(exercise) {
891
936
  const comparableCount = Math.min(currentSets.length, previousSets.length);
892
937
  if (comparableCount === 0) return null;
893
938
 
894
- const sameLoad = currentSets.slice(0, comparableCount).every((set, index) => set.weight === previousSets[index].weight);
895
- if (!sameLoad) return null;
896
-
897
- const repDeltas = currentSets
898
- .slice(0, comparableCount)
899
- .map((set, index) => formatRepDelta(set.reps - previousSets[index].reps));
939
+ const currentOverlap = currentSets.slice(0, comparableCount);
940
+ const previousOverlap = previousSets.slice(0, comparableCount);
941
+ const sameLoad = currentOverlap.every((set, index) => Number(set.weight) === Number(previousOverlap[index].weight));
942
+ const repDeltas = currentOverlap.map((set, index) => formatRepDelta(Number(set.reps) - Number(previousOverlap[index].reps)));
900
943
  if (repDeltas.some((delta) => delta == null)) return null;
901
944
 
902
- return `vs previous ${previous.label} on ${previous.date}: same load, reps ${repDeltas.join(', ')}`;
945
+ const currentTotalReps = currentSets.reduce((sum, set) => sum + Number(set.reps ?? 0), 0);
946
+ const previousTotalReps = previousSets.reduce((sum, set) => sum + Number(set.reps ?? 0), 0);
947
+ const currentTotalWork = currentSets.reduce((sum, set) => sum + setWorkUnits(set), 0);
948
+ const previousTotalWork = previousSets.reduce((sum, set) => sum + setWorkUnits(set), 0);
949
+ const topCurrent = currentSets[0] ?? {};
950
+ const topPrevious = previousSets[0] ?? {};
951
+ const topLoadDelta = Number(topCurrent.weight ?? 0) - Number(topPrevious.weight ?? 0);
952
+ const topRepDelta = Number(topCurrent.reps ?? 0) - Number(topPrevious.reps ?? 0);
953
+ const averageCurrentOverlap = currentOverlap.reduce((sum, set) => sum + Number(set.reps ?? 0), 0) / comparableCount;
954
+ const averagePreviousOverlap = previousOverlap.reduce((sum, set) => sum + Number(set.reps ?? 0), 0) / comparableCount;
955
+ const averageRepDelta = averageCurrentOverlap - averagePreviousOverlap;
956
+ // Only flag a regression when the session actually did LESS total work. Without
957
+ // this gate, adding a set (more total reps) or going heavier for slightly fewer
958
+ // reps per set — both textbook progression — tripped the average/top-rep
959
+ // branches and mislabeled a better session "regression".
960
+ const didLessTotalWork = currentTotalWork < previousTotalWork;
961
+ const regressionFlag = didLessTotalWork
962
+ && (averageRepDelta <= -2 || topRepDelta <= -3 || (topLoadDelta > 0 && topRepDelta <= -2));
963
+
964
+ const details = [];
965
+ if (sameLoad) {
966
+ details.push(`same load, reps ${repDeltas.join(', ')}`);
967
+ } else {
968
+ const loadDeltas = currentOverlap.map((set, index) => formatSignedDelta(Number(set.weight ?? 0) - Number(previousOverlap[index].weight ?? 0), 'kg'));
969
+ details.push(`load/reps by overlapping set ${loadDeltas.map((load, index) => `${load ?? '0.0kg'} and reps ${repDeltas[index]}`).join('; ')}`);
970
+ }
971
+ if (currentSets.length !== previousSets.length || currentTotalReps !== previousTotalReps) {
972
+ details.push(`total reps ${currentTotalReps} vs ${previousTotalReps}${currentSets.length !== previousSets.length ? ` across ${currentSets.length} vs ${previousSets.length} sets` : ''}`);
973
+ }
974
+ if (regressionFlag) {
975
+ details.push('regression flag: reps dropped sharply despite the load/set context');
976
+ }
977
+
978
+ return `vs previous ${previous.label} on ${previous.date}: ${details.join('; ')}`;
903
979
  }
904
980
 
905
981
  function appendUserNotesForSession(lines, session) {
@@ -1039,6 +1115,17 @@ function appendProgressSummaryRows(lines, rows = []) {
1039
1115
  }
1040
1116
  }
1041
1117
 
1118
+ function appendExerciseProgressAnswerContract(lines, exerciseProgress, namedExercises = []) {
1119
+ const hasNamedExercise = namedExercises.length > 0;
1120
+ const hasRows = (exerciseProgress.rows ?? []).length > 0;
1121
+ if (!hasNamedExercise || hasRows) return;
1122
+ lines.push('');
1123
+ lines.push('Answer contract: sparse named-exercise progress.');
1124
+ lines.push(' Use 1-2 sentences. Say there is not enough logged history for that exercise yet.');
1125
+ lines.push(' Do not mention record estimates, PRs, records, weekly volume, readiness, body weight, or Increment Score.');
1126
+ lines.push(' Ask for logged sessions only if a next step is needed.');
1127
+ }
1128
+
1042
1129
  function appendProgressWindow(lines, since) {
1043
1130
  if (since) {
1044
1131
  lines.push(`Progress window: since ${since}.`);
@@ -1065,6 +1152,7 @@ function buildExerciseProgressSummaryAskContext(snapshot, namedExercises, { excl
1065
1152
  }
1066
1153
  lines.push('Exercise first/best/latest progress:');
1067
1154
  appendProgressSummaryRows(lines, exerciseProgress.rows);
1155
+ appendExerciseProgressAnswerContract(lines, exerciseProgress, namedExercises);
1068
1156
  appendExcludeNote(lines, exclude);
1069
1157
  return {
1070
1158
  context: lines.join('\n'),
@@ -1174,6 +1262,169 @@ function buildRecordsAskContext(snapshot, namedExercises, { exclude = new Set(),
1174
1262
  return { context: lines.join('\n'), sections: ['header', 'records'], tools: [recordsTool], provenance: [coachToolProvenance('records', recordsTool)] };
1175
1263
  }
1176
1264
 
1265
+ function appendIncrementScoreEvidence(lines, incrementScore) {
1266
+ lines.push('');
1267
+ lines.push('Increment Score evidence:');
1268
+ if (incrementScore.facts?.score == null) {
1269
+ lines.push(' No Increment Score snapshot is available.');
1270
+ return;
1271
+ }
1272
+ const facts = incrementScore.facts;
1273
+ const delta = facts.dayOverDayDelta;
1274
+ const scoreParts = [`Current score: ${Math.round(facts.score)}/100`];
1275
+ if (Number.isFinite(delta)) {
1276
+ const trend = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
1277
+ scoreParts.push(`day-over-day ${trend}`);
1278
+ }
1279
+ lines.push(` ${scoreParts.join('; ')}.`);
1280
+ if (facts.summaryText) lines.push(` Summary: ${facts.summaryText}`);
1281
+ if ((facts.topPositiveDrivers ?? []).length > 0) {
1282
+ lines.push(` Top positive drivers: ${facts.topPositiveDrivers.join('; ')}.`);
1283
+ }
1284
+ if ((facts.topNegativeDrivers ?? []).length > 0) {
1285
+ lines.push(` Top negative drivers: ${facts.topNegativeDrivers.join('; ')}.`);
1286
+ }
1287
+ }
1288
+
1289
+ function formatRecentPrDelta(pr) {
1290
+ if (!pr || pr.priorBest == null) {
1291
+ return ' (first logged record for this lift)';
1292
+ }
1293
+ const sign = pr.delta >= 0 ? '+' : '';
1294
+ const priorDate = validDateOnlyString(pr.priorBest.date) ?? 'unknown date';
1295
+ const kindLabel = pr.kind === 'load_pr'
1296
+ ? 'load PR — heavier bar than the prior best'
1297
+ : 'rep PR — more reps at the same or lighter bar than the prior best (load looks flat but strength rose)';
1298
+ return ` (${sign}${pr.delta.toFixed(1)} kg vs prior best ${pr.priorBest.e1rm.toFixed(1)} kg from ${priorDate}; ${kindLabel})`;
1299
+ }
1300
+
1301
+ function appendRecordEvidence(lines, records, { windowStart = null, today = new Date() } = {}) {
1302
+ lines.push('');
1303
+ lines.push('Best estimated 1RM records:');
1304
+ if (records.rows.length === 0) {
1305
+ lines.push(' No weighted completed sets found.');
1306
+ return;
1307
+ }
1308
+ const todayIso = dateOnlyString(today);
1309
+ for (const record of records.rows) {
1310
+ const recordDate = validDateOnlyString(record.date);
1311
+ const inWindow = windowStart && recordDate != null && recordDate >= windowStart && recordDate <= todayIso;
1312
+ lines.push(` ${inWindow ? '★ ' : ''}${record.name}: ${record.e1rm.toFixed(1)} kg (${recordDate ?? 'unknown date'})`);
1313
+ }
1314
+ }
1315
+
1316
+ function appendBodyWeightEvidence(lines, bodyWeight, exclude) {
1317
+ lines.push('');
1318
+ if (exclude.has('bodyWeight')) {
1319
+ lines.push('Body weight sharing is disabled for AI Coach.');
1320
+ } else if (bodyWeight.facts.latestBodyWeightKg != null) {
1321
+ const source = bodyWeight.facts.latestBodyWeightDate
1322
+ ? `latest reading ${bodyWeight.facts.latestBodyWeightDate}`
1323
+ : 'profile';
1324
+ lines.push(`Body weight: ${bodyWeight.facts.latestBodyWeightKg.toFixed(1)} kg (${source}).`);
1325
+ if (bodyWeight.facts.trendKg != null) {
1326
+ const trend = bodyWeight.facts.trendKg >= 0 ? `+${bodyWeight.facts.trendKg.toFixed(1)}` : bodyWeight.facts.trendKg.toFixed(1);
1327
+ lines.push(`Body weight trend, last ${bodyWeight.facts.recentDays} days: ${trend} kg across ${bodyWeight.facts.readingCount} readings.`);
1328
+ }
1329
+ } else {
1330
+ lines.push('No body weight is available in the exported profile or HealthKit body-mass readings.');
1331
+ }
1332
+ }
1333
+
1334
+ function appendGoalStatusEvidence(lines, goalStatus) {
1335
+ if (goalStatus.rows.length === 0) return;
1336
+ lines.push('');
1337
+ lines.push('Goal status:');
1338
+ for (const goal of goalStatus.rows) {
1339
+ const progress = goal.progressPercent != null ? `${goal.progressPercent}%` : 'unknown progress';
1340
+ lines.push(` ${goal.exerciseName}: ${progress}`);
1341
+ }
1342
+ }
1343
+
1344
+ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
1345
+ exclude = new Set(),
1346
+ today = new Date(),
1347
+ namedExercises = [],
1348
+ existingSections = []
1349
+ } = {}) {
1350
+ const sections = new Set(existingSections);
1351
+ const note = buildExcludeNote(exclude);
1352
+ const noteAtEnd = note && lines.at(-1) === note;
1353
+ if (noteAtEnd) {
1354
+ lines.pop();
1355
+ if (lines.at(-1) === '') lines.pop();
1356
+ }
1357
+
1358
+ const tools = [];
1359
+ const provenance = [];
1360
+ const addedSections = [];
1361
+ const addTool = (section, tool) => {
1362
+ tools.push(tool);
1363
+ provenance.push(coachToolProvenance(section, tool));
1364
+ addedSections.push(section);
1365
+ };
1366
+
1367
+ if (!sections.has('increment_score')) {
1368
+ const incrementScore = executeCoachReadTool(snapshot, 'get_increment_score', { historyDays: 21 });
1369
+ appendIncrementScoreEvidence(lines, incrementScore);
1370
+ addTool('increment_score', incrementScore);
1371
+ }
1372
+
1373
+ if (!sections.has('weekly_volume')) {
1374
+ const weeklyVolume = executeCoachReadTool(snapshot, 'get_weekly_volume', { today });
1375
+ lines.push('');
1376
+ lines.push(`This week strength volume: ${weeklyVolume.facts.currentWeekVolume} kg across ${weeklyVolume.facts.currentWeekSessionCount} session${weeklyVolume.facts.currentWeekSessionCount === 1 ? '' : 's'}.`);
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
+ }
1381
+ addTool('weekly_volume', weeklyVolume);
1382
+ }
1383
+
1384
+ if (!sections.has('records')) {
1385
+ const records = executeCoachReadTool(snapshot, 'get_records', {
1386
+ exercises: namedExercises,
1387
+ limit: namedExercises.length > 0 ? Math.max(5, namedExercises.length) : 10,
1388
+ today
1389
+ });
1390
+ appendRecordEvidence(lines, records, { today });
1391
+ addTool('records', records);
1392
+ }
1393
+
1394
+ if (!sections.has('body_weight')) {
1395
+ const bodyWeight = executeCoachReadTool(snapshot, 'get_body_weight_snapshot', {
1396
+ recentDays: 30,
1397
+ exclude: [...exclude],
1398
+ today
1399
+ });
1400
+ appendBodyWeightEvidence(lines, bodyWeight, exclude);
1401
+ addTool('body_weight', bodyWeight);
1402
+ }
1403
+
1404
+ if (!sections.has('readiness')) {
1405
+ const readiness = executeCoachReadTool(snapshot, 'get_readiness_snapshot', {
1406
+ recentDays: 14,
1407
+ exclude: [...exclude],
1408
+ today
1409
+ });
1410
+ appendReadinessSummary(lines, readiness);
1411
+ addTool('readiness', readiness);
1412
+ }
1413
+
1414
+ if (!sections.has('goal_status')) {
1415
+ const goalStatus = executeCoachReadTool(snapshot, 'get_goal_status', { limit: 5 });
1416
+ appendGoalStatusEvidence(lines, goalStatus);
1417
+ addTool('goal_status', goalStatus);
1418
+ }
1419
+
1420
+ if (noteAtEnd) {
1421
+ lines.push('');
1422
+ lines.push(note);
1423
+ }
1424
+
1425
+ return { sections: addedSections, tools, provenance };
1426
+ }
1427
+
1177
1428
  function rowMatchesSetHint(row, hint) {
1178
1429
  const exercise = (row?.exercises ?? []).find((candidate) => canonicalExerciseName(candidate.name) === hint.exerciseCanonical);
1179
1430
  if (!exercise) return false;
@@ -1428,9 +1679,17 @@ function buildProgressReviewAskContext(snapshot, { exclude = new Set(), since =
1428
1679
  // Records, flagging which were set inside the review window (recent PRs).
1429
1680
  if (records.rows.length > 0) {
1430
1681
  lines.push('');
1682
+ const recentRecords = records.facts.recentRecords ?? [];
1431
1683
  if (recentRecordCount > 0) {
1432
- const names = recentRecordNames.join(', ');
1433
- lines.push(`Recent all-time estimated 1RM PR count in review window: ${recentRecordCount}. Mention this count explicitly in broad progress reviews. Exercises: ${names}.`);
1684
+ lines.push(`Recent all-time estimated 1RM PR count in review window: ${recentRecordCount}. Mention this count explicitly in broad progress reviews.`);
1685
+ if (recentRecords.length > 0) {
1686
+ lines.push('Recent PRs (compared to the prior best, so a rep PR at the same bar weight is not mistaken for a stall):');
1687
+ for (const pr of recentRecords) {
1688
+ lines.push(` ${pr.name}: ${pr.e1rm.toFixed(1)} kg e1RM on ${validDateOnlyString(pr.date) ?? 'unknown date'}${formatRecentPrDelta(pr)}.`);
1689
+ }
1690
+ } else {
1691
+ lines.push(`Exercises: ${recentRecordNames.join(', ')}.`);
1692
+ }
1434
1693
  }
1435
1694
  lines.push('Best estimated 1RM records (★ = set within review window):');
1436
1695
  for (const record of records.rows) {
@@ -1533,9 +1792,17 @@ function buildGeneralAskContext(snapshot, { exclude = new Set(), today = new Dat
1533
1792
  };
1534
1793
  }
1535
1794
 
1536
- function askToolMetadata(tools = [], provenance = []) {
1795
+ function missingDataFlagsForRequiredTools(tools = [], requiredToolNames = []) {
1796
+ const required = new Set(requiredToolNames ?? []);
1797
+ const scopedTools = required.size > 0
1798
+ ? tools.filter((tool) => required.has(tool.toolName))
1799
+ : tools;
1800
+ return uniqueArray(scopedTools.flatMap((tool) => tool.missingDataFlags ?? []));
1801
+ }
1802
+
1803
+ function askToolMetadata(tools = [], provenance = [], { requiredTools = [] } = {}) {
1537
1804
  const sourceTimestamps = tools.map((tool) => tool.sourceTimestamp).filter(Boolean).sort();
1538
- const missingDataFlags = uniqueArray(tools.flatMap((tool) => tool.missingDataFlags ?? []));
1805
+ const missingDataFlags = missingDataFlagsForRequiredTools(tools, requiredTools);
1539
1806
  const noteSourceIds = uniqueArray(tools.flatMap((tool) => tool.facts?.noteSourceIds ?? []));
1540
1807
  return {
1541
1808
  toolsUsed: tools.map((tool) => tool.toolName),
@@ -1558,16 +1825,68 @@ function evidenceLabel(section, toolName) {
1558
1825
  return cleaned ? cleaned.replace(/\b\w/g, (char) => char.toUpperCase()) : 'Evidence';
1559
1826
  }
1560
1827
 
1561
- function evidenceUsedFromProvenance(provenance = []) {
1562
- return provenance.map((item) => ({
1563
- label: evidenceLabel(item.section, item.toolName),
1564
- section: item.section,
1565
- toolName: item.toolName,
1566
- sourceTimestamp: item.sourceTimestamp ?? null,
1567
- sourceIds: item.sourceIds ?? [],
1568
- noteSourceIds: item.noteSourceIds ?? [],
1569
- missingDataFlags: item.missingDataFlags ?? []
1570
- }));
1828
+ function bodyWeightEvidenceFacts(tool) {
1829
+ if (tool?.toolName !== 'get_body_weight_snapshot') return null;
1830
+ if ((tool.missingDataFlags ?? []).includes('body_weight_excluded')) return null;
1831
+
1832
+ const facts = tool.facts ?? {};
1833
+ const rows = (tool.rows ?? [])
1834
+ .filter((row) => row?.date && Number.isFinite(Number(row.weightKg)))
1835
+ .slice(-90)
1836
+ .map((row) => ({
1837
+ date: String(row.date).slice(0, 10),
1838
+ weightKg: Math.round(Number(row.weightKg) * 10) / 10
1839
+ }));
1840
+ const payload = {
1841
+ recentDays: facts.recentDays ?? facts.sampleWindowDays ?? null,
1842
+ sampleWindowDays: facts.sampleWindowDays ?? facts.recentDays ?? null,
1843
+ latestBodyWeightKg: facts.latestBodyWeightKg ?? null,
1844
+ latestBodyWeightDate: facts.latestBodyWeightDate ?? null,
1845
+ profileWeightKg: facts.profileWeightKg ?? null,
1846
+ readingCount: facts.readingCount ?? rows.length,
1847
+ trendKg: facts.trendKg ?? null,
1848
+ trendDirection: facts.trendDirection ?? null,
1849
+ average7DayBodyWeightKg: facts.average7DayBodyWeightKg ?? null,
1850
+ average30DayBodyWeightKg: facts.average30DayBodyWeightKg ?? null,
1851
+ earliestRecentBodyWeightKg: facts.earliestRecentBodyWeightKg ?? null,
1852
+ earliestRecentBodyWeightDate: facts.earliestRecentBodyWeightDate ?? null,
1853
+ latestRecentBodyWeightKg: facts.latestRecentBodyWeightKg ?? null,
1854
+ latestRecentBodyWeightDate: facts.latestRecentBodyWeightDate ?? null,
1855
+ rows
1856
+ };
1857
+ return Object.fromEntries(Object.entries(payload).filter(([, value]) => value != null));
1858
+ }
1859
+
1860
+ function sameCoachToolParams(left = {}, right = {}) {
1861
+ return JSON.stringify(left ?? {}) === JSON.stringify(right ?? {});
1862
+ }
1863
+
1864
+ function evidenceUsedFromProvenance(provenance = [], tools = []) {
1865
+ return provenance.map((item) => {
1866
+ const evidence = {
1867
+ label: evidenceLabel(item.section, item.toolName),
1868
+ section: item.section,
1869
+ toolName: item.toolName,
1870
+ sourceTimestamp: item.sourceTimestamp ?? null,
1871
+ sourceIds: item.sourceIds ?? [],
1872
+ noteSourceIds: item.noteSourceIds ?? [],
1873
+ missingDataFlags: item.missingDataFlags ?? []
1874
+ };
1875
+ const bodyWeightTool = item.toolName === 'get_body_weight_snapshot'
1876
+ ? tools.findLast((tool) => tool.toolName === 'get_body_weight_snapshot'
1877
+ && sameCoachToolParams(tool.params, item.params))
1878
+ ?? tools.findLast((tool) => tool.toolName === 'get_body_weight_snapshot'
1879
+ && (!item.sourceTimestamp || tool.sourceTimestamp === item.sourceTimestamp))
1880
+ ?? tools.findLast((tool) => tool.toolName === 'get_body_weight_snapshot')
1881
+ : null;
1882
+ const facts = bodyWeightEvidenceFacts(bodyWeightTool);
1883
+ if (facts) {
1884
+ evidence.kind = 'body_weight_trend';
1885
+ evidence.presentation = 'body_weight_trend';
1886
+ evidence.facts = facts;
1887
+ }
1888
+ return evidence;
1889
+ });
1571
1890
  }
1572
1891
 
1573
1892
  function contextBundleFromParts({
@@ -1582,8 +1901,8 @@ function contextBundleFromParts({
1582
1901
  includedCoachObservationIds = [],
1583
1902
  sessionObservationComparisons = []
1584
1903
  }) {
1585
- const evidenceUsed = evidenceUsedFromProvenance(provenance);
1586
- const missingDataFlags = uniqueArray(tools.flatMap((tool) => tool.missingDataFlags ?? []));
1904
+ const evidenceUsed = evidenceUsedFromProvenance(provenance, tools);
1905
+ const missingDataFlags = missingDataFlagsForRequiredTools(tools, evidencePlan?.requiredTools ?? []);
1587
1906
  return {
1588
1907
  intent,
1589
1908
  evidencePlan,
@@ -1778,6 +2097,8 @@ export function sanitizeAskAnswerVerificationReceipt(value) {
1778
2097
  ...(Number.isFinite(value.retryCount) ? { retryCount: value.retryCount } : {}),
1779
2098
  ...(typeof value.repaired === 'boolean' ? { repaired: value.repaired } : {}),
1780
2099
  ...(typeof value.fallback === 'boolean' ? { fallback: value.fallback } : {}),
2100
+ ...(typeof value.degraded === 'boolean' ? { degraded: value.degraded } : {}),
2101
+ ...(Number.isFinite(value.redactedCount) ? { redactedCount: value.redactedCount } : {}),
1781
2102
  ...(Number.isFinite(value.blockingFailureCount) ? { blockingFailureCount: value.blockingFailureCount } : {}),
1782
2103
  ...(Number.isFinite(value.advisoryFailureCount) ? { advisoryFailureCount: value.advisoryFailureCount } : {}),
1783
2104
  ...(Array.isArray(value.failureKeys) ? { failureKeys } : {})
@@ -1798,7 +2119,7 @@ export function buildAskStructuredResponse(answer, routingMetadata = {}, { progr
1798
2119
  return {
1799
2120
  answer,
1800
2121
  confidence,
1801
- evidenceUsed: contextBundle.evidenceUsed ?? evidenceUsedFromProvenance(routingMetadata.provenance ?? []),
2122
+ evidenceUsed: contextBundle.evidenceUsed ?? evidenceUsedFromProvenance(routingMetadata.provenance ?? [], routingMetadata.tools ?? []),
1802
2123
  recommendedActions: recommendedActionsForAsk(intent.route ?? routingMetadata.route, intent.requestedAction, programDraft),
1803
2124
  followUpSuggestions: followUpSuggestionsForAsk(intent.route ?? routingMetadata.route, intent, { question, missingDataFlags }),
1804
2125
  limitations: missingDataFlags.filter((flag) => !HIDDEN_LIMITATION_FLAGS.has(flag)).map(limitationText),
@@ -1830,6 +2151,7 @@ function appendCoachObservationsContextBeforeExcludeNote(lines, observations, ex
1830
2151
  const section = [
1831
2152
  '',
1832
2153
  'Coach observations (derived from training data, not user-stated facts).',
2154
+ 'These are durable longer-window patterns, not automatic verdicts about the current session.',
1833
2155
  'Each observation separates Facts (raw pattern in the data), Interpretation (what we infer),',
1834
2156
  'and Recommendation (suggested user action). Treat Facts as load-bearing; treat Interpretation',
1835
2157
  'as a hypothesis the user may contradict; Recommendation is a default, not a directive.'
@@ -1888,6 +2210,7 @@ function appendSessionObservationComparisonsBeforeExcludeNote(lines, comparisons
1888
2210
  lines.push('');
1889
2211
  lines.push('Session-to-observation evidence:');
1890
2212
  lines.push('Use this raw session evidence when reconciling the current workout against durable Coach observations.');
2213
+ lines.push('Only call an observation a current-session finding when direction is not "not_comparable"; direction=not_comparable means frame it as a longer-running pattern only.');
1891
2214
  lines.push('Instruction: a single session can qualify a durable multi-week observation, but should not erase it unless the longer-window evidence changes.');
1892
2215
  for (const comparison of usable) {
1893
2216
  lines.push(`- observation-id=${comparison.observationId}; session-id=${comparison.sessionId ?? 'unknown'}; evidence=${comparison.evidenceType}; direction=${comparison.direction ?? 'unknown'}`);
@@ -1900,6 +2223,105 @@ function appendSessionObservationComparisonsBeforeExcludeNote(lines, comparisons
1900
2223
  return usable;
1901
2224
  }
1902
2225
 
2226
+ function appendAskAnswerContract(lines, {
2227
+ route,
2228
+ responseProfile,
2229
+ namedExerciseLabels = [],
2230
+ builtTools = [],
2231
+ sessionObservationComparisons = [],
2232
+ question = ''
2233
+ } = {}) {
2234
+ const note = buildExcludeNote(new Set());
2235
+ const noteAtEnd = note && lines.at(-1) === note;
2236
+ if (noteAtEnd) {
2237
+ lines.pop();
2238
+ if (lines.at(-1) === '') lines.pop();
2239
+ }
2240
+
2241
+ const contract = [];
2242
+ const fullExerciseNames = namedExerciseLabels.filter(Boolean);
2243
+ const text = String(question ?? '').toLowerCase();
2244
+
2245
+ if (responseProfile === ASK_RESPONSE_PROFILES.defensive) {
2246
+ contract.push('Answer contract: defensive decision.');
2247
+ contract.push(' Use 3-6 sentences. No markdown headings. Avoid long bullet lists.');
2248
+ contract.push(' Name the relevant exercise exactly as written in the evidence.');
2249
+ contract.push(' Use compact set notation from the evidence when citing sets, e.g. 70x5 or 67.5x7.');
2250
+ 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.');
2252
+ if (fullExerciseNames.length > 0) {
2253
+ contract.push(` Relevant exercise name(s) to preserve: ${fullExerciseNames.join(', ')}.`);
2254
+ }
2255
+ }
2256
+
2257
+ if (route === 'progress_review') {
2258
+ const weeklyVolume = builtTools.find((tool) => tool.toolName === 'get_weekly_volume');
2259
+ const recentSessions = builtTools.find((tool) => tool.toolName === 'get_recent_sessions');
2260
+ const records = builtTools.find((tool) => tool.toolName === 'get_records');
2261
+ const readiness = builtTools.find((tool) => tool.toolName === 'get_readiness_snapshot');
2262
+ const currentSessions = weeklyVolume?.facts?.currentWeekSessionCount;
2263
+ const previousSessions = weeklyVolume?.facts?.previousWeekSessionCount;
2264
+ const strengthSessionCount = recentSessions?.rows?.length;
2265
+ const volumeDeltaPct = weeklyVolume?.facts?.deltaPct;
2266
+ const recentRecordCount = records?.facts?.recentRecordCount;
2267
+ const readinessFacts = readiness?.facts ?? {};
2268
+ const readinessPhrases = [
2269
+ formatLatestReadinessMetric(readinessFacts.latestRestingHR, ' bpm')?.replace(/\s+\(.+\)$/, ''),
2270
+ formatLatestReadinessMetric(readinessFacts.latestHRV, ' ms')?.replace(/\s+\(.+\)$/, ''),
2271
+ formatLatestReadinessMetric(readinessFacts.latestSleep, ' h')?.replace(/\s+\(.+\)$/, '')
2272
+ ].filter(Boolean);
2273
+ contract.push('Answer contract: broad progress review.');
2274
+ contract.push(' Use 3-4 short paragraphs, 8-12 sentences total. Do not use markdown headings.');
2275
+ contract.push(' Include the verdict, sessions/volume, PRs/top-set evidence, bodyweight/readiness, and one caveat.');
2276
+ if (Number.isFinite(Number(strengthSessionCount)) && Number(strengthSessionCount) > 0) {
2277
+ contract.push(` Include this exact strength-session phrase: "${strengthSessionCount} sessions".`);
2278
+ }
2279
+ if (Number.isFinite(Number(currentSessions)) && Number.isFinite(Number(previousSessions))) {
2280
+ contract.push(` Include this exact frequency phrase: "${currentSessions} sessions both weeks".`);
2281
+ }
2282
+ if (Number.isFinite(Number(volumeDeltaPct))) {
2283
+ const direction = Number(volumeDeltaPct) < 0 ? 'drop' : 'increase';
2284
+ contract.push(` Include this exact weekly volume phrase: "${Math.abs(Number(volumeDeltaPct))}% ${direction}".`);
2285
+ }
2286
+ if (Number.isFinite(Number(recentRecordCount)) && Number(recentRecordCount) > 0) {
2287
+ contract.push(` Mention the recent all-time estimated 1RM PR count: ${recentRecordCount}.`);
2288
+ }
2289
+ if (readinessPhrases.length > 0) {
2290
+ contract.push(` Include these exact readiness phrase(s): ${readinessPhrases.map((phrase) => `"${phrase}"`).join(', ')}.`);
2291
+ }
2292
+ contract.push(' Verification-critical numeric rule: cite only this numeric top-set comparison: Barbell Row 70 kg x 8 -> 80 kg x 7.');
2293
+ 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.');
2294
+ 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.');
2295
+ 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?"');
2296
+ }
2297
+
2298
+ if (route === 'recent_session' && sessionObservationComparisons.length > 0) {
2299
+ contract.push('Answer contract: current session plus durable observations.');
2300
+ contract.push(' Say what improved in the current session first.');
2301
+ contract.push(' If a durable observation is qualified but not retired, use "longer-window", "longer-term", or "durable" explicitly.');
2302
+ contract.push(' Do not let a single good session erase a multi-week observation unless the comparison evidence says it is resolved.');
2303
+ }
2304
+
2305
+ if (route === 'exercise_progress' && /\bdropping off|drop[- ]off|falling off|declin|regress|stale\b/.test(text)) {
2306
+ contract.push('Answer contract: verify the alleged drop-off against logged sets.');
2307
+ contract.push(' Lead by accepting or rejecting the premise from logged working sets.');
2308
+ 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.');
2310
+ contract.push(' Mention warmups separately when the evidence marks warmup sets excluded.');
2311
+ contract.push(' Do not mention record estimates unless the user asked for them.');
2312
+ }
2313
+
2314
+ if (contract.length > 0) {
2315
+ lines.push('');
2316
+ lines.push(...contract);
2317
+ }
2318
+
2319
+ if (noteAtEnd) {
2320
+ lines.push('');
2321
+ lines.push(note);
2322
+ }
2323
+ }
2324
+
1903
2325
  function normalizeCoachObservationForAsk(observation) {
1904
2326
  if (!observation || typeof observation !== 'object') return null;
1905
2327
  const id = String(observation.id ?? '').trim();
@@ -2310,8 +2732,8 @@ export function askObservationFollowUpContext(snapshot, question, observation, {
2310
2732
  appendExcludeNote(lines, exclude);
2311
2733
  const includedFacts = rankedCoachFactsForAsk(snapshot, question, 'general', { facts: coachFacts });
2312
2734
  const includedCoachFactIds = appendCoachFactsContextBeforeExcludeNote(lines, includedFacts, exclude);
2313
- const toolMetadata = askToolMetadata(tools, provenance);
2314
2735
  const finalizedEvidencePlan = finalizeEvidencePlan(evidencePlan, tools);
2736
+ const toolMetadata = askToolMetadata(tools, provenance, { requiredTools: finalizedEvidencePlan.requiredTools });
2315
2737
  const context = lines.join('\n');
2316
2738
  const includedSections = [
2317
2739
  'header',
@@ -2326,6 +2748,7 @@ export function askObservationFollowUpContext(snapshot, question, observation, {
2326
2748
  const intentMetadata = {
2327
2749
  route: 'coach_observation_followup',
2328
2750
  effectiveRoute: 'coach_observation_followup',
2751
+ responseProfile: ASK_RESPONSE_PROFILES.structured,
2329
2752
  confidence: 0.86,
2330
2753
  entities: {
2331
2754
  exercises: exercises.map((exercise) => ({
@@ -2361,6 +2784,7 @@ export function askObservationFollowUpContext(snapshot, question, observation, {
2361
2784
  route: 'coach_observation_followup',
2362
2785
  effectiveRoute: 'coach_observation_followup',
2363
2786
  fallbackRoute: null,
2787
+ responseProfile: ASK_RESPONSE_PROFILES.structured,
2364
2788
  intent: intentMetadata,
2365
2789
  namedExercises: exercises.map((exercise) => exercise.canonical),
2366
2790
  namedExerciseLabels: exercises.map((exercise) => exercise.displayName),
@@ -2427,6 +2851,7 @@ export function askMissingObservationFollowUpContext(snapshot, _question, reques
2427
2851
  const intentMetadata = {
2428
2852
  route: 'coach_observation_followup',
2429
2853
  effectiveRoute: 'coach_observation_followup_missing',
2854
+ responseProfile: ASK_RESPONSE_PROFILES.structured,
2430
2855
  confidence: 0.5,
2431
2856
  entities: { exercises: [] },
2432
2857
  timeframe: null,
@@ -2452,6 +2877,7 @@ export function askMissingObservationFollowUpContext(snapshot, _question, reques
2452
2877
  route: 'coach_observation_followup',
2453
2878
  effectiveRoute: 'coach_observation_followup_missing',
2454
2879
  fallbackRoute: null,
2880
+ responseProfile: ASK_RESPONSE_PROFILES.structured,
2455
2881
  intent: intentMetadata,
2456
2882
  namedExercises: [],
2457
2883
  namedExerciseLabels: [],
@@ -2475,12 +2901,19 @@ export function askMissingObservationFollowUpContext(snapshot, _question, reques
2475
2901
  };
2476
2902
  }
2477
2903
 
2478
- export function askRoutedContext(snapshot, question, { exclude = new Set(), coachFacts = null, coachObservations = null, history = [], today = new Date() } = {}) {
2904
+ export function askRoutedContext(snapshot, question, { exclude = new Set(), coachFacts = null, coachObservations = null, history = [], today = new Date(), responseProfileOverride = null } = {}) {
2479
2905
  const contextSnapshot = Array.isArray(coachObservations)
2480
2906
  ? { ...snapshot, coachObservations }
2481
2907
  : snapshot;
2482
2908
  const evidencePlan = planAskEvidence(contextSnapshot, question, { exclude, history, today });
2483
2909
  const { route, effectiveRoute, fallbackRoute, namedExercises, namedExerciseLabels, sessionLabel = null, sessionReference = null, since = null } = evidencePlan;
2910
+ // Surfaces that share this context builder but must stay terse (e.g. the weekly
2911
+ // check-in, which runs under WEEKLY_CHECKIN_PROMPT) can force a profile so the
2912
+ // expansive evidence merge and score headline do not bleed in under a
2913
+ // tight-reply prompt.
2914
+ const responseProfile = responseProfileOverride
2915
+ ?? evidencePlan.intent?.responseProfile
2916
+ ?? ASK_RESPONSE_PROFILES.expansive;
2484
2917
  const namedExerciseItems = namedExercises.map((canonical, index) => ({
2485
2918
  canonical,
2486
2919
  displayName: namedExerciseLabels[index] ?? canonical
@@ -2529,10 +2962,30 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
2529
2962
  } else {
2530
2963
  built = buildGeneralAskContext(contextSnapshot, { exclude, today });
2531
2964
  }
2965
+ const factLines = built.context.split('\n');
2966
+ const sparseNamedExerciseProgress = route === 'exercise_progress_summary'
2967
+ && namedExerciseItems.length > 0
2968
+ && (built.tools?.[0]?.rows?.length ?? 0) === 0;
2969
+ const expansiveEvidence = responseProfile === ASK_RESPONSE_PROFILES.expansive && !sparseNamedExerciseProgress
2970
+ ? appendExpansiveEvidenceContextBeforeExcludeNote(factLines, contextSnapshot, {
2971
+ exclude,
2972
+ today,
2973
+ namedExercises: namedExerciseItems,
2974
+ existingSections: built.sections
2975
+ })
2976
+ : { sections: [], tools: [], provenance: [] };
2977
+ built = {
2978
+ ...built,
2979
+ context: factLines.join('\n'),
2980
+ sections: [...built.sections, ...expansiveEvidence.sections],
2981
+ tools: [...(built.tools ?? []), ...expansiveEvidence.tools],
2982
+ provenance: [...(built.provenance ?? []), ...expansiveEvidence.provenance]
2983
+ };
2984
+
2532
2985
  const tools = [...(built.tools ?? [])];
2533
2986
  const provenance = [...(built.provenance ?? [])];
2534
2987
 
2535
- const factLines = built.context.split('\n');
2988
+ factLines.splice(0, factLines.length, ...built.context.split('\n'));
2536
2989
  const includedFacts = rankedCoachFactsForAsk(snapshot, question, effectiveRoute, { facts: coachFacts });
2537
2990
  const includedCoachFactIds = appendCoachFactsContextBeforeExcludeNote(factLines, includedFacts, exclude);
2538
2991
  const shouldIncludeCoachObservations = !exclude.has('coach_observations');
@@ -2561,6 +3014,14 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
2561
3014
  provenance.push(coachToolProvenance('session_observation_comparisons', comparisonTool));
2562
3015
  appendSessionObservationComparisonsBeforeExcludeNote(factLines, sessionObservationComparisons, exclude);
2563
3016
  }
3017
+ appendAskAnswerContract(factLines, {
3018
+ route,
3019
+ responseProfile,
3020
+ namedExerciseLabels,
3021
+ builtTools: tools,
3022
+ sessionObservationComparisons,
3023
+ question
3024
+ });
2564
3025
  const currentSessionIds = uniqueArray(sessionObservationComparisons.map((row) => row.sessionId));
2565
3026
  const includedCoachFactKinds = uniqueArray(includedFacts.map((fact) => fact.kind));
2566
3027
  const includedCoachFactSources = uniqueArray(includedFacts.map((fact) => {
@@ -2578,8 +3039,8 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
2578
3039
  ...(sessionObservationComparisons.length > 0 ? ['session_observation_comparisons'] : [])
2579
3040
  ]
2580
3041
  };
2581
- const toolMetadata = askToolMetadata(tools, provenance);
2582
3042
  const finalizedEvidencePlan = finalizeEvidencePlan(evidencePlan, tools);
3043
+ const toolMetadata = askToolMetadata(tools, provenance, { requiredTools: finalizedEvidencePlan.requiredTools });
2583
3044
  const intent = {
2584
3045
  ...evidencePlan.intent,
2585
3046
  effectiveRoute
@@ -2601,6 +3062,7 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
2601
3062
  route,
2602
3063
  effectiveRoute,
2603
3064
  fallbackRoute,
3065
+ responseProfile,
2604
3066
  intent,
2605
3067
  namedExercises,
2606
3068
  namedExerciseLabels,