incremnt 0.6.0 → 0.6.1
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/README.md +0 -3
- package/package.json +1 -1
- package/src/auth.js +5 -1
- package/src/contract.js +8 -4
- package/src/format.js +1 -1
- package/src/lib.js +9 -1
- package/src/openrouter.js +2 -70
- package/src/queries.js +301 -186
- package/src/remote.js +2 -2
- package/src/summary-evals.js +40 -1
- package/src/sync-service.js +48 -41
package/README.md
CHANGED
|
@@ -59,11 +59,8 @@ incremnt login --session-file ~/Downloads/session.json
|
|
|
59
59
|
| `programs current` | Active program state |
|
|
60
60
|
| `programs list` | All programs |
|
|
61
61
|
| `programs show --id <id>` | Full program detail |
|
|
62
|
-
| `cycles list [--program-id <id>]` | Completed cycle summaries |
|
|
63
|
-
| `cycles show --id <id>` | Details for a completed cycle summary |
|
|
64
62
|
| `exercises history --name <name>` | Set-by-set history for an exercise |
|
|
65
63
|
| `records` | Personal records (best e1RM per exercise) |
|
|
66
|
-
| `goals list` / `goals show --id <id>` | Strength plan goals |
|
|
67
64
|
| `health summary` / `health ai` | Health metrics and AI summary |
|
|
68
65
|
| `training load` | ATL/CTL/TSB and workload context |
|
|
69
66
|
| `ask history` / `ask show --id <id>` | Coach conversation history |
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -275,7 +275,11 @@ async function fetchRemoteContract(baseUrl, token) {
|
|
|
275
275
|
|
|
276
276
|
const payload = await response.json();
|
|
277
277
|
if (payload.contractVersion !== contractVersion) {
|
|
278
|
-
const
|
|
278
|
+
const cliBehind = typeof payload.contractVersion === 'number' && payload.contractVersion > contractVersion;
|
|
279
|
+
const hint = cliBehind
|
|
280
|
+
? ' Your CLI is out of date — run `npm install -g incremnt@latest` to upgrade.'
|
|
281
|
+
: ' The sync service is older than this CLI — wait for the service to redeploy, or use an older CLI version.';
|
|
282
|
+
const error = new Error(`Remote incremnt sync service is incompatible with this CLI. Expected contract v${contractVersion}, got v${payload.contractVersion}.${hint}`);
|
|
279
283
|
error.code = 'REMOTE_CONTRACT_MISMATCH';
|
|
280
284
|
throw error;
|
|
281
285
|
}
|
package/src/contract.js
CHANGED
|
@@ -87,13 +87,15 @@ export const commandSchema = [
|
|
|
87
87
|
{
|
|
88
88
|
command: 'goals list',
|
|
89
89
|
id: 'goals-list',
|
|
90
|
-
description: '
|
|
90
|
+
description: 'Legacy read-only strength plan and lift goal data',
|
|
91
|
+
hidden: true,
|
|
91
92
|
options: []
|
|
92
93
|
},
|
|
93
94
|
{
|
|
94
95
|
command: 'goals show',
|
|
95
96
|
id: 'goals-show',
|
|
96
|
-
description: '
|
|
97
|
+
description: 'Legacy read-only strength plan goal details',
|
|
98
|
+
hidden: true,
|
|
97
99
|
usage: 'goals show --id <plan-id>',
|
|
98
100
|
options: [
|
|
99
101
|
{ name: 'id', type: 'string', required: true, description: 'Plan ID' }
|
|
@@ -102,7 +104,8 @@ export const commandSchema = [
|
|
|
102
104
|
{
|
|
103
105
|
command: 'cycles list',
|
|
104
106
|
id: 'cycle-summary-list',
|
|
105
|
-
description: '
|
|
107
|
+
description: 'Legacy read-only cycle summaries',
|
|
108
|
+
hidden: true,
|
|
106
109
|
options: [
|
|
107
110
|
{ name: 'program-id', type: 'string', required: false, description: 'Filter by program ID' }
|
|
108
111
|
]
|
|
@@ -110,7 +113,8 @@ export const commandSchema = [
|
|
|
110
113
|
{
|
|
111
114
|
command: 'cycles show',
|
|
112
115
|
id: 'cycle-summary-show',
|
|
113
|
-
description: '
|
|
116
|
+
description: 'Legacy read-only cycle summary details',
|
|
117
|
+
hidden: true,
|
|
114
118
|
usage: 'cycles show --id <summary-id>',
|
|
115
119
|
options: [
|
|
116
120
|
{ name: 'id', type: 'string', required: true, description: 'Cycle summary ID' }
|
package/src/format.js
CHANGED
|
@@ -755,7 +755,7 @@ export function formatHelp(opts = {}) {
|
|
|
755
755
|
` incremnt <command> [options]`,
|
|
756
756
|
'',
|
|
757
757
|
header('COMMANDS'),
|
|
758
|
-
...commandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
|
|
758
|
+
...commandSchema.filter((c) => !c.hidden).map((c) => cmd(c.usage ?? c.command, c.description)),
|
|
759
759
|
'',
|
|
760
760
|
header('WRITE COMMANDS'),
|
|
761
761
|
...writeCommandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
|
package/src/lib.js
CHANGED
|
@@ -73,7 +73,15 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
73
73
|
})[command] ?? command;
|
|
74
74
|
|
|
75
75
|
const sessionState = await readSessionState();
|
|
76
|
-
const
|
|
76
|
+
const hasTransport = Boolean(
|
|
77
|
+
sessionState?.session?.transport?.baseUrl
|
|
78
|
+
|| sessionState?.session?.transport?.fixturePath
|
|
79
|
+
);
|
|
80
|
+
const isAuthenticated = Boolean(
|
|
81
|
+
sessionState?.session
|
|
82
|
+
&& !isSessionExpired(sessionState.session)
|
|
83
|
+
&& hasTransport
|
|
84
|
+
);
|
|
77
85
|
|
|
78
86
|
if (!command || options.help) {
|
|
79
87
|
await printLogo(stdout);
|
package/src/openrouter.js
CHANGED
|
@@ -70,8 +70,7 @@ function exerciseNamesFromContext(source) {
|
|
|
70
70
|
source.exercises?.map((exercise) => exercise.exerciseName ?? exercise.name),
|
|
71
71
|
source.sessions?.flatMap((session) => session.exercises?.map((exercise) => exercise.exerciseName ?? exercise.name) ?? []),
|
|
72
72
|
source.prsThisWeek?.map((pr) => pr.exerciseName),
|
|
73
|
-
source.stalledExercises?.map((exercise) => exercise.exerciseName)
|
|
74
|
-
source.goalProgress?.map((goal) => goal.exerciseName)
|
|
73
|
+
source.stalledExercises?.map((exercise) => exercise.exerciseName)
|
|
75
74
|
]);
|
|
76
75
|
}
|
|
77
76
|
|
|
@@ -108,8 +107,7 @@ function includedSectionsForSurface(surface, source) {
|
|
|
108
107
|
return [
|
|
109
108
|
'week',
|
|
110
109
|
hasItems(source.prsThisWeek) ? 'prs' : null,
|
|
111
|
-
hasItems(source.stalledExercises) ? 'stalled_exercises' : null
|
|
112
|
-
hasItems(source.goalProgress) ? 'goal_progress' : null
|
|
110
|
+
hasItems(source.stalledExercises) ? 'stalled_exercises' : null
|
|
113
111
|
].filter(Boolean);
|
|
114
112
|
default:
|
|
115
113
|
return [];
|
|
@@ -713,12 +711,6 @@ export function formatCycleContext(ctx) {
|
|
|
713
711
|
`Program: ${ctx.programName}, Week ${ctx.cycleNumber}${intentLabel}, ${ctx.totalSessions} session(s).`
|
|
714
712
|
];
|
|
715
713
|
|
|
716
|
-
const phaseLines = formatProgramPhaseContext(ctx.programPhase);
|
|
717
|
-
if (phaseLines.length > 0) {
|
|
718
|
-
lines.push('');
|
|
719
|
-
lines.push(...phaseLines);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
714
|
if (ctx.prioritySignals?.length > 0) {
|
|
723
715
|
lines.push('');
|
|
724
716
|
lines.push('Priority signals (ranked):');
|
|
@@ -798,14 +790,6 @@ export function formatCycleContext(ctx) {
|
|
|
798
790
|
}
|
|
799
791
|
}
|
|
800
792
|
|
|
801
|
-
if (ctx.goalProgress) {
|
|
802
|
-
lines.push('');
|
|
803
|
-
lines.push('Goal progress:');
|
|
804
|
-
for (const g of ctx.goalProgress) {
|
|
805
|
-
lines.push(` ${g.exerciseName}: ${g.progressPercent ?? '?'}% toward ${g.targetE1RM} e1RM`);
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
793
|
if (ctx.previousCycles?.length > 0) {
|
|
810
794
|
lines.push('');
|
|
811
795
|
lines.push('Previous cycles:');
|
|
@@ -963,10 +947,6 @@ export function formatWorkoutContext(ctx) {
|
|
|
963
947
|
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
|
964
948
|
lines.push(`Completed: ${dayNames[d.getUTCDay()]}, ${timeOfDay}.`);
|
|
965
949
|
}
|
|
966
|
-
if (ctx.programWeekNumber) {
|
|
967
|
-
const phase = ctx.programProgressionType ? ` (${ctx.programProgressionType})` : '';
|
|
968
|
-
lines.push(`Program week: ${ctx.programWeekNumber}${phase}.`);
|
|
969
|
-
}
|
|
970
950
|
if (ctx.sessionsThisWeek) {
|
|
971
951
|
lines.push(`Sessions this week: ${ctx.sessionsThisWeek}.`);
|
|
972
952
|
}
|
|
@@ -1201,12 +1181,6 @@ export function formatCheckpointContext(ctx) {
|
|
|
1201
1181
|
`Program: ${ctx.programName}, Checkpoint at week ${ctx.checkpointWeek} of ${ctx.totalWeeks}.`
|
|
1202
1182
|
];
|
|
1203
1183
|
|
|
1204
|
-
const phaseLines = formatProgramPhaseContext(ctx.programPhase);
|
|
1205
|
-
if (phaseLines.length > 0) {
|
|
1206
|
-
lines.push('');
|
|
1207
|
-
lines.push(...phaseLines);
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
1184
|
lines.push('');
|
|
1211
1185
|
lines.push('Exercise targets:');
|
|
1212
1186
|
for (const ex of ctx.exercises) {
|
|
@@ -1419,10 +1393,6 @@ function formatWeeklyCheckinContext(context) {
|
|
|
1419
1393
|
const lines = [];
|
|
1420
1394
|
lines.push(`Today: ${context.todayIso}`);
|
|
1421
1395
|
lines.push(`Week range: ${context.weekRangeIso?.start} to ${context.weekRangeIso?.end}`);
|
|
1422
|
-
const phaseLines = formatProgramPhaseContext(context.programPhase);
|
|
1423
|
-
if (phaseLines.length > 0) {
|
|
1424
|
-
lines.push(...phaseLines);
|
|
1425
|
-
}
|
|
1426
1396
|
lines.push(`Sessions this week: ${context.sessionCount}`);
|
|
1427
1397
|
if (context.adherencePct != null) {
|
|
1428
1398
|
lines.push(`Adherence: ${context.completedSets}/${context.plannedSets} sets (${context.adherencePct}%)`);
|
|
@@ -1446,47 +1416,9 @@ function formatWeeklyCheckinContext(context) {
|
|
|
1446
1416
|
const sign = context.bodyweightDeltaKg >= 0 ? '+' : '';
|
|
1447
1417
|
lines.push(`Bodyweight 7d delta: ${sign}${context.bodyweightDeltaKg}kg`);
|
|
1448
1418
|
}
|
|
1449
|
-
if (Array.isArray(context.goalProgress) && context.goalProgress.length > 0) {
|
|
1450
|
-
lines.push('Goal progress:');
|
|
1451
|
-
for (const g of context.goalProgress) {
|
|
1452
|
-
const deadline = g.finishDate ? ` (finish ${g.finishDate})` : '';
|
|
1453
|
-
lines.push(` - ${g.exerciseName}: ${g.progressPercent}% toward ${g.targetE1RM}kg${deadline}`);
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
1419
|
return lines.join('\n');
|
|
1457
1420
|
}
|
|
1458
1421
|
|
|
1459
|
-
export function formatProgramPhaseContext(programPhase) {
|
|
1460
|
-
if (!programPhase || typeof programPhase !== 'object') return [];
|
|
1461
|
-
const current = programPhase.current;
|
|
1462
|
-
if (!current?.phase || typeof current.displayWeek !== 'number') return [];
|
|
1463
|
-
|
|
1464
|
-
const describe = (phase) => {
|
|
1465
|
-
if (!phase?.phase) return null;
|
|
1466
|
-
const week = typeof phase.displayWeek === 'number' ? `week ${phase.displayWeek}` : 'week ?';
|
|
1467
|
-
return `${week} ${phase.phase}${phase.isDeload ? ' (deload)' : ''}`;
|
|
1468
|
-
};
|
|
1469
|
-
const describeList = (phases) => {
|
|
1470
|
-
if (!Array.isArray(phases) || phases.length === 0) return null;
|
|
1471
|
-
return phases.map(describe).filter(Boolean).join(', ');
|
|
1472
|
-
};
|
|
1473
|
-
|
|
1474
|
-
const lines = ['Program phase:'];
|
|
1475
|
-
lines.push(` Current: ${describe(current)}`);
|
|
1476
|
-
const previous = describe(programPhase.previousWeek);
|
|
1477
|
-
if (previous) lines.push(` Previous: ${previous}`);
|
|
1478
|
-
const next = describe(programPhase.nextWeek);
|
|
1479
|
-
if (next) lines.push(` Next: ${next}`);
|
|
1480
|
-
if (programPhase.isPostDeloadReturn === true) {
|
|
1481
|
-
lines.push(' Post-deload return: yes');
|
|
1482
|
-
}
|
|
1483
|
-
const range = describeList(programPhase.phasesInRange);
|
|
1484
|
-
if (range) lines.push(` Range phases: ${range}`);
|
|
1485
|
-
const previousRange = describeList(programPhase.previousRangePhases);
|
|
1486
|
-
if (previousRange) lines.push(` Previous range phases: ${previousRange}`);
|
|
1487
|
-
return lines;
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
1422
|
export async function generateWeeklyCheckinRecap(context, { apiKey, model, timeoutMs, priorCommitment, user, sessionId, contextMetadata } = {}) {
|
|
1491
1423
|
const contextText = formatWeeklyCheckinContext(context);
|
|
1492
1424
|
const userLines = [fenceContent('training_data', contextText)];
|
package/src/queries.js
CHANGED
|
@@ -1,12 +1,148 @@
|
|
|
1
1
|
import { coachFactPolicyViolation } from './coach-facts.js';
|
|
2
2
|
import { exerciseAliasMapping } from './exercise-aliases.js';
|
|
3
|
-
import {
|
|
3
|
+
import { resolveProgramPhase } from './program-phase-resolver.js';
|
|
4
4
|
import { enrichScoreSnapshots } from './score-context.js';
|
|
5
5
|
|
|
6
6
|
function completionDateForSession(session) {
|
|
7
7
|
return session.completedAt ?? session.summary?.date ?? session.date;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
const WEEKDAY_NAMES = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
11
|
+
|
|
12
|
+
function isoWeekdayOf(date) {
|
|
13
|
+
const jsDay = date.getDay();
|
|
14
|
+
return jsDay === 0 ? 7 : jsDay;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function dateOnly(date) {
|
|
18
|
+
return localDateString(date);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function shiftDate(date, days) {
|
|
22
|
+
const d = new Date(date);
|
|
23
|
+
d.setDate(d.getDate() + days);
|
|
24
|
+
return d;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Mirror of iOS DashboardSchedulingLogic for AI prompt builders.
|
|
29
|
+
* Resolves the program's schedule position into one of:
|
|
30
|
+
* - 'catchUp' — currentDayIndex's scheduled weekday already passed this week
|
|
31
|
+
* and that index isn't in completedDayIndices.
|
|
32
|
+
* - 'today' — currentDayIndex's scheduled weekday is today.
|
|
33
|
+
* - 'upcoming' — scheduled later this week or next.
|
|
34
|
+
*
|
|
35
|
+
* Without this, the naive `(trainingWeekdays[currentDayIndex] - todayIso + 7) % 7`
|
|
36
|
+
* surfaces a missed Friday session as "Friday (in 6 days)" the morning after.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveProgramSchedule(program, now = new Date()) {
|
|
39
|
+
if (!program) return null;
|
|
40
|
+
const trainingWeekdays = program.trainingWeekdays ?? [];
|
|
41
|
+
const days = program.days ?? [];
|
|
42
|
+
const dayCount = days.length;
|
|
43
|
+
if (dayCount === 0) return null;
|
|
44
|
+
|
|
45
|
+
const currentDayIndex = program.currentDayIndex ?? 0;
|
|
46
|
+
if (!Number.isInteger(currentDayIndex) || currentDayIndex < 0 || currentDayIndex >= dayCount) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const scheduledWeekday = trainingWeekdays[currentDayIndex];
|
|
50
|
+
if (scheduledWeekday == null) return null;
|
|
51
|
+
|
|
52
|
+
const todayIso = isoWeekdayOf(now);
|
|
53
|
+
const completed = new Set(program.completedDayIndices ?? []);
|
|
54
|
+
const dayTitle = days[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
|
|
55
|
+
|
|
56
|
+
// Mirror DashboardSchedulingLogic.shouldShowCatchUpWorkout: cycle has started
|
|
57
|
+
// (some completions exist) and currentDayIndex itself isn't completed.
|
|
58
|
+
const cycleStarted = completed.size > 0;
|
|
59
|
+
const currentIncomplete = !completed.has(currentDayIndex);
|
|
60
|
+
const nextScheduledSession = trainingWeekdays
|
|
61
|
+
.map((weekday, dayIndex) => ({ weekday, dayIndex }))
|
|
62
|
+
.filter(({ weekday, dayIndex }) => (
|
|
63
|
+
dayIndex < dayCount
|
|
64
|
+
&& weekday > todayIso
|
|
65
|
+
&& !completed.has(dayIndex)
|
|
66
|
+
))
|
|
67
|
+
.sort((a, b) => a.weekday - b.weekday)[0] ?? null;
|
|
68
|
+
|
|
69
|
+
if (
|
|
70
|
+
scheduledWeekday < todayIso
|
|
71
|
+
&& cycleStarted
|
|
72
|
+
&& currentIncomplete
|
|
73
|
+
) {
|
|
74
|
+
const daysAgo = todayIso - scheduledWeekday;
|
|
75
|
+
const resolved = {
|
|
76
|
+
state: 'catchUp',
|
|
77
|
+
dayIndex: currentDayIndex,
|
|
78
|
+
dayTitle,
|
|
79
|
+
scheduledWeekday,
|
|
80
|
+
scheduledWeekdayName: WEEKDAY_NAMES[scheduledWeekday],
|
|
81
|
+
scheduledOn: dateOnly(shiftDate(now, -daysAgo)),
|
|
82
|
+
daysAgo
|
|
83
|
+
};
|
|
84
|
+
if (nextScheduledSession) {
|
|
85
|
+
const daysUntil = nextScheduledSession.weekday - todayIso;
|
|
86
|
+
resolved.nextScheduledSession = {
|
|
87
|
+
state: 'upcoming',
|
|
88
|
+
dayIndex: nextScheduledSession.dayIndex,
|
|
89
|
+
dayTitle: days[nextScheduledSession.dayIndex]?.title ?? `Day ${nextScheduledSession.dayIndex + 1}`,
|
|
90
|
+
scheduledWeekday: nextScheduledSession.weekday,
|
|
91
|
+
scheduledWeekdayName: WEEKDAY_NAMES[nextScheduledSession.weekday],
|
|
92
|
+
scheduledOn: dateOnly(shiftDate(now, daysUntil)),
|
|
93
|
+
daysUntil
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let daysUntil = scheduledWeekday - todayIso;
|
|
100
|
+
if (daysUntil < 0) daysUntil += 7;
|
|
101
|
+
const state = daysUntil === 0 ? 'today' : 'upcoming';
|
|
102
|
+
return {
|
|
103
|
+
state,
|
|
104
|
+
dayIndex: currentDayIndex,
|
|
105
|
+
dayTitle,
|
|
106
|
+
scheduledWeekday,
|
|
107
|
+
scheduledWeekdayName: WEEKDAY_NAMES[scheduledWeekday],
|
|
108
|
+
scheduledOn: dateOnly(shiftDate(now, daysUntil)),
|
|
109
|
+
daysUntil
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatScheduleLine(resolved) {
|
|
114
|
+
if (!resolved) return null;
|
|
115
|
+
if (resolved.state === 'catchUp') {
|
|
116
|
+
const next = resolved.nextScheduledSession;
|
|
117
|
+
const nextLine = next
|
|
118
|
+
? ` Next scheduled session: ${next.dayTitle} on ${next.scheduledWeekdayName} (${next.daysUntil === 1 ? 'tomorrow' : `in ${next.daysUntil} days`}).`
|
|
119
|
+
: '';
|
|
120
|
+
return `Missed scheduled session: ${resolved.dayTitle} (was ${resolved.scheduledWeekdayName} ${resolved.scheduledOn}, not completed). Available to catch up.${nextLine}`;
|
|
121
|
+
}
|
|
122
|
+
if (resolved.state === 'today') {
|
|
123
|
+
return `Today's scheduled session: ${resolved.dayTitle} (${resolved.scheduledWeekdayName}).`;
|
|
124
|
+
}
|
|
125
|
+
const whenLabel = resolved.daysUntil === 1 ? 'tomorrow' : `in ${resolved.daysUntil} days`;
|
|
126
|
+
return `Next scheduled session: ${resolved.dayTitle} on ${resolved.scheduledWeekdayName} (${whenLabel}).`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function completedThisCycleLines(program) {
|
|
130
|
+
if (!program) return [];
|
|
131
|
+
const completed = Array.from(new Set(program.completedDayIndices ?? []))
|
|
132
|
+
.filter((idx) => Number.isInteger(idx))
|
|
133
|
+
.sort((a, b) => a - b);
|
|
134
|
+
if (completed.length === 0) return [];
|
|
135
|
+
const days = program.days ?? [];
|
|
136
|
+
const trainingWeekdays = program.trainingWeekdays ?? [];
|
|
137
|
+
const parts = completed.map((idx) => {
|
|
138
|
+
const title = days[idx]?.title ?? `Day ${idx + 1}`;
|
|
139
|
+
const wd = trainingWeekdays[idx];
|
|
140
|
+
const wdName = wd != null ? WEEKDAY_NAMES[wd]?.slice(0, 3) : null;
|
|
141
|
+
return wdName ? `${title} (${wdName})` : title;
|
|
142
|
+
});
|
|
143
|
+
return [`Completed this cycle: ${parts.join(', ')}.`];
|
|
144
|
+
}
|
|
145
|
+
|
|
10
146
|
function normalizedNote(note) {
|
|
11
147
|
if (typeof note !== 'string') return null;
|
|
12
148
|
const trimmed = note.trim();
|
|
@@ -1101,38 +1237,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
|
|
|
1101
1237
|
return { original, replacement, count };
|
|
1102
1238
|
});
|
|
1103
1239
|
|
|
1104
|
-
let goalProgress = null;
|
|
1105
|
-
const plans = snapshot.strengthPlans ?? [];
|
|
1106
|
-
const activePlan = plans.find(
|
|
1107
|
-
(p) => p.status === 'active' && p.programId === program.id
|
|
1108
|
-
);
|
|
1109
|
-
if (activePlan) {
|
|
1110
|
-
const programExerciseNames = new Set(
|
|
1111
|
-
(program.days ?? [])
|
|
1112
|
-
.flatMap((day) => day.exercises ?? [])
|
|
1113
|
-
.map((exercise) => canonicalExerciseName(exercise.name ?? exercise.exerciseName))
|
|
1114
|
-
);
|
|
1115
|
-
goalProgress = (activePlan.liftGoals ?? [])
|
|
1116
|
-
.filter((g) => {
|
|
1117
|
-
if (programExerciseNames.size === 0) return true;
|
|
1118
|
-
return programExerciseNames.has(canonicalExerciseName(g.exerciseDisplayName));
|
|
1119
|
-
})
|
|
1120
|
-
.map((g) => {
|
|
1121
|
-
const range = g.targetE1RM - g.startingE1RM;
|
|
1122
|
-
const gained = g.currentBestE1RM - g.startingE1RM;
|
|
1123
|
-
const progressPct =
|
|
1124
|
-
range > 0 ? Math.max(0, Math.round((gained / range) * 100)) : null;
|
|
1125
|
-
return {
|
|
1126
|
-
exerciseName: g.exerciseDisplayName,
|
|
1127
|
-
progressPercent: progressPct,
|
|
1128
|
-
currentBestE1RM: g.currentBestE1RM,
|
|
1129
|
-
targetE1RM: g.targetE1RM,
|
|
1130
|
-
goalAdjustmentAction: g.goalAdjustmentAction ?? null,
|
|
1131
|
-
goalAdjustedAt: g.goalAdjustedAt ?? null
|
|
1132
|
-
};
|
|
1133
|
-
});
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
1240
|
const matchingSummary = programCycleSummaries[0] ?? null;
|
|
1137
1241
|
|
|
1138
1242
|
const progressionDecisions = (matchingSummary?.progressionUpdates ?? []).map((u) => ({
|
|
@@ -1236,39 +1340,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
|
|
|
1236
1340
|
actionability: 9
|
|
1237
1341
|
});
|
|
1238
1342
|
}
|
|
1239
|
-
if (goalProgress?.length > 0) {
|
|
1240
|
-
const lagging = goalProgress.filter((g) => g.progressPercent != null && g.progressPercent < 40);
|
|
1241
|
-
if (lagging.length > 0) {
|
|
1242
|
-
cycleSignals.push({
|
|
1243
|
-
id: 'goal-lagging',
|
|
1244
|
-
category: 'goals',
|
|
1245
|
-
summary: `${lagging.length} goal${lagging.length === 1 ? '' : 's'} under 40% progress`,
|
|
1246
|
-
detail: lagging.map((g) => `${g.exerciseName} ${g.progressPercent}%`).join(', '),
|
|
1247
|
-
impact: 9,
|
|
1248
|
-
confidence: 8,
|
|
1249
|
-
novelty: 6,
|
|
1250
|
-
actionability: 9
|
|
1251
|
-
});
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
// Phase-window context (Step 9b of the deload-week unification plan): explicit
|
|
1256
|
-
// structured phase facts so prompt builders / models never have to infer
|
|
1257
|
-
// "is this a deload week?" from session prose.
|
|
1258
|
-
const phaseRangeStart = cycleSessions[0]?.completedAt ?? cycleSessions[0]?.date ?? null;
|
|
1259
|
-
const phaseRangeEnd = cycleSessions[cycleSessions.length - 1]?.completedAt
|
|
1260
|
-
?? cycleSessions[cycleSessions.length - 1]?.date
|
|
1261
|
-
?? null;
|
|
1262
|
-
const summaryRange = phaseRangeStart && phaseRangeEnd
|
|
1263
|
-
? { start: phaseRangeStart, end: phaseRangeEnd }
|
|
1264
|
-
: null;
|
|
1265
|
-
const programPhase = programPhaseWindowContext(
|
|
1266
|
-
program,
|
|
1267
|
-
activeStrengthPlanForProgram(snapshot, program.id),
|
|
1268
|
-
summaryRange,
|
|
1269
|
-
new Date()
|
|
1270
|
-
);
|
|
1271
|
-
|
|
1272
1343
|
return {
|
|
1273
1344
|
programName: program.name,
|
|
1274
1345
|
cycleNumber: cycleWeekNumber,
|
|
@@ -1276,7 +1347,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
|
|
|
1276
1347
|
sessions,
|
|
1277
1348
|
prsThisCycle,
|
|
1278
1349
|
bwPrsThisCycle,
|
|
1279
|
-
goalProgress,
|
|
1280
1350
|
progressionDecisions,
|
|
1281
1351
|
setCompletionRate,
|
|
1282
1352
|
swapPatterns,
|
|
@@ -1291,7 +1361,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
|
|
|
1291
1361
|
avgSleepMins,
|
|
1292
1362
|
latestBodyWeightKg,
|
|
1293
1363
|
prioritySignals: rankPrioritySignals(cycleSignals),
|
|
1294
|
-
programPhase,
|
|
1295
1364
|
excludeNote: buildExcludeNote(exclude)
|
|
1296
1365
|
};
|
|
1297
1366
|
}
|
|
@@ -1373,26 +1442,12 @@ export function checkpointContext(snapshot, programId, checkpointWeek, { exclude
|
|
|
1373
1442
|
.slice(0, 3);
|
|
1374
1443
|
const previousCycleNotes = programCycleSummaries.map((cs) => cs.aiSummary);
|
|
1375
1444
|
|
|
1376
|
-
// Phase-window context (Step 9b). Scoped to a 14-day window ending today
|
|
1377
|
-
// so phasesInRange covers "current week" + "previous week" — enough for
|
|
1378
|
-
// the model to spot post-deload-return / pre-deload patterns without
|
|
1379
|
-
// bloating the prompt with the entire plan timeline.
|
|
1380
|
-
const checkpointToday = new Date();
|
|
1381
|
-
const checkpointStart = new Date(checkpointToday.getTime() - 14 * 24 * 60 * 60 * 1000);
|
|
1382
|
-
const programPhase = programPhaseWindowContext(
|
|
1383
|
-
program,
|
|
1384
|
-
activeStrengthPlanForProgram(snapshot, program.id),
|
|
1385
|
-
{ start: checkpointStart, end: checkpointToday },
|
|
1386
|
-
checkpointToday
|
|
1387
|
-
);
|
|
1388
|
-
|
|
1389
1445
|
return {
|
|
1390
1446
|
programName: program.name,
|
|
1391
1447
|
checkpointWeek,
|
|
1392
1448
|
totalWeeks,
|
|
1393
1449
|
exercises,
|
|
1394
1450
|
previousCycleNotes,
|
|
1395
|
-
programPhase,
|
|
1396
1451
|
excludeNote: buildExcludeNote(exclude)
|
|
1397
1452
|
};
|
|
1398
1453
|
}
|
|
@@ -1402,6 +1457,7 @@ export function workoutSummaryContext(snapshot, sessionId, { exclude = new Set()
|
|
|
1402
1457
|
const session = sessions.find((s) => s.id === sessionId);
|
|
1403
1458
|
if (!session) return null;
|
|
1404
1459
|
|
|
1460
|
+
const today = new Date();
|
|
1405
1461
|
const sessionDate = completionDateForSession(session);
|
|
1406
1462
|
const dayName = session.dayName ?? 'Session';
|
|
1407
1463
|
const earlierSessions = sessions
|
|
@@ -1715,14 +1771,16 @@ export function workoutSummaryContext(snapshot, sessionId, { exclude = new Set()
|
|
|
1715
1771
|
const nextExercises = ((program.days ?? [])[currentDayIndex]?.exercises ?? [])
|
|
1716
1772
|
.map(ex => ex.exerciseName ?? ex.name)
|
|
1717
1773
|
.filter(Boolean);
|
|
1718
|
-
const
|
|
1719
|
-
let weekdayName = null;
|
|
1720
|
-
if (nextSessionWeekday != null) {
|
|
1721
|
-
const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
1722
|
-
weekdayName = dayNames[nextSessionWeekday] ?? null;
|
|
1723
|
-
}
|
|
1774
|
+
const schedule = resolveProgramSchedule(program, today);
|
|
1724
1775
|
if (nextDayTitle) {
|
|
1725
|
-
nextSession = {
|
|
1776
|
+
nextSession = {
|
|
1777
|
+
dayTitle: nextDayTitle,
|
|
1778
|
+
weekday: schedule?.scheduledWeekdayName ?? null,
|
|
1779
|
+
exerciseNames: nextExercises,
|
|
1780
|
+
state: schedule?.state ?? null,
|
|
1781
|
+
scheduledOn: schedule?.scheduledOn ?? null,
|
|
1782
|
+
nextScheduledSession: schedule?.nextScheduledSession ?? null
|
|
1783
|
+
};
|
|
1726
1784
|
}
|
|
1727
1785
|
}
|
|
1728
1786
|
}
|
|
@@ -1997,30 +2055,13 @@ export function askContext(snapshot, { exclude = new Set() } = {}) {
|
|
|
1997
2055
|
lines.push(`Recent frequency: ${perWeek} sessions/week (last 4 weeks).`);
|
|
1998
2056
|
}
|
|
1999
2057
|
|
|
2000
|
-
// Current program
|
|
2001
|
-
//
|
|
2058
|
+
// Current program context without plan-week or phase framing. Score is the
|
|
2059
|
+
// release progress model; program context is only for what the user trains.
|
|
2002
2060
|
const program = activeProgram(snapshot);
|
|
2003
2061
|
if (program) {
|
|
2004
2062
|
const recoveryOutcome = exclude.has('recovery') ? null : deriveRecoveryOutcome(snapshot, program);
|
|
2005
|
-
const
|
|
2006
|
-
|
|
2007
|
-
.sort((a, b) => String(completionDateForSession(b)).localeCompare(String(completionDateForSession(a))));
|
|
2008
|
-
const latestSession = programSessions[0];
|
|
2009
|
-
const phase = resolveCurrentProgramPhase(snapshot, program, today);
|
|
2010
|
-
const currentWeek = phase?.displayWeek
|
|
2011
|
-
?? Math.max(
|
|
2012
|
-
Number(program.completedCyclesCount ?? 0) + 1,
|
|
2013
|
-
Number(latestSession?.historicalContext?.programWeekNumber ?? 0),
|
|
2014
|
-
1
|
|
2015
|
-
);
|
|
2016
|
-
const weekPhase = phase?.phase
|
|
2017
|
-
?? latestSession?.historicalContext?.programProgressionType
|
|
2018
|
-
?? null;
|
|
2019
|
-
const phaseLabel = weekPhase ? ` (${weekPhase} week)` : '';
|
|
2020
|
-
lines.push(`Current program: ${program.name}, week ${currentWeek}${phaseLabel}, ${program.daysPerWeek} days/week, equipment: ${program.equipmentTier ?? 'unknown'}.`);
|
|
2021
|
-
if (weekPhase === 'deload') {
|
|
2022
|
-
lines.push('Note: This is a planned deload week — reduced volume and intensity are intentional, not a regression.');
|
|
2023
|
-
}
|
|
2063
|
+
const daysPerWeek = program.daysPerWeek ?? program.days?.length ?? 'unknown';
|
|
2064
|
+
lines.push(`Current program: ${program.name}, ${daysPerWeek} days/week, equipment: ${program.equipmentTier ?? 'unknown'}.`);
|
|
2024
2065
|
|
|
2025
2066
|
const weekStart = startOfCurrentIsoWeek(today);
|
|
2026
2067
|
const strengthSessionsThisWeek = sessions.filter((session) => {
|
|
@@ -2038,30 +2079,21 @@ export function askContext(snapshot, { exclude = new Set() } = {}) {
|
|
|
2038
2079
|
lines.push(`Last strength session: ${lastStrengthSessionDate}.`);
|
|
2039
2080
|
}
|
|
2040
2081
|
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
if (
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2082
|
+
// Days until next session — respects iOS catch-up semantics.
|
|
2083
|
+
const schedule = resolveProgramSchedule(program, today);
|
|
2084
|
+
const scheduleLine = formatScheduleLine(schedule);
|
|
2085
|
+
if (scheduleLine) {
|
|
2086
|
+
// workout-summary historically uses "Next session:" lead — preserve when not catching up.
|
|
2087
|
+
if (schedule.state === 'catchUp') {
|
|
2088
|
+
lines.push(scheduleLine);
|
|
2089
|
+
} else if (schedule.state === 'today') {
|
|
2090
|
+
lines.push(`Next session: ${schedule.dayTitle} (today).`);
|
|
2091
|
+
} else {
|
|
2092
|
+
const whenLabel = schedule.daysUntil === 1 ? 'tomorrow' : `in ${schedule.daysUntil} days`;
|
|
2093
|
+
lines.push(`Next session: ${schedule.dayTitle} on ${schedule.scheduledWeekdayName} (${whenLabel}).`);
|
|
2094
|
+
}
|
|
2050
2095
|
}
|
|
2051
|
-
|
|
2052
|
-
// Days until next session
|
|
2053
2096
|
const currentDayIndex = program.currentDayIndex ?? 0;
|
|
2054
|
-
const nextSessionWeekday = (program.trainingWeekdays ?? [])[currentDayIndex];
|
|
2055
|
-
if (nextSessionWeekday != null) {
|
|
2056
|
-
const jsDay = new Date().getDay(); // 0=Sun
|
|
2057
|
-
const todayWeekday = jsDay === 0 ? 7 : jsDay; // 1=Mon … 7=Sun
|
|
2058
|
-
let daysUntil = nextSessionWeekday - todayWeekday;
|
|
2059
|
-
if (daysUntil < 0) daysUntil += 7;
|
|
2060
|
-
const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
2061
|
-
const whenLabel = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil} days`;
|
|
2062
|
-
const nextDayTitle = (program.days ?? [])[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
|
|
2063
|
-
lines.push(`Next session: ${nextDayTitle} on ${dayNames[nextSessionWeekday]} (${whenLabel}).`);
|
|
2064
|
-
}
|
|
2065
2097
|
|
|
2066
2098
|
if (recoveryOutcome) {
|
|
2067
2099
|
lines.push(`Recovery update: ${recoveryOutcome.scheduleLine} ${recoveryOutcome.targetLine} ${recoveryOutcome.nextStepLine}`);
|
|
@@ -4102,7 +4134,8 @@ function weeklyActivitySummary(metrics) {
|
|
|
4102
4134
|
|
|
4103
4135
|
export function vitalsSummaryContext(snapshot, { exclude = new Set() } = {}) {
|
|
4104
4136
|
const lines = [];
|
|
4105
|
-
|
|
4137
|
+
const today = new Date();
|
|
4138
|
+
lines.push(`Date: ${dateOnly(today)} (${WEEKDAY_NAMES[isoWeekdayOf(today)]})`);
|
|
4106
4139
|
|
|
4107
4140
|
// Training context
|
|
4108
4141
|
const cutoff = new Date();
|
|
@@ -4144,18 +4177,10 @@ export function vitalsSummaryContext(snapshot, { exclude = new Set() } = {}) {
|
|
|
4144
4177
|
const prog = snapshot.currentProgram ?? activeProgram(snapshot);
|
|
4145
4178
|
if (prog) {
|
|
4146
4179
|
lines.push(`Current program: ${prog.name}.`);
|
|
4147
|
-
const
|
|
4148
|
-
const
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
const todayIso = jsDay === 0 ? 7 : jsDay;
|
|
4152
|
-
let daysUntil = nextSessionWeekday - todayIso;
|
|
4153
|
-
if (daysUntil < 0) daysUntil += 7;
|
|
4154
|
-
const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
4155
|
-
const whenLabel = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil} days`;
|
|
4156
|
-
const nextDayTitle = (prog.days ?? [])[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
|
|
4157
|
-
lines.push(`Next scheduled session: ${nextDayTitle} on ${dayNames[nextSessionWeekday]} (${whenLabel}).`);
|
|
4158
|
-
}
|
|
4180
|
+
for (const line of completedThisCycleLines(prog)) lines.push(line);
|
|
4181
|
+
const schedule = resolveProgramSchedule(prog, today);
|
|
4182
|
+
const scheduleLine = formatScheduleLine(schedule);
|
|
4183
|
+
if (scheduleLine) lines.push(scheduleLine);
|
|
4159
4184
|
}
|
|
4160
4185
|
|
|
4161
4186
|
const vitals = healthSummary(snapshot, 14);
|
|
@@ -4453,19 +4478,25 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
|
|
|
4453
4478
|
// ---------- Weekly Coach Check-in ----------
|
|
4454
4479
|
// Builds a rolling 7-day context for the Sunday Coach Check-in.
|
|
4455
4480
|
// See docs/plans/2026-04-23-001-feat-sunday-coach-checkin-plan-deepened.md.
|
|
4456
|
-
|
|
4481
|
+
// `now` (optional, in options) anchors the 7-day window to a caller-provided
|
|
4482
|
+
// reference time instead of real time. The cron uses this to pin the window
|
|
4483
|
+
// to `row.week_start_date` so a late catch-up run still reports the canonical
|
|
4484
|
+
// Sun→Sun week rather than a Tue→Tue rolling slice. Defaults to new Date().
|
|
4485
|
+
export function weeklyCheckinContext(snapshot, accountId, { now: providedNow } = {}) {
|
|
4457
4486
|
if (!snapshot) return null;
|
|
4458
4487
|
const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
|
|
4459
|
-
const now =
|
|
4488
|
+
const now = providedNow instanceof Date && !Number.isNaN(providedNow.getTime())
|
|
4489
|
+
? providedNow
|
|
4490
|
+
: new Date();
|
|
4460
4491
|
const todayIso = now.toISOString().slice(0, 10);
|
|
4461
|
-
const cutoff = new Date(now
|
|
4462
|
-
|
|
4463
|
-
|
|
4492
|
+
const cutoff = new Date(now);
|
|
4493
|
+
cutoff.setUTCHours(0, 0, 0, 0);
|
|
4494
|
+
cutoff.setUTCDate(cutoff.getUTCDate() - 7);
|
|
4464
4495
|
const weekSessions = sessions.filter((s) => {
|
|
4465
4496
|
const d = completionDateForSession(s);
|
|
4466
4497
|
if (!d) return false;
|
|
4467
4498
|
const dt = new Date(d);
|
|
4468
|
-
return !Number.isNaN(dt.getTime()) && dt >= cutoff;
|
|
4499
|
+
return !Number.isNaN(dt.getTime()) && dt >= cutoff && dt < now;
|
|
4469
4500
|
});
|
|
4470
4501
|
|
|
4471
4502
|
// Sessions prior to this week for stall/PR comparison.
|
|
@@ -4532,6 +4563,7 @@ export function weeklyCheckinContext(snapshot, accountId) {
|
|
|
4532
4563
|
}
|
|
4533
4564
|
|
|
4534
4565
|
// Stalled exercises: e1RM hasn't increased in 3+ consecutive weeks.
|
|
4566
|
+
// Bounded by `now` so anchored windows ignore sessions after the reference.
|
|
4535
4567
|
const stalled = [];
|
|
4536
4568
|
const byExercise = new Map();
|
|
4537
4569
|
for (const s of sessions) {
|
|
@@ -4539,6 +4571,7 @@ export function weeklyCheckinContext(snapshot, accountId) {
|
|
|
4539
4571
|
if (!d) continue;
|
|
4540
4572
|
const dt = new Date(d);
|
|
4541
4573
|
if (Number.isNaN(dt.getTime())) continue;
|
|
4574
|
+
if (dt >= now) continue;
|
|
4542
4575
|
for (const ex of s.exercises ?? []) {
|
|
4543
4576
|
if (!ex.name) continue;
|
|
4544
4577
|
let top = 0;
|
|
@@ -4576,7 +4609,10 @@ export function weeklyCheckinContext(snapshot, accountId) {
|
|
|
4576
4609
|
.slice()
|
|
4577
4610
|
.filter((e) => e && e.date && Number.isFinite(Number(e.value ?? e.weight)))
|
|
4578
4611
|
.sort((a, b) => String(a.date).localeCompare(String(b.date)));
|
|
4579
|
-
const recent = sorted.filter((e) =>
|
|
4612
|
+
const recent = sorted.filter((e) => {
|
|
4613
|
+
const dt = new Date(e.date);
|
|
4614
|
+
return !Number.isNaN(dt.getTime()) && dt >= cutoff && dt < now;
|
|
4615
|
+
});
|
|
4580
4616
|
if (recent.length >= 2) {
|
|
4581
4617
|
const first = Number(recent[0].value ?? recent[0].weight);
|
|
4582
4618
|
const last = Number(recent[recent.length - 1].value ?? recent[recent.length - 1].weight);
|
|
@@ -4584,38 +4620,6 @@ export function weeklyCheckinContext(snapshot, accountId) {
|
|
|
4584
4620
|
}
|
|
4585
4621
|
}
|
|
4586
4622
|
|
|
4587
|
-
// Goal trajectory: read from active StrengthPlan liftGoals.
|
|
4588
|
-
let goalProgress = [];
|
|
4589
|
-
const plans = Array.isArray(snapshot.strengthPlans) ? snapshot.strengthPlans : [];
|
|
4590
|
-
const activePlan = program
|
|
4591
|
-
? activeStrengthPlanForProgram(snapshot, program.id)
|
|
4592
|
-
: plans.find((p) => p?.status === 'active');
|
|
4593
|
-
if (activePlan && Array.isArray(activePlan.liftGoals)) {
|
|
4594
|
-
for (const goal of activePlan.liftGoals) {
|
|
4595
|
-
const start = Number(goal.startingE1RM ?? 0);
|
|
4596
|
-
const target = Number(goal.targetE1RM ?? 0);
|
|
4597
|
-
const current = Number(goal.currentBestE1RM ?? 0);
|
|
4598
|
-
if (target <= 0 || target === start) continue;
|
|
4599
|
-
const pct = Math.max(0, Math.min(100, Math.round(((current - start) / (target - start)) * 100)));
|
|
4600
|
-
goalProgress.push({
|
|
4601
|
-
exerciseName: goal.exerciseDisplayName ?? goal.exerciseName ?? 'goal',
|
|
4602
|
-
progressPercent: pct,
|
|
4603
|
-
currentE1RM: Math.round(current * 10) / 10,
|
|
4604
|
-
targetE1RM: target,
|
|
4605
|
-
finishDate: activePlan.finishDate ?? null,
|
|
4606
|
-
});
|
|
4607
|
-
}
|
|
4608
|
-
}
|
|
4609
|
-
|
|
4610
|
-
const programPhase = program
|
|
4611
|
-
? programPhaseWindowContext(
|
|
4612
|
-
program,
|
|
4613
|
-
activeStrengthPlanForProgram(snapshot, program.id),
|
|
4614
|
-
{ start: cutoff, end: now },
|
|
4615
|
-
now
|
|
4616
|
-
)
|
|
4617
|
-
: null;
|
|
4618
|
-
|
|
4619
4623
|
// Prior commitment from typed coach_commitments storage (caller may inject).
|
|
4620
4624
|
const context = {
|
|
4621
4625
|
accountId,
|
|
@@ -4629,10 +4633,121 @@ export function weeklyCheckinContext(snapshot, accountId) {
|
|
|
4629
4633
|
prsThisWeek: prs,
|
|
4630
4634
|
stalledExercises: stalled.slice(0, 5),
|
|
4631
4635
|
bodyweightDeltaKg: bodyweightDelta,
|
|
4632
|
-
goalProgress,
|
|
4633
|
-
programPhase,
|
|
4634
4636
|
// Placeholder for injection by the handler; not a secret, just coherent.
|
|
4635
4637
|
priorCommitment: null,
|
|
4636
4638
|
};
|
|
4637
4639
|
return context;
|
|
4638
4640
|
}
|
|
4641
|
+
|
|
4642
|
+
// ---------- Weekly score digest (onemore-3s7j) ----------
|
|
4643
|
+
// Pure derivation: given the existing weekly check-in context (sessions,
|
|
4644
|
+
// volume, adherence, PRs, stalled lifts, bodyweight delta) plus the last week
|
|
4645
|
+
// of score_snapshots rows from the DB, build the row that backs the iOS
|
|
4646
|
+
// digest card. The card is glanceable and rule-based — no LLM call here.
|
|
4647
|
+
//
|
|
4648
|
+
// scoreSnapshots argument: array of { snapshotAt, score, components } rows,
|
|
4649
|
+
// ordered DESC by snapshotAt (i.e. listScoreSnapshots default ordering).
|
|
4650
|
+
export function buildWeeklyScoreDigest(weeklyContext, scoreSnapshots) {
|
|
4651
|
+
if (!weeklyContext) return null;
|
|
4652
|
+
const rows = Array.isArray(scoreSnapshots) ? scoreSnapshots : [];
|
|
4653
|
+
if (rows.length === 0) return null;
|
|
4654
|
+
|
|
4655
|
+
const weekStart = weeklyContext.weekRangeIso?.start;
|
|
4656
|
+
const weekEnd = weeklyContext.weekRangeIso?.end;
|
|
4657
|
+
if (!weekStart || !weekEnd) return null;
|
|
4658
|
+
|
|
4659
|
+
const inWeek = (iso) => {
|
|
4660
|
+
const d = String(iso ?? '').slice(0, 10);
|
|
4661
|
+
return d >= weekStart && d <= weekEnd;
|
|
4662
|
+
};
|
|
4663
|
+
|
|
4664
|
+
const latestInWeek = rows.find((r) => inWeek(r.snapshotAt));
|
|
4665
|
+
if (!latestInWeek) return null;
|
|
4666
|
+
|
|
4667
|
+
// Earliest prior row to compute a delta against. "Prior" means strictly
|
|
4668
|
+
// before the start of the digest week, capped at the most recent such row.
|
|
4669
|
+
const priorRow = rows.find((r) => String(r.snapshotAt ?? '').slice(0, 10) < weekStart);
|
|
4670
|
+
|
|
4671
|
+
const components = latestInWeek.components ?? {};
|
|
4672
|
+
const priorComponents = priorRow?.components ?? {};
|
|
4673
|
+
const componentsDelta = {};
|
|
4674
|
+
const keys = new Set([...Object.keys(components), ...Object.keys(priorComponents)]);
|
|
4675
|
+
for (const key of keys) {
|
|
4676
|
+
const now = scoreComponentNumber(components[key]);
|
|
4677
|
+
const before = scoreComponentNumber(priorComponents[key]);
|
|
4678
|
+
if (Number.isFinite(now) && Number.isFinite(before)) {
|
|
4679
|
+
componentsDelta[key] = Math.round((now - before) * 10) / 10;
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
|
|
4683
|
+
const scoreDelta = priorRow && Number.isFinite(Number(priorRow.score))
|
|
4684
|
+
? Math.round(Number(latestInWeek.score) - Number(priorRow.score))
|
|
4685
|
+
: null;
|
|
4686
|
+
|
|
4687
|
+
const signals = {
|
|
4688
|
+
sessionCount: weeklyContext.sessionCount ?? 0,
|
|
4689
|
+
totalVolume: weeklyContext.totalVolume ?? 0,
|
|
4690
|
+
adherencePct: weeklyContext.adherencePct ?? null,
|
|
4691
|
+
prCount: Array.isArray(weeklyContext.prsThisWeek) ? weeklyContext.prsThisWeek.length : 0,
|
|
4692
|
+
topPr: Array.isArray(weeklyContext.prsThisWeek) && weeklyContext.prsThisWeek.length > 0
|
|
4693
|
+
? weeklyContext.prsThisWeek[0]
|
|
4694
|
+
: null,
|
|
4695
|
+
topStalledExercise: Array.isArray(weeklyContext.stalledExercises) && weeklyContext.stalledExercises.length > 0
|
|
4696
|
+
? weeklyContext.stalledExercises[0].exerciseName
|
|
4697
|
+
: null,
|
|
4698
|
+
bodyweightDeltaKg: weeklyContext.bodyweightDeltaKg ?? null,
|
|
4699
|
+
};
|
|
4700
|
+
|
|
4701
|
+
return {
|
|
4702
|
+
weekStart,
|
|
4703
|
+
weekEnd,
|
|
4704
|
+
score: Math.round(Number(latestInWeek.score)),
|
|
4705
|
+
scoreDelta,
|
|
4706
|
+
components,
|
|
4707
|
+
componentsDelta,
|
|
4708
|
+
signals,
|
|
4709
|
+
observation: weeklyScoreDigestObservation({ signals, componentsDelta, scoreDelta }),
|
|
4710
|
+
sourceSnapshotId: latestInWeek.id != null ? String(latestInWeek.id) : null,
|
|
4711
|
+
};
|
|
4712
|
+
}
|
|
4713
|
+
|
|
4714
|
+
// Rule-based observation. Picks the single most useful sentence for the card.
|
|
4715
|
+
// Order: no sessions logged > biggest negative component drop > top PR >
|
|
4716
|
+
// stalled lift > positive consistency. Templated, never references plan rituals.
|
|
4717
|
+
export function weeklyScoreDigestObservation({ signals, componentsDelta, scoreDelta }) {
|
|
4718
|
+
const sessionCount = signals?.sessionCount ?? 0;
|
|
4719
|
+
if (sessionCount === 0) {
|
|
4720
|
+
return 'No sessions logged this week.';
|
|
4721
|
+
}
|
|
4722
|
+
|
|
4723
|
+
let worstKey = null;
|
|
4724
|
+
let worstValue = 0;
|
|
4725
|
+
for (const [key, value] of Object.entries(componentsDelta ?? {})) {
|
|
4726
|
+
if (Number.isFinite(value) && value < worstValue) {
|
|
4727
|
+
worstKey = key;
|
|
4728
|
+
worstValue = value;
|
|
4729
|
+
}
|
|
4730
|
+
}
|
|
4731
|
+
if (worstKey && worstValue <= -2) {
|
|
4732
|
+
return `Your ${worstKey} score dropped ${Math.abs(Math.round(worstValue))} this week — biggest drag on your overall score.`;
|
|
4733
|
+
}
|
|
4734
|
+
|
|
4735
|
+
if (signals.topPr?.exerciseName) {
|
|
4736
|
+
const weight = Number(signals.topPr.weight);
|
|
4737
|
+
const reps = Number(signals.topPr.reps);
|
|
4738
|
+
const repStr = Number.isFinite(weight) && Number.isFinite(reps) && weight > 0 && reps > 0
|
|
4739
|
+
? ` (${weight}×${reps})`
|
|
4740
|
+
: '';
|
|
4741
|
+
return `${signals.topPr.exerciseName} PR this week${repStr}.`;
|
|
4742
|
+
}
|
|
4743
|
+
|
|
4744
|
+
if (signals.topStalledExercise) {
|
|
4745
|
+
return `${signals.topStalledExercise} hasn't moved in a few weeks — the lift dragging your trajectory most.`;
|
|
4746
|
+
}
|
|
4747
|
+
|
|
4748
|
+
if (Number.isFinite(scoreDelta) && scoreDelta >= 3) {
|
|
4749
|
+
return `Score up ${scoreDelta} from last week — keep the rhythm.`;
|
|
4750
|
+
}
|
|
4751
|
+
|
|
4752
|
+
return `${sessionCount} session${sessionCount === 1 ? '' : 's'} logged this week.`;
|
|
4753
|
+
}
|
package/src/remote.js
CHANGED
|
@@ -4,8 +4,8 @@ import { executeCoachReadTool as executeLocalCoachReadTool, executeReadCommand }
|
|
|
4
4
|
import { resolveServiceUrl } from './service-url.js';
|
|
5
5
|
|
|
6
6
|
function notImplementedError() {
|
|
7
|
-
const error = new Error('
|
|
8
|
-
error.code = '
|
|
7
|
+
const error = new Error('No transport configured for this session. Run `incremnt login` to authenticate, or pass --input <file> / set INCREMNT_SNAPSHOT to use a local snapshot. If login keeps failing with a contract-mismatch error, run `npm install -g incremnt@latest` to upgrade the CLI.');
|
|
8
|
+
error.code = 'NO_TRANSPORT_CONFIGURED';
|
|
9
9
|
return error;
|
|
10
10
|
}
|
|
11
11
|
|
package/src/summary-evals.js
CHANGED
|
@@ -733,6 +733,45 @@ function hasFatigueLanguage(output) {
|
|
|
733
733
|
return /\b(fatigue|fatigued|underrecovered|recovery debt|fatigue ceiling|limited by recovery|limited by fatigue|accumulated fatigue)\b/i.test(output);
|
|
734
734
|
}
|
|
735
735
|
|
|
736
|
+
function hasAskFatigueRecoveryLanguage(output) {
|
|
737
|
+
return hasFatigueLanguage(output)
|
|
738
|
+
|| /\b(?:poor|low|bad|incomplete)\s+recovery\b/i.test(output)
|
|
739
|
+
|| /\bunder[-\s]?recovery\b/i.test(output)
|
|
740
|
+
|| /\brecovery\s+(?:limited|held back|caused|explains|drove|deficit|issue|problem)\b/i.test(output);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function hasAskFatigueRecoveryUncertaintyLanguage(output) {
|
|
744
|
+
const missingRecoveryData = /\b(?:no|not enough|without|missing|lack(?:ing)?|insufficient)\s+(?:\w+\s+){0,4}?(?:recovery|readiness|vitals?|sleep|hrv|heart rate|data|info|signals?|metrics?)\b/i.test(output);
|
|
745
|
+
const refusesInference = /\b(?:cannot|can't|do not|don't|does not|doesn't|would not|wouldn't|not enough|isn't enough|is not enough|no basis to|hard to)\s+(?:\w+\s+){0,12}?(?:infer|tie|connect|attribute|blame|claim|say|show|prove|know|call)\s+(?:\w+\s+){0,12}?(?:fatigue|recovery|readiness|why)\b/i.test(output);
|
|
746
|
+
const recoveryDoesNotExplain = /\b(?:fatigue|recovery|readiness)\b\s+(?:\w+\s+){0,10}?(?:cannot|can't|does not|doesn't|would not|wouldn't|isn't|is not)\s+(?:\w+\s+){0,10}?(?:explain|prove|show|tell|account for)\b/i.test(output);
|
|
747
|
+
return missingRecoveryData || refusesInference || recoveryDoesNotExplain;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function hasAskPositiveFatigueRecoveryAttribution(output) {
|
|
751
|
+
const concept = String.raw`(?:fatigue|fatigued|under[-\s]?recovered|under[-\s]?recovery|poor recovery|low recovery|incomplete recovery|recovery debt|fatigue ceiling|accumulated fatigue)`;
|
|
752
|
+
const causeVerb = String.raw`(?:because|due to|caused by|from|reflects?|suggests?|indicates?|points? to|explains?|limited|held back|drove|contributed to|tied to|tie\s+\w+\s+to)`;
|
|
753
|
+
const patterns = [
|
|
754
|
+
new RegExp(String.raw`\b${causeVerb}\b.{0,80}\b${concept}\b`, 'gi'),
|
|
755
|
+
new RegExp(String.raw`\b${concept}\b.{0,80}\b(?:caused|limited|held back|explains?|drove|led to|contributed to|accounts? for)\b`, 'gi')
|
|
756
|
+
];
|
|
757
|
+
for (const pattern of patterns) {
|
|
758
|
+
for (const match of output.matchAll(pattern)) {
|
|
759
|
+
const start = Math.max(0, (match.index ?? 0) - 40);
|
|
760
|
+
const window = output.slice(start, (match.index ?? 0) + match[0].length);
|
|
761
|
+
if (!/\b(?:not|no|cannot|can't|doesn't|does not|would not|wouldn't|isn't|is not)\b/i.test(window)) {
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function hasUnsupportedAskFatigueRecoveryClaim(output) {
|
|
770
|
+
if (!hasAskFatigueRecoveryLanguage(output)) return false;
|
|
771
|
+
if (hasAskPositiveFatigueRecoveryAttribution(output)) return true;
|
|
772
|
+
return !hasAskFatigueRecoveryUncertaintyLanguage(output);
|
|
773
|
+
}
|
|
774
|
+
|
|
736
775
|
function matchesHistoricalFamilyName(claimName, actualName) {
|
|
737
776
|
const claimVariants = new Set(historicalExerciseVariants(claimName));
|
|
738
777
|
const actualVariants = new Set(historicalExerciseVariants(actualName));
|
|
@@ -1182,7 +1221,7 @@ function evaluateAskClaims(output, snapshot, testCase) {
|
|
|
1182
1221
|
}
|
|
1183
1222
|
}
|
|
1184
1223
|
|
|
1185
|
-
if (
|
|
1224
|
+
if (hasUnsupportedAskFatigueRecoveryClaim(normalized) && !hasAskFatigueSupport(snapshot)) {
|
|
1186
1225
|
failures.push('Ask answer uses fatigue/recovery language but the snapshot has no recent vitals, sleep, or rep-dropoff signals to support it.');
|
|
1187
1226
|
}
|
|
1188
1227
|
|
package/src/sync-service.js
CHANGED
|
@@ -21,6 +21,7 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
21
21
|
'weekly-checkin-current': 30,
|
|
22
22
|
'weekly-checkin-ack': 30,
|
|
23
23
|
'weekly-checkin-start': 10,
|
|
24
|
+
'weekly-digest-current': 30,
|
|
24
25
|
'dev-login': 10,
|
|
25
26
|
'device-start': 20,
|
|
26
27
|
'device-poll': 300,
|
|
@@ -1050,6 +1051,10 @@ function routeRequest(url, method) {
|
|
|
1050
1051
|
return { command: 'weekly-checkin-start', options: {} };
|
|
1051
1052
|
}
|
|
1052
1053
|
|
|
1054
|
+
if (pathname === '/cli/weekly-digest/current') {
|
|
1055
|
+
return { command: 'weekly-digest-current', options: {} };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1053
1058
|
if (pathname === '/cli/health/ai') {
|
|
1054
1059
|
return {
|
|
1055
1060
|
command: 'health-ai',
|
|
@@ -1434,41 +1439,6 @@ function routeRequest(url, method) {
|
|
|
1434
1439
|
return null;
|
|
1435
1440
|
}
|
|
1436
1441
|
|
|
1437
|
-
/// Formats a `ProgramPhaseWindowContext` (sent by iOS in the request body) as
|
|
1438
|
-
/// a short text prelude prepended to the AI context. Without this the model
|
|
1439
|
-
/// would have to infer "is this a deload week / was last week deload?" from
|
|
1440
|
-
/// session prose; with it the structured phase facts are explicit.
|
|
1441
|
-
function formatProgramPhasePrelude(programPhase) {
|
|
1442
|
-
if (!programPhase || typeof programPhase !== 'object') return null;
|
|
1443
|
-
const current = programPhase.current;
|
|
1444
|
-
const previous = programPhase.previousWeek;
|
|
1445
|
-
const next = programPhase.nextWeek;
|
|
1446
|
-
if (!current?.phase || typeof current.displayWeek !== 'number') return null;
|
|
1447
|
-
const describe = (phase) => {
|
|
1448
|
-
if (!phase?.phase) return null;
|
|
1449
|
-
const week = typeof phase.displayWeek === 'number' ? `week ${phase.displayWeek}` : 'week ?';
|
|
1450
|
-
return `${week} (${phase.phase})${phase.isDeload ? ' · deload week' : ''}`;
|
|
1451
|
-
};
|
|
1452
|
-
const describeList = (phases) => {
|
|
1453
|
-
if (!Array.isArray(phases) || phases.length === 0) return null;
|
|
1454
|
-
return phases.map(describe).filter(Boolean).join(', ');
|
|
1455
|
-
};
|
|
1456
|
-
const lines = [
|
|
1457
|
-
'[Program phase]',
|
|
1458
|
-
`- Current: ${describe(current)}`
|
|
1459
|
-
];
|
|
1460
|
-
if (previous?.phase) lines.push(`- Previous: ${describe(previous)}`);
|
|
1461
|
-
if (next?.phase) lines.push(`- Next: ${describe(next)}`);
|
|
1462
|
-
if (programPhase.isPostDeloadReturn === true) {
|
|
1463
|
-
lines.push('- Post-deload return: yes (last week was deload, this week is build)');
|
|
1464
|
-
}
|
|
1465
|
-
const range = describeList(programPhase.phasesInRange);
|
|
1466
|
-
if (range) lines.push(`- Range phases: ${range}`);
|
|
1467
|
-
const previousRange = describeList(programPhase.previousRangePhases);
|
|
1468
|
-
if (previousRange) lines.push(`- Previous range phases: ${previousRange}`);
|
|
1469
|
-
return lines.join('\n');
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
1442
|
export function formatIncrementScorePrelude(snapshots) {
|
|
1473
1443
|
if (!Array.isArray(snapshots) || snapshots.length === 0) return null;
|
|
1474
1444
|
const latest = snapshots[0];
|
|
@@ -2021,6 +1991,7 @@ export function createSyncServiceRequestHandler({
|
|
|
2021
1991
|
pushMobileSyncChangesForAccount = null,
|
|
2022
1992
|
insertScoreSnapshotsForAccount = null,
|
|
2023
1993
|
listScoreSnapshotsForAccount = null,
|
|
1994
|
+
getCurrentWeeklyScoreDigestForAccount = null,
|
|
2024
1995
|
// Social
|
|
2025
1996
|
social = null,
|
|
2026
1997
|
onError = null
|
|
@@ -3488,6 +3459,43 @@ export function createSyncServiceRequestHandler({
|
|
|
3488
3459
|
return;
|
|
3489
3460
|
}
|
|
3490
3461
|
|
|
3462
|
+
if (route.command === 'weekly-digest-current') {
|
|
3463
|
+
if (request.method !== 'GET') {
|
|
3464
|
+
methodNotAllowed(response, 'Use GET for /cli/weekly-digest/current.');
|
|
3465
|
+
return;
|
|
3466
|
+
}
|
|
3467
|
+
if (!getCurrentWeeklyScoreDigestForAccount) {
|
|
3468
|
+
json(response, 503, { error: 'Weekly digest not available' });
|
|
3469
|
+
return;
|
|
3470
|
+
}
|
|
3471
|
+
try {
|
|
3472
|
+
const row = await getCurrentWeeklyScoreDigestForAccount(account);
|
|
3473
|
+
if (!row) {
|
|
3474
|
+
response.writeHead(204);
|
|
3475
|
+
response.end();
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
json(response, 200, {
|
|
3479
|
+
id: row.id,
|
|
3480
|
+
weekStartDate: row.weekStartDate,
|
|
3481
|
+
status: row.status,
|
|
3482
|
+
score: row.score,
|
|
3483
|
+
scoreDelta: row.scoreDelta,
|
|
3484
|
+
components: row.components,
|
|
3485
|
+
componentsDelta: row.componentsDelta,
|
|
3486
|
+
signals: row.signals,
|
|
3487
|
+
observation: row.observation,
|
|
3488
|
+
formulaVersion: row.formulaVersion,
|
|
3489
|
+
generatedAt: row.generatedAt,
|
|
3490
|
+
seenAt: row.seenAt,
|
|
3491
|
+
});
|
|
3492
|
+
} catch (err) {
|
|
3493
|
+
console.error('Weekly digest read error:', err.message);
|
|
3494
|
+
json(response, 500, { error: 'Failed to read weekly digest' });
|
|
3495
|
+
}
|
|
3496
|
+
return;
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3491
3499
|
let snapshot;
|
|
3492
3500
|
try {
|
|
3493
3501
|
snapshot = loadSnapshotForAccount
|
|
@@ -3707,7 +3715,10 @@ export function createSyncServiceRequestHandler({
|
|
|
3707
3715
|
if (openrouterKey && generateWeeklyCheckinRecapImpl && generateCheckinQuestionsImpl) {
|
|
3708
3716
|
try {
|
|
3709
3717
|
const { weeklyCheckinContext } = await import('./queries.js');
|
|
3710
|
-
|
|
3718
|
+
// Anchor to row.weekStartDate so lazy-gen describes the
|
|
3719
|
+
// canonical week, matching cron behaviour (onemore-8oc5).
|
|
3720
|
+
const referenceNow = new Date(`${row.weekStartDate}T23:59:59.999Z`);
|
|
3721
|
+
const ctx = weeklyCheckinContext(snapshot, account.id, { now: referenceNow });
|
|
3711
3722
|
if (ctx) {
|
|
3712
3723
|
let priorCommitmentRow = null;
|
|
3713
3724
|
if (listActiveCoachCommitmentsForAccount) {
|
|
@@ -4160,13 +4171,9 @@ export function createSyncServiceRequestHandler({
|
|
|
4160
4171
|
? queries.askRoutedContext(snapshot, question, { exclude, coachFacts })
|
|
4161
4172
|
: { context: queries.askContext(snapshot, { exclude }), metadata: { route: 'legacy' } };
|
|
4162
4173
|
const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
|
|
4163
|
-
const serverProgramPhase = persistedKind === 'weekly-checkin'
|
|
4164
|
-
? queries.weeklyCheckinContext?.(snapshot, account.id)?.programPhase
|
|
4165
|
-
: null;
|
|
4166
|
-
const programPhasePrelude = formatProgramPhasePrelude(body?.programPhase ?? serverProgramPhase);
|
|
4167
4174
|
const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
|
|
4168
4175
|
|
|
4169
|
-
const preludes = [
|
|
4176
|
+
const preludes = [incrementScorePrelude].filter(Boolean);
|
|
4170
4177
|
const ctx = preludes.length > 0
|
|
4171
4178
|
? `${preludes.join('\n\n')}\n\n${routedContext.context}`
|
|
4172
4179
|
: routedContext.context;
|