incremnt 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/contract.js +17 -0
- package/src/format.js +68 -1
- package/src/lib.js +6 -2
- package/src/logo.js +73 -0
- package/src/openrouter.js +110 -19
- package/src/queries.js +221 -3
package/package.json
CHANGED
package/src/contract.js
CHANGED
|
@@ -98,6 +98,23 @@ export const commandSchema = [
|
|
|
98
98
|
options: [
|
|
99
99
|
{ name: 'id', type: 'string', required: true, description: 'Plan ID' }
|
|
100
100
|
]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
command: 'cycles list',
|
|
104
|
+
id: 'cycle-summary-list',
|
|
105
|
+
description: 'List cycle summaries',
|
|
106
|
+
options: [
|
|
107
|
+
{ name: 'program-id', type: 'string', required: false, description: 'Filter by program ID' }
|
|
108
|
+
]
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
command: 'cycles show',
|
|
112
|
+
id: 'cycle-summary-show',
|
|
113
|
+
description: 'Show cycle summary details',
|
|
114
|
+
usage: 'cycles show --id <summary-id>',
|
|
115
|
+
options: [
|
|
116
|
+
{ name: 'id', type: 'string', required: true, description: 'Cycle summary ID' }
|
|
117
|
+
]
|
|
101
118
|
}
|
|
102
119
|
];
|
|
103
120
|
|
package/src/format.js
CHANGED
|
@@ -145,6 +145,14 @@ function formatSessionShow(payload) {
|
|
|
145
145
|
lines.push(keyValue('Calories', `${payload.activeCalories} kcal`));
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
if (payload.aiCoachNotes) {
|
|
149
|
+
lines.push('');
|
|
150
|
+
lines.push(` ${chalk.bold('AI Summary')}`);
|
|
151
|
+
for (const line of payload.aiCoachNotes.split('\n')) {
|
|
152
|
+
lines.push(` ${line}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
148
156
|
return lines.join('\n');
|
|
149
157
|
}
|
|
150
158
|
|
|
@@ -403,6 +411,58 @@ function formatProposalsList(payload) {
|
|
|
403
411
|
return lines.join('\n');
|
|
404
412
|
}
|
|
405
413
|
|
|
414
|
+
function formatCycleSummaryList(payload) {
|
|
415
|
+
if (!Array.isArray(payload) || payload.length === 0) {
|
|
416
|
+
return 'No cycle summaries found.';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const lines = [header('CYCLE SUMMARIES'), ''];
|
|
420
|
+
|
|
421
|
+
for (const summary of payload) {
|
|
422
|
+
const date = formatShortDate(summary.completedDate);
|
|
423
|
+
const sets = `${summary.totalSetsCompleted}/${summary.totalSetsPlanned} sets`;
|
|
424
|
+
const progressions = summary.progressionCount > 0 ? `${summary.progressionCount} progressions` : '';
|
|
425
|
+
const ai = summary.hasAISummary ? chalk.green('AI') : '';
|
|
426
|
+
const parts = [chalk.dim(sets), progressions ? chalk.dim(progressions) : '', ai].filter(Boolean);
|
|
427
|
+
|
|
428
|
+
lines.push(` ${chalk.bold(date)} ${summary.programName}${dimDot()}${parts.join(dimDot())}`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return lines.join('\n');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function formatCycleSummaryShow(payload) {
|
|
435
|
+
if (!payload) {
|
|
436
|
+
return 'Cycle summary not found.';
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const date = formatDayAndDate(payload.completedDate);
|
|
440
|
+
const lines = [` ${chalk.bold('CYCLE SUMMARY')}${dimDot()}${date}`, ''];
|
|
441
|
+
|
|
442
|
+
lines.push(keyValue('Program', payload.programName));
|
|
443
|
+
lines.push(keyValue('Sets', `${payload.totalSetsCompleted}/${payload.totalSetsPlanned}`));
|
|
444
|
+
|
|
445
|
+
if (payload.progressionUpdates.length > 0) {
|
|
446
|
+
lines.push('');
|
|
447
|
+
lines.push(` ${chalk.bold('Progressions')}`);
|
|
448
|
+
|
|
449
|
+
for (const update of payload.progressionUpdates) {
|
|
450
|
+
const icon = update.kind === 'deload' ? chalk.yellow('\u2193') : chalk.green('\u2191');
|
|
451
|
+
lines.push(` ${icon} ${update.exerciseName.padEnd(24)} ${chalk.dim(update.change)}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (payload.aiSummary) {
|
|
456
|
+
lines.push('');
|
|
457
|
+
lines.push(` ${chalk.bold('AI Summary')}`);
|
|
458
|
+
for (const line of payload.aiSummary.split('\n')) {
|
|
459
|
+
lines.push(` ${line}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return lines.join('\n');
|
|
464
|
+
}
|
|
465
|
+
|
|
406
466
|
function formatProposalDismissed(payload) {
|
|
407
467
|
if (!payload) return 'Proposal not found.';
|
|
408
468
|
return ` Proposal ${chalk.bold(payload.id)} dismissed.`;
|
|
@@ -410,11 +470,16 @@ function formatProposalDismissed(payload) {
|
|
|
410
470
|
|
|
411
471
|
// --- Main export ---
|
|
412
472
|
|
|
413
|
-
export function formatHelp() {
|
|
473
|
+
export function formatHelp(opts = {}) {
|
|
414
474
|
const cmd = (name, desc) => ` ${chalk.bold(name.padEnd(38))} ${chalk.dim(desc)}`;
|
|
475
|
+
const authBadge = opts.isAuthenticated
|
|
476
|
+
? chalk.green('\u25cf Authenticated')
|
|
477
|
+
: chalk.yellow("\u25cf Authentication required - run 'incremnt login'");
|
|
478
|
+
|
|
415
479
|
const lines = [
|
|
416
480
|
'',
|
|
417
481
|
header('INCREMNT') + dimDot() + chalk.dim('strength tracking CLI'),
|
|
482
|
+
` ${authBadge}`,
|
|
418
483
|
'',
|
|
419
484
|
header('USAGE'),
|
|
420
485
|
` incremnt <command> [options]`,
|
|
@@ -459,6 +524,8 @@ export function formatPretty(command, payload) {
|
|
|
459
524
|
'program-detail': formatProgramDetail,
|
|
460
525
|
'planned-vs-actual': formatPlannedVsActual,
|
|
461
526
|
'why-did-this-change': formatWhyDidThisChange,
|
|
527
|
+
'cycle-summary-list': formatCycleSummaryList,
|
|
528
|
+
'cycle-summary-show': formatCycleSummaryShow,
|
|
462
529
|
'programs-propose': formatProposalCreated,
|
|
463
530
|
'programs-proposals': formatProposalsList,
|
|
464
531
|
'proposal-dismiss': formatProposalDismissed
|
package/src/lib.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { clearSessionState, isSessionExpired, readSessionState, resolveConfigDir } from './state.js';
|
|
13
13
|
import { createTransport } from './transport.js';
|
|
14
14
|
import { formatPretty, formatHelp } from './format.js';
|
|
15
|
+
import { printLogo } from './logo.js';
|
|
15
16
|
|
|
16
17
|
function parseArgs(argv) {
|
|
17
18
|
const commandTokens = [];
|
|
@@ -66,12 +67,15 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
66
67
|
dismiss: 'proposal-dismiss'
|
|
67
68
|
})[command] ?? command;
|
|
68
69
|
|
|
70
|
+
const sessionState = await readSessionState();
|
|
71
|
+
const isAuthenticated = Boolean(sessionState?.session && !isSessionExpired(sessionState.session));
|
|
72
|
+
|
|
69
73
|
if (!command || options.help) {
|
|
70
|
-
stdout
|
|
74
|
+
printLogo(stdout);
|
|
75
|
+
stdout.write(`${formatHelp({ isAuthenticated })}\n`);
|
|
71
76
|
return 0;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
const sessionState = await readSessionState();
|
|
75
79
|
const transport = await createTransport(options, sessionState);
|
|
76
80
|
|
|
77
81
|
if (normalizedCommand === 'status') {
|
package/src/logo.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
const startColor = { r: 0, g: 255, b: 163 }; // #00ffa3
|
|
4
|
+
const endColor = { r: 59, g: 130, b: 246 }; // #3b82f6
|
|
5
|
+
|
|
6
|
+
function interpolateColor(start, end, factor) {
|
|
7
|
+
const r = Math.round(start.r + (end.r - start.r) * factor);
|
|
8
|
+
const g = Math.round(start.g + (end.g - start.g) * factor);
|
|
9
|
+
const b = Math.round(start.b + (end.b - start.b) * factor);
|
|
10
|
+
return { r, g, b };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Extracted from figlet 'ANSI Regular' output for 'INCREMNT'
|
|
14
|
+
// We use '#' in source so the ASCII art stays ASCII-only and easy to edit,
|
|
15
|
+
// then render as '█' block characters at runtime.
|
|
16
|
+
const rawLines = [
|
|
17
|
+
'## ### ## ###### ###### ####### ### ### ### ## ######## ',
|
|
18
|
+
'## #### ## ## ## ## ## #### #### #### ## ## ',
|
|
19
|
+
'## ## ## ## ## ###### ##### ## #### ## ## ## ## ## ',
|
|
20
|
+
'## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ',
|
|
21
|
+
'## ## #### ###### ## ## ####### ## ## ## #### ## '
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// 3D Drop Shadow offset exactly like Gemini CLI
|
|
25
|
+
const SHADOW_OFFSET_X = 1;
|
|
26
|
+
const SHADOW_OFFSET_Y = 1;
|
|
27
|
+
|
|
28
|
+
const totalLines = 5 + SHADOW_OFFSET_Y;
|
|
29
|
+
const lineLength = rawLines[0].length + SHADOW_OFFSET_X;
|
|
30
|
+
|
|
31
|
+
export function printLogo(stdout = process.stdout) {
|
|
32
|
+
// Gradient starts at the 'M' character position
|
|
33
|
+
// I: 0, N: 3, C: 14, R: 21, E: 29, M: 37
|
|
34
|
+
const gradientStartIdx = 37;
|
|
35
|
+
|
|
36
|
+
stdout.write('\n');
|
|
37
|
+
for (let r = 0; r < totalLines; r++) {
|
|
38
|
+
let lineStr = '';
|
|
39
|
+
|
|
40
|
+
for (let c = 0; c < lineLength; c++) {
|
|
41
|
+
const fgY = r;
|
|
42
|
+
const fgX = c;
|
|
43
|
+
const hasFg = (fgY >= 0 && fgY < 5 && fgX >= 0 && fgX < rawLines[0].length && rawLines[fgY][fgX] === '#');
|
|
44
|
+
|
|
45
|
+
const bgY = r - SHADOW_OFFSET_Y;
|
|
46
|
+
const bgX = c - SHADOW_OFFSET_X;
|
|
47
|
+
const hasBg = (bgY >= 0 && bgY < 5 && bgX >= 0 && bgX < rawLines[0].length && rawLines[bgY][bgX] === '#');
|
|
48
|
+
|
|
49
|
+
if (hasFg) {
|
|
50
|
+
if (fgX < gradientStartIdx) {
|
|
51
|
+
lineStr += chalk.white('█');
|
|
52
|
+
} else {
|
|
53
|
+
const factor = Math.max(0, Math.min(1, (fgX - gradientStartIdx) / (rawLines[0].length - gradientStartIdx - 2)));
|
|
54
|
+
const col = interpolateColor(startColor, endColor, factor);
|
|
55
|
+
lineStr += chalk.rgb(col.r, col.g, col.b)('█');
|
|
56
|
+
}
|
|
57
|
+
} else if (hasBg) {
|
|
58
|
+
if (bgX < gradientStartIdx) {
|
|
59
|
+
lineStr += chalk.rgb(55, 55, 65)('█');
|
|
60
|
+
} else {
|
|
61
|
+
const factor = Math.max(0, Math.min(1, (bgX - gradientStartIdx) / (rawLines[0].length - gradientStartIdx - 2)));
|
|
62
|
+
const col = interpolateColor(startColor, endColor, factor);
|
|
63
|
+
const shadowFactor = 0.35;
|
|
64
|
+
lineStr += chalk.rgb(Math.round(col.r * shadowFactor), Math.round(col.g * shadowFactor), Math.round(col.b * shadowFactor))('█');
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
lineStr += ' ';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
stdout.write(lineStr + '\n');
|
|
71
|
+
}
|
|
72
|
+
stdout.write('\n');
|
|
73
|
+
}
|
package/src/openrouter.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
const DEFAULT_MODEL = 'meta-llama/llama-3.1-8b-instruct:free';
|
|
2
2
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
3
|
-
const DEFAULT_MAX_TOKENS =
|
|
3
|
+
const DEFAULT_MAX_TOKENS = 500;
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const CYCLE_SUMMARY_PROMPT = `You are a strength coach reviewing a trainee's completed training cycle (typically one week). Write 3-4 short paragraphs separated by blank lines.
|
|
6
|
+
|
|
7
|
+
Your job is to give a cycle-level review — not a session-by-session recap. The app already shows set completion rate, individual session breakdowns, and deload adjustments — do NOT repeat any of that. Synthesize across the cycle.
|
|
8
|
+
|
|
9
|
+
Cover these in order of relevance (skip any that don't apply):
|
|
10
|
+
1. Overall cycle assessment: was this a build/deload/peak week? Did volume and intensity match the intent? If it was a deload, don't flag low numbers as a problem.
|
|
11
|
+
2. Progression commentary: the app made auto-progression decisions listed below. Comment on whether they look right given the data.
|
|
12
|
+
3. Multi-cycle trends: if previous cycle data is provided, note meaningful trends. Don't force trends where there aren't any.
|
|
13
|
+
4. Goal progress: if the trainee has strength goals, comment on trajectory.
|
|
14
|
+
5. One concrete thing to watch or change next cycle. Be specific.
|
|
6
15
|
|
|
7
16
|
Rules:
|
|
8
|
-
-
|
|
17
|
+
- Only state what the data shows. Never claim how something "felt."
|
|
18
|
+
- Reference specific exercises, weights, and reps. Use numbers, not vague praise.
|
|
9
19
|
- If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
- Never use
|
|
14
|
-
- Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
|
|
20
|
+
- If exercises were swapped from the plan, note the pattern and ask about it if recurring.
|
|
21
|
+
- Write like a training partner looking at a logbook, not a motivational coach. Short sentences, no filler, no cheerleading. Questions are good.
|
|
22
|
+
- Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting, smooth, controlled.
|
|
23
|
+
- Never use -ing clauses that add fake depth.
|
|
15
24
|
- No bullet points or lists.`;
|
|
16
25
|
|
|
17
26
|
export async function generateCoachingSummary(cycleContext, { apiKey, model, timeoutMs } = {}) {
|
|
@@ -35,7 +44,7 @@ export async function generateCoachingSummary(cycleContext, { apiKey, model, tim
|
|
|
35
44
|
body: JSON.stringify({
|
|
36
45
|
model: resolvedModel,
|
|
37
46
|
messages: [
|
|
38
|
-
{ role: 'system', content:
|
|
47
|
+
{ role: 'system', content: CYCLE_SUMMARY_PROMPT },
|
|
39
48
|
{ role: 'user', content: userContent }
|
|
40
49
|
],
|
|
41
50
|
max_tokens: DEFAULT_MAX_TOKENS,
|
|
@@ -61,15 +70,30 @@ export async function generateCoachingSummary(cycleContext, { apiKey, model, tim
|
|
|
61
70
|
}
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
function formatCycleContext(ctx) {
|
|
73
|
+
export function formatCycleContext(ctx) {
|
|
74
|
+
const intentLabel = ctx.cycleIntent ? ` (${ctx.cycleIntent})` : '';
|
|
65
75
|
const lines = [
|
|
66
|
-
`Program: ${ctx.programName}, Week ${ctx.cycleNumber}, ${ctx.totalSessions} session(s).`
|
|
76
|
+
`Program: ${ctx.programName}, Week ${ctx.cycleNumber}${intentLabel}, ${ctx.totalSessions} session(s).`
|
|
67
77
|
];
|
|
68
78
|
|
|
79
|
+
if (ctx.setCompletionRate) {
|
|
80
|
+
const pct = ctx.setCompletionRate.planned > 0
|
|
81
|
+
? Math.round(ctx.setCompletionRate.completed / ctx.setCompletionRate.planned * 100)
|
|
82
|
+
: 0;
|
|
83
|
+
lines.push(`Completion: ${ctx.setCompletionRate.completed}/${ctx.setCompletionRate.planned} sets (${pct}%).`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (ctx.adaptationNote) {
|
|
87
|
+
lines.push(`Adaptation: ${ctx.adaptationNote}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
lines.push('');
|
|
91
|
+
lines.push('Sessions:');
|
|
69
92
|
for (const session of ctx.sessions) {
|
|
70
93
|
const parts = [session.dayName || 'Session'];
|
|
71
94
|
if (session.totalVolume) parts.push(`${session.totalVolume} kg volume`);
|
|
72
95
|
if (session.effortScore) parts.push(`effort ${session.effortScore}/10`);
|
|
96
|
+
if (session.averageHeartRate) parts.push(`avg HR ${session.averageHeartRate}`);
|
|
73
97
|
lines.push(parts.join(', '));
|
|
74
98
|
|
|
75
99
|
for (const ex of session.exercises) {
|
|
@@ -84,32 +108,80 @@ function formatCycleContext(ctx) {
|
|
|
84
108
|
}
|
|
85
109
|
|
|
86
110
|
if (ctx.prsThisCycle.length > 0) {
|
|
87
|
-
lines.push('
|
|
111
|
+
lines.push('');
|
|
112
|
+
lines.push('PRs this cycle:');
|
|
88
113
|
for (const pr of ctx.prsThisCycle) {
|
|
89
114
|
lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps} (e1RM ${pr.estimatedOneRM})`);
|
|
90
115
|
}
|
|
91
116
|
}
|
|
92
117
|
|
|
118
|
+
if (ctx.progressionDecisions?.length > 0) {
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push('Progression decisions made by the app:');
|
|
121
|
+
for (const pd of ctx.progressionDecisions) {
|
|
122
|
+
const detail = pd.detail ? ` (${pd.detail})` : '';
|
|
123
|
+
const kind = pd.kind ? `[${pd.kind}] ` : '';
|
|
124
|
+
lines.push(` ${pd.exerciseName}: ${kind}${pd.action}${detail}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (ctx.swapPatterns?.length > 0) {
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push('Exercise swaps:');
|
|
131
|
+
for (const sp of ctx.swapPatterns) {
|
|
132
|
+
lines.push(` ${sp.original} → ${sp.replacement} (${sp.count} of ${ctx.totalSessions} sessions)`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
if (ctx.goalProgress) {
|
|
137
|
+
lines.push('');
|
|
94
138
|
lines.push('Goal progress:');
|
|
95
139
|
for (const g of ctx.goalProgress) {
|
|
96
140
|
lines.push(` ${g.exerciseName}: ${g.progressPercent ?? '?'}% toward ${g.targetE1RM} e1RM`);
|
|
97
141
|
}
|
|
98
142
|
}
|
|
99
143
|
|
|
144
|
+
if (ctx.previousCycles?.length > 0) {
|
|
145
|
+
lines.push('');
|
|
146
|
+
lines.push('Previous cycles:');
|
|
147
|
+
for (const pc of ctx.previousCycles) {
|
|
148
|
+
const summaryLine = pc.previousAISummary
|
|
149
|
+
? `\n Coach noted: "${pc.previousAISummary.split('\n')[0].slice(0, 120)}"`
|
|
150
|
+
: '';
|
|
151
|
+
lines.push(` Week ${pc.weekNumber}: ${pc.sessionCount} sessions, ${pc.totalVolume} kg total volume${summaryLine}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (ctx.exerciseTrends?.length > 0) {
|
|
156
|
+
lines.push('');
|
|
157
|
+
lines.push('Exercise trends (last 3 cycles):');
|
|
158
|
+
for (const et of ctx.exerciseTrends) {
|
|
159
|
+
const trendStr = et.trend.map((t) => t.e1RM).join(' → ');
|
|
160
|
+
lines.push(` ${et.exerciseName} e1RM: ${trendStr}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
100
164
|
return lines.join('\n');
|
|
101
165
|
}
|
|
102
166
|
|
|
103
|
-
export const WORKOUT_COACH_PROMPT = `You are
|
|
167
|
+
export const WORKOUT_COACH_PROMPT = `You are reviewing a training session log. Write 2-3 short paragraphs separated by blank lines.
|
|
168
|
+
|
|
169
|
+
Your job is to surface things the user wouldn't notice from glancing at their workout summary. The app already shows them PRs, total volume, effort score, and exercise breakdown — do NOT repeat any of that.
|
|
170
|
+
|
|
171
|
+
Focus on:
|
|
172
|
+
- Plan deviations: exercises swapped, skipped, or added vs the plan. Ask why if something looks unusual.
|
|
173
|
+
- Set completion: if they did fewer sets than planned on an exercise, note it and ask about it.
|
|
174
|
+
- Cross-session patterns: trends across recent sessions (volume direction on specific lifts, consistent cutoffs, same weight for weeks).
|
|
175
|
+
- Ask 1-2 genuine questions about choices that look interesting or unusual.
|
|
104
176
|
|
|
105
177
|
Rules:
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
- Compare volume or effort to recent sessions if the data is there. Just state what changed.
|
|
109
|
-
- End with one concrete suggestion for next session. Be specific about which exercise and what to try.
|
|
110
|
-
- Write like a real person texting a training partner — short sentences, no filler, no cheerleading.
|
|
111
|
-
- Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting.
|
|
178
|
+
- Only state what the data shows. Never claim how something "felt" — you have numbers, not feelings.
|
|
179
|
+
- Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, smooth, controlled.
|
|
112
180
|
- Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
|
|
181
|
+
- Never say things "felt smooth", "felt controlled", "feels about average" — you cannot know this.
|
|
182
|
+
- Never restate PRs, total volume, or effort score — the user already sees these in the app.
|
|
183
|
+
- Write like a training partner looking at a logbook, not a motivational coach.
|
|
184
|
+
- Short sentences, no filler, no cheerleading. Questions are good.
|
|
113
185
|
- No bullet points or lists.`;
|
|
114
186
|
|
|
115
187
|
export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, model, timeoutMs } = {}) {
|
|
@@ -189,5 +261,24 @@ export function formatWorkoutContext(ctx) {
|
|
|
189
261
|
}
|
|
190
262
|
}
|
|
191
263
|
|
|
264
|
+
if (ctx.planComparison) {
|
|
265
|
+
const planLines = [];
|
|
266
|
+
if (ctx.planComparison.skipped.length > 0) {
|
|
267
|
+
planLines.push(` Skipped: ${ctx.planComparison.skipped.join(', ')}`);
|
|
268
|
+
}
|
|
269
|
+
if (ctx.planComparison.added.length > 0) {
|
|
270
|
+
planLines.push(` Added: ${ctx.planComparison.added.join(', ')}`);
|
|
271
|
+
}
|
|
272
|
+
for (const sc of ctx.planComparison.setsComparison) {
|
|
273
|
+
if (sc.completed !== sc.planned) {
|
|
274
|
+
planLines.push(` ${sc.exercise}: ${sc.completed}/${sc.planned} sets`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (planLines.length > 0) {
|
|
278
|
+
lines.push('Plan comparison:');
|
|
279
|
+
lines.push(...planLines);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
192
283
|
return lines.join('\n');
|
|
193
284
|
}
|
package/src/queries.js
CHANGED
|
@@ -2,6 +2,44 @@ function completionDateForSession(session) {
|
|
|
2
2
|
return session.completedAt ?? session.summary?.date ?? session.date;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
function buildPlanComparison(session, performedExercises, plannedExercises) {
|
|
6
|
+
if (!Array.isArray(plannedExercises) || plannedExercises.length === 0) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const plannedNames = plannedExercises.map((exercise) =>
|
|
11
|
+
normalizeExerciseName(exercise.name ?? exercise.exerciseName)
|
|
12
|
+
);
|
|
13
|
+
const performedNames = performedExercises.map((exercise) =>
|
|
14
|
+
normalizeExerciseName(exercise.exerciseName)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const skipped = plannedExercises
|
|
18
|
+
.filter((exercise) => !performedNames.includes(normalizeExerciseName(exercise.name ?? exercise.exerciseName)))
|
|
19
|
+
.map((exercise) => exercise.name ?? exercise.exerciseName);
|
|
20
|
+
|
|
21
|
+
const added = (session.exercises ?? [])
|
|
22
|
+
.filter((exercise) => !plannedNames.includes(normalizeExerciseName(exercise.name)))
|
|
23
|
+
.map((exercise) => exercise.name);
|
|
24
|
+
|
|
25
|
+
const setsComparison = plannedExercises
|
|
26
|
+
.filter((exercise) => performedNames.includes(normalizeExerciseName(exercise.name ?? exercise.exerciseName)))
|
|
27
|
+
.map((planned) => {
|
|
28
|
+
const plannedName = planned.name ?? planned.exerciseName;
|
|
29
|
+
const performed = (session.exercises ?? []).find(
|
|
30
|
+
(exercise) => normalizeExerciseName(exercise.name) === normalizeExerciseName(plannedName)
|
|
31
|
+
);
|
|
32
|
+
const completedSets = (performed?.sets ?? []).filter((set) => set.isComplete).length;
|
|
33
|
+
return {
|
|
34
|
+
exercise: plannedName,
|
|
35
|
+
planned: Array.isArray(planned.sets) ? planned.sets.length : (planned.targetSets ?? []).length,
|
|
36
|
+
completed: completedSets
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return { skipped, added, setsComparison };
|
|
41
|
+
}
|
|
42
|
+
|
|
5
43
|
function sessionSummary(session) {
|
|
6
44
|
return {
|
|
7
45
|
sessionId: session.id,
|
|
@@ -19,7 +57,8 @@ function sessionSummary(session) {
|
|
|
19
57
|
exerciseCount: (session.exercises ?? []).length,
|
|
20
58
|
recommendations: session.recommendations ?? {},
|
|
21
59
|
historicalContext: session.historicalContext ?? null,
|
|
22
|
-
prescriptionSnapshot: session.prescriptionSnapshot ?? null
|
|
60
|
+
prescriptionSnapshot: session.prescriptionSnapshot ?? null,
|
|
61
|
+
aiCoachNotes: session.aiCoachNotes ?? null
|
|
23
62
|
};
|
|
24
63
|
}
|
|
25
64
|
|
|
@@ -349,6 +388,43 @@ export function goalDetail(snapshot, planId) {
|
|
|
349
388
|
};
|
|
350
389
|
}
|
|
351
390
|
|
|
391
|
+
export function cycleSummaryList(snapshot, programId) {
|
|
392
|
+
const summaries = snapshot.cycleSummaries ?? [];
|
|
393
|
+
const filtered = programId
|
|
394
|
+
? summaries.filter((s) => s.programId === programId)
|
|
395
|
+
: summaries;
|
|
396
|
+
|
|
397
|
+
return [...filtered]
|
|
398
|
+
.sort((a, b) => String(b.completedDate).localeCompare(String(a.completedDate)))
|
|
399
|
+
.map((s) => ({
|
|
400
|
+
id: s.id,
|
|
401
|
+
programId: s.programId,
|
|
402
|
+
programName: s.programName,
|
|
403
|
+
completedDate: s.completedDate,
|
|
404
|
+
totalSetsCompleted: s.totalSetsCompleted ?? 0,
|
|
405
|
+
totalSetsPlanned: s.totalSetsPlanned ?? 0,
|
|
406
|
+
progressionCount: (s.progressionUpdates ?? []).length,
|
|
407
|
+
hasAISummary: !!s.aiSummary
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function cycleSummaryShow(snapshot, summaryId) {
|
|
412
|
+
const summaries = snapshot.cycleSummaries ?? [];
|
|
413
|
+
const summary = summaries.find((s) => s.id === summaryId);
|
|
414
|
+
if (!summary) return null;
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
id: summary.id,
|
|
418
|
+
programId: summary.programId,
|
|
419
|
+
programName: summary.programName,
|
|
420
|
+
completedDate: summary.completedDate,
|
|
421
|
+
totalSetsCompleted: summary.totalSetsCompleted ?? 0,
|
|
422
|
+
totalSetsPlanned: summary.totalSetsPlanned ?? 0,
|
|
423
|
+
progressionUpdates: summary.progressionUpdates ?? [],
|
|
424
|
+
aiSummary: summary.aiSummary ?? null
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
352
428
|
export function cycleSummaryContext(snapshot, programId) {
|
|
353
429
|
const programs = snapshot.programs ?? [];
|
|
354
430
|
const program = programId
|
|
@@ -376,6 +452,10 @@ export function cycleSummaryContext(snapshot, programId) {
|
|
|
376
452
|
|
|
377
453
|
if (cycleSessions.length === 0) return null;
|
|
378
454
|
|
|
455
|
+
const firstSession = cycleSessions[0];
|
|
456
|
+
const cycleIntent = firstSession?.historicalContext?.programProgressionType ?? null;
|
|
457
|
+
const adaptationNote = firstSession?.historicalContext?.latestAdaptationSummary ?? null;
|
|
458
|
+
|
|
379
459
|
const priorSessions = (snapshot.sessions ?? []).filter(
|
|
380
460
|
(s) =>
|
|
381
461
|
s.programId === program.id &&
|
|
@@ -452,6 +532,89 @@ export function cycleSummaryContext(snapshot, programId) {
|
|
|
452
532
|
};
|
|
453
533
|
});
|
|
454
534
|
|
|
535
|
+
// Group prior sessions by week number for multi-cycle data
|
|
536
|
+
const sessionsByWeek = new Map();
|
|
537
|
+
for (const s of priorSessions) {
|
|
538
|
+
const wk = s.historicalContext?.programWeekNumber;
|
|
539
|
+
if (wk == null) continue;
|
|
540
|
+
if (!sessionsByWeek.has(wk)) sessionsByWeek.set(wk, []);
|
|
541
|
+
sessionsByWeek.get(wk).push(s);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Build previous cycle summaries (last 2 weeks before current, most recent first)
|
|
545
|
+
const priorWeeks = [...sessionsByWeek.keys()].sort((a, b) => b - a).slice(0, 2);
|
|
546
|
+
const cycleSummariesArr = snapshot.cycleSummaries ?? [];
|
|
547
|
+
const programCycleSummaries = cycleSummariesArr
|
|
548
|
+
.filter((cs) => cs.programId === program.id)
|
|
549
|
+
.sort((a, b) => String(b.completedDate).localeCompare(String(a.completedDate)));
|
|
550
|
+
|
|
551
|
+
const previousCycles = priorWeeks.map((wk, idx) => {
|
|
552
|
+
const weekSessions = sessionsByWeek.get(wk);
|
|
553
|
+
const totalVolume = weekSessions.reduce((sum, s) => sum + (s.summary?.totalVolume ?? s.volume ?? 0), 0);
|
|
554
|
+
const matchingCycleSummary = programCycleSummaries[idx] ?? null;
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
weekNumber: wk,
|
|
558
|
+
sessionCount: weekSessions.length,
|
|
559
|
+
totalVolume: Math.round(totalVolume),
|
|
560
|
+
previousAISummary: matchingCycleSummary?.aiSummary ?? null
|
|
561
|
+
};
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// Build exercise e1RM trends across last 3 cycles (including current)
|
|
565
|
+
const currentExerciseKeys = new Set();
|
|
566
|
+
const exerciseDisplayNames = new Map();
|
|
567
|
+
for (const s of cycleSessions) {
|
|
568
|
+
for (const ex of s.exercises ?? []) {
|
|
569
|
+
const key = normalizeExerciseName(ex.name);
|
|
570
|
+
currentExerciseKeys.add(key);
|
|
571
|
+
if (!exerciseDisplayNames.has(key)) exerciseDisplayNames.set(key, ex.name);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const allWeeks = [...new Set([...priorWeeks, cycleWeekNumber])].sort((a, b) => a - b);
|
|
576
|
+
const trendWeeks = allWeeks.slice(-3);
|
|
577
|
+
const allSessions = [...priorSessions, ...cycleSessions];
|
|
578
|
+
|
|
579
|
+
const exerciseTrends = [];
|
|
580
|
+
for (const exKey of currentExerciseKeys) {
|
|
581
|
+
const trend = [];
|
|
582
|
+
for (const wk of trendWeeks) {
|
|
583
|
+
let bestE1RM = 0;
|
|
584
|
+
for (const s of allSessions) {
|
|
585
|
+
if ((s.historicalContext?.programWeekNumber ?? 0) !== wk) continue;
|
|
586
|
+
for (const ex of s.exercises ?? []) {
|
|
587
|
+
if (normalizeExerciseName(ex.name) !== exKey) continue;
|
|
588
|
+
for (const set of ex.sets ?? []) {
|
|
589
|
+
if (!set.isComplete) continue;
|
|
590
|
+
const score = Number(set.weight) * (1 + Number(set.reps) / 30);
|
|
591
|
+
if (score > bestE1RM) bestE1RM = score;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (bestE1RM > 0) {
|
|
596
|
+
trend.push({ week: wk, e1RM: Math.round(bestE1RM * 10) / 10 });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (trend.length >= 2) {
|
|
600
|
+
exerciseTrends.push({ exerciseName: exerciseDisplayNames.get(exKey), trend });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const swapCounts = new Map();
|
|
605
|
+
for (const session of cycleSessions) {
|
|
606
|
+
for (const exercise of session.exercises ?? []) {
|
|
607
|
+
if (exercise.swappedFrom) {
|
|
608
|
+
const key = `${exercise.swappedFrom}\u2192${exercise.name}`;
|
|
609
|
+
swapCounts.set(key, (swapCounts.get(key) ?? 0) + 1);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const swapPatterns = [...swapCounts.entries()].map(([key, count]) => {
|
|
614
|
+
const [original, replacement] = key.split('\u2192');
|
|
615
|
+
return { original, replacement, count };
|
|
616
|
+
});
|
|
617
|
+
|
|
455
618
|
let goalProgress = null;
|
|
456
619
|
const plans = snapshot.strengthPlans ?? [];
|
|
457
620
|
const activePlan = plans.find(
|
|
@@ -472,13 +635,33 @@ export function cycleSummaryContext(snapshot, programId) {
|
|
|
472
635
|
});
|
|
473
636
|
}
|
|
474
637
|
|
|
638
|
+
const matchingSummary = programCycleSummaries[0] ?? null;
|
|
639
|
+
|
|
640
|
+
const progressionDecisions = (matchingSummary?.progressionUpdates ?? []).map((u) => ({
|
|
641
|
+
exerciseName: u.exerciseName,
|
|
642
|
+
action: u.action ?? u.change ?? null,
|
|
643
|
+
detail: u.detail ?? null,
|
|
644
|
+
kind: u.kind ?? null
|
|
645
|
+
}));
|
|
646
|
+
|
|
647
|
+
const setCompletionRate = matchingSummary
|
|
648
|
+
? { completed: matchingSummary.totalSetsCompleted ?? 0, planned: matchingSummary.totalSetsPlanned ?? 0 }
|
|
649
|
+
: null;
|
|
650
|
+
|
|
475
651
|
return {
|
|
476
652
|
programName: program.name,
|
|
477
653
|
cycleNumber: cycleWeekNumber,
|
|
478
654
|
totalSessions: cycleSessions.length,
|
|
479
655
|
sessions,
|
|
480
656
|
prsThisCycle,
|
|
481
|
-
goalProgress
|
|
657
|
+
goalProgress,
|
|
658
|
+
progressionDecisions,
|
|
659
|
+
setCompletionRate,
|
|
660
|
+
swapPatterns,
|
|
661
|
+
cycleIntent,
|
|
662
|
+
adaptationNote,
|
|
663
|
+
previousCycles,
|
|
664
|
+
exerciseTrends
|
|
482
665
|
};
|
|
483
666
|
}
|
|
484
667
|
|
|
@@ -563,7 +746,21 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
563
746
|
}
|
|
564
747
|
}
|
|
565
748
|
|
|
566
|
-
|
|
749
|
+
// Plan comparison — prefer the logged point-in-time prescription snapshot.
|
|
750
|
+
let planComparison;
|
|
751
|
+
if (session.prescriptionSnapshot?.exercises?.length > 0) {
|
|
752
|
+
planComparison = buildPlanComparison(session, exercises, session.prescriptionSnapshot.exercises);
|
|
753
|
+
} else if (session.programId) {
|
|
754
|
+
const program = (snapshot.programs ?? []).find(p => p.id === session.programId);
|
|
755
|
+
const matchingDay = Number.isInteger(session.programDayIndex)
|
|
756
|
+
? program?.days?.[session.programDayIndex]
|
|
757
|
+
: program?.days?.find(d => d.title === dayName);
|
|
758
|
+
if (matchingDay) {
|
|
759
|
+
planComparison = buildPlanComparison(session, exercises, matchingDay.exercises ?? []);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const result = {
|
|
567
764
|
sessionDate,
|
|
568
765
|
dayName,
|
|
569
766
|
totalVolume: session.summary?.totalVolume ?? session.volume ?? 0,
|
|
@@ -572,6 +769,8 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
572
769
|
recentComparisons,
|
|
573
770
|
prs
|
|
574
771
|
};
|
|
772
|
+
if (planComparison) result.planComparison = planComparison;
|
|
773
|
+
return result;
|
|
575
774
|
}
|
|
576
775
|
|
|
577
776
|
function requiredOption(options, primaryKey, legacyKey = null) {
|
|
@@ -676,5 +875,24 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
|
|
|
676
875
|
return { ok: true, payload };
|
|
677
876
|
}
|
|
678
877
|
|
|
878
|
+
if (normalizedCommand === 'cycle-summary-list') {
|
|
879
|
+
const programId = requiredOption(options, 'program-id');
|
|
880
|
+
return { ok: true, payload: cycleSummaryList(snapshot, programId ?? null) };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (normalizedCommand === 'cycle-summary-show') {
|
|
884
|
+
const summaryId = requiredOption(options, 'id');
|
|
885
|
+
if (!summaryId) {
|
|
886
|
+
return { ok: false, error: '--id is required' };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const payload = cycleSummaryShow(snapshot, summaryId);
|
|
890
|
+
if (!payload) {
|
|
891
|
+
return { ok: false, error: `Cycle summary not found: ${summaryId}` };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return { ok: true, payload };
|
|
895
|
+
}
|
|
896
|
+
|
|
679
897
|
return { ok: false, error: `Unknown read command: ${normalizedCommand}` };
|
|
680
898
|
}
|