incremnt 0.1.17 → 0.1.18
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 +13 -1
- package/src/format.js +41 -0
- package/src/mcp.js +2 -1
- package/src/openrouter.js +266 -59
- package/src/queries.js +1004 -50
- package/src/remote.js +6 -0
- package/src/sync-service.js +149 -15
package/package.json
CHANGED
package/src/contract.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const contractVersion =
|
|
1
|
+
export const contractVersion = 4;
|
|
2
2
|
|
|
3
3
|
export const capabilities = {
|
|
4
4
|
readOnly: false,
|
|
@@ -124,6 +124,18 @@ export const commandSchema = [
|
|
|
124
124
|
{ name: 'days', type: 'number', required: false, description: 'Last N days (default: 14)' }
|
|
125
125
|
]
|
|
126
126
|
},
|
|
127
|
+
{
|
|
128
|
+
command: 'health ai',
|
|
129
|
+
id: 'health-ai',
|
|
130
|
+
description: 'Latest AI vitals summary and history',
|
|
131
|
+
options: []
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
command: 'training load',
|
|
135
|
+
id: 'training-load',
|
|
136
|
+
description: 'Training load analysis (7-day vs 28-day comparison, effort scores, readiness via ATL/CTL/TSB, per-type breakdown)',
|
|
137
|
+
options: []
|
|
138
|
+
},
|
|
127
139
|
{
|
|
128
140
|
command: 'ask history',
|
|
129
141
|
id: 'ask-history',
|
package/src/format.js
CHANGED
|
@@ -463,6 +463,45 @@ function formatCycleSummaryShow(payload) {
|
|
|
463
463
|
return lines.join('\n');
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
+
function formatAskHistory(payload) {
|
|
467
|
+
const conversations = payload?.conversations;
|
|
468
|
+
if (!Array.isArray(conversations) || conversations.length === 0) {
|
|
469
|
+
return 'No conversations found.';
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const lines = [header('COACH CONVERSATIONS'), ''];
|
|
473
|
+
|
|
474
|
+
for (const c of conversations) {
|
|
475
|
+
const date = formatShortDate(c.createdAt);
|
|
476
|
+
const preview = c.preview || chalk.dim('(no preview)');
|
|
477
|
+
const msgs = `${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}`;
|
|
478
|
+
lines.push(` ${chalk.bold(date)} ${preview}${dimDot()}${chalk.dim(msgs)}${dimDot()}${chalk.dim(c.id)}`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return lines.join('\n');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function formatAskShow(payload) {
|
|
485
|
+
if (!payload || !Array.isArray(payload.messages)) {
|
|
486
|
+
return 'Conversation not found.';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const date = formatShortDate(payload.createdAt);
|
|
490
|
+
const lines = [` ${chalk.bold('CONVERSATION')}${dimDot()}${date}`, ''];
|
|
491
|
+
|
|
492
|
+
for (const msg of payload.messages) {
|
|
493
|
+
const label = msg.role === 'user' ? chalk.cyan('You') : chalk.green('Coach');
|
|
494
|
+
lines.push(` ${label}`);
|
|
495
|
+
for (const line of (msg.content ?? '').split('\n')) {
|
|
496
|
+
lines.push(` ${line}`);
|
|
497
|
+
}
|
|
498
|
+
lines.push('');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (lines.at(-1) === '') lines.pop();
|
|
502
|
+
return lines.join('\n');
|
|
503
|
+
}
|
|
504
|
+
|
|
466
505
|
function formatProposalDismissed(payload) {
|
|
467
506
|
if (!payload) return 'Proposal not found.';
|
|
468
507
|
return ` Proposal ${chalk.bold(payload.id)} dismissed.`;
|
|
@@ -526,6 +565,8 @@ export function formatPretty(command, payload) {
|
|
|
526
565
|
'why-did-this-change': formatWhyDidThisChange,
|
|
527
566
|
'cycle-summary-list': formatCycleSummaryList,
|
|
528
567
|
'cycle-summary-show': formatCycleSummaryShow,
|
|
568
|
+
'ask-history': formatAskHistory,
|
|
569
|
+
'ask-show': formatAskShow,
|
|
529
570
|
'programs-propose': formatProposalCreated,
|
|
530
571
|
'programs-proposals': formatProposalsList,
|
|
531
572
|
'proposal-dismiss': formatProposalDismissed
|
package/src/mcp.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import fs from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -82,6 +83,6 @@ async function main() {
|
|
|
82
83
|
await server.connect(transport);
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
86
|
+
if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
86
87
|
await main();
|
|
87
88
|
}
|
package/src/openrouter.js
CHANGED
|
@@ -1,68 +1,108 @@
|
|
|
1
1
|
const SUMMARY_MODEL_CHAIN = [
|
|
2
|
-
'deepseek/deepseek-v3.2
|
|
2
|
+
'deepseek/deepseek-v3.2',
|
|
3
3
|
'anthropic/claude-3.5-haiku'
|
|
4
4
|
];
|
|
5
5
|
const ASK_MODEL_CHAIN = [
|
|
6
6
|
'anthropic/claude-3.5-haiku',
|
|
7
|
-
'deepseek/deepseek-v3.2
|
|
7
|
+
'deepseek/deepseek-v3.2'
|
|
8
8
|
];
|
|
9
|
-
const TIMEOUT_PER_MODEL_MS =
|
|
10
|
-
const ASK_TIMEOUT_MS =
|
|
9
|
+
const TIMEOUT_PER_MODEL_MS = 15_000;
|
|
10
|
+
const ASK_TIMEOUT_MS = 15_000;
|
|
11
11
|
const DEFAULT_MAX_TOKENS = 500;
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
function callModel(model, messages, { apiKey, temperature, maxTokens, timeoutMs, signal }) {
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
16
|
+
if (signal) signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
17
|
+
const start = Date.now();
|
|
18
|
+
|
|
19
|
+
return fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: {
|
|
22
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
23
|
+
'Content-Type': 'application/json',
|
|
24
|
+
'HTTP-Referer': 'https://incremnt.app',
|
|
25
|
+
'X-Title': 'incremnt'
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
model,
|
|
29
|
+
messages,
|
|
30
|
+
max_tokens: maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
31
|
+
temperature: temperature ?? 0.5
|
|
32
|
+
}),
|
|
33
|
+
signal: controller.signal
|
|
34
|
+
}).then(async (response) => {
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const text = await response.text().catch(() => '');
|
|
37
|
+
throw new Error(`OpenRouter API error ${response.status}: ${text}`);
|
|
38
|
+
}
|
|
39
|
+
const data = await response.json();
|
|
40
|
+
const content = data.choices?.[0]?.message?.content;
|
|
41
|
+
if (!content) {
|
|
42
|
+
throw new Error('No content in OpenRouter response');
|
|
43
|
+
}
|
|
44
|
+
return { text: content.trim(), model, durationMs: Date.now() - start };
|
|
45
|
+
}).catch((err) => {
|
|
46
|
+
if (err.name === 'AbortError' && signal?.aborted) return null; // cancelled by race winner
|
|
47
|
+
err.model = err.model ?? model;
|
|
48
|
+
err.durationMs = err.durationMs ?? (Date.now() - start);
|
|
49
|
+
throw err;
|
|
50
|
+
}).finally(() => {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function callOpenRouter(messages, { apiKey, models, temperature, maxTokens, timeoutMs, race }) {
|
|
14
56
|
const chain = models ?? SUMMARY_MODEL_CHAIN;
|
|
15
57
|
const timeout = timeoutMs ?? TIMEOUT_PER_MODEL_MS;
|
|
16
|
-
const errors = [];
|
|
17
|
-
|
|
18
58
|
const startTotal = Date.now();
|
|
59
|
+
const opts = { apiKey, temperature, maxTokens, timeoutMs: timeout };
|
|
19
60
|
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
61
|
+
if (race && chain.length > 1) {
|
|
62
|
+
const raceController = new AbortController();
|
|
63
|
+
const promises = chain.map((model) =>
|
|
64
|
+
callModel(model, messages, { ...opts, signal: raceController.signal })
|
|
65
|
+
);
|
|
25
66
|
try {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (!content) {
|
|
51
|
-
throw new Error('No content in OpenRouter response');
|
|
52
|
-
}
|
|
67
|
+
const result = await Promise.any(promises);
|
|
68
|
+
raceController.abort(); // cancel losers
|
|
69
|
+
// Collect errors from models that failed before the winner resolved
|
|
70
|
+
const settled = await Promise.allSettled(promises);
|
|
71
|
+
const errors = settled
|
|
72
|
+
.filter((s) => s.status === 'rejected')
|
|
73
|
+
.map((s) => ({ model: s.reason.model, error: s.reason.message, durationMs: s.reason.durationMs }));
|
|
74
|
+
return {
|
|
75
|
+
...result,
|
|
76
|
+
fallback: result.model !== chain[0],
|
|
77
|
+
durationMs: Date.now() - startTotal,
|
|
78
|
+
errors: errors.length > 0 ? errors : undefined
|
|
79
|
+
};
|
|
80
|
+
} catch (aggregate) {
|
|
81
|
+
const errors = aggregate.errors.map((e) => ({
|
|
82
|
+
model: e.model, error: e.message, durationMs: e.durationMs
|
|
83
|
+
}));
|
|
84
|
+
errors.forEach((e) => console.error(`OpenRouter ${e.model} failed (${e.durationMs}ms): ${e.error}`));
|
|
85
|
+
const err = new Error(`All models failed: ${errors.map((e) => `${e.model}: ${e.error}`).join('; ')}`);
|
|
86
|
+
err.modelErrors = errors;
|
|
87
|
+
err.durationMs = Date.now() - startTotal;
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
53
91
|
|
|
92
|
+
// Sequential fallback (for single-model calls or explicit sequential mode)
|
|
93
|
+
const errors = [];
|
|
94
|
+
for (const model of chain) {
|
|
95
|
+
try {
|
|
96
|
+
const result = await callModel(model, messages, opts);
|
|
54
97
|
return {
|
|
55
|
-
|
|
56
|
-
model,
|
|
98
|
+
...result,
|
|
57
99
|
fallback: model !== chain[0],
|
|
58
100
|
durationMs: Date.now() - startTotal,
|
|
59
101
|
errors: errors.length > 0 ? errors : undefined
|
|
60
102
|
};
|
|
61
103
|
} catch (err) {
|
|
62
|
-
errors.push({ model, error: err.message, durationMs:
|
|
63
|
-
console.error(`OpenRouter ${model} failed (${
|
|
64
|
-
} finally {
|
|
65
|
-
clearTimeout(timer);
|
|
104
|
+
errors.push({ model: err.model, error: err.message, durationMs: err.durationMs });
|
|
105
|
+
console.error(`OpenRouter ${err.model} failed (${err.durationMs}ms): ${err.message}`);
|
|
66
106
|
}
|
|
67
107
|
}
|
|
68
108
|
|
|
@@ -78,7 +118,7 @@ Your job is to give a cycle-level review — not a session-by-session recap. The
|
|
|
78
118
|
|
|
79
119
|
The data tells the story — your job is to interpret it honestly, not to make the trainee feel good.
|
|
80
120
|
|
|
81
|
-
Cover these in order of relevance (skip any that don't apply):
|
|
121
|
+
Cover these in order of relevance (skip any that don't apply). If "Priority signals (ranked)" are present in context, treat them as the ordering anchor:
|
|
82
122
|
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.
|
|
83
123
|
2. Progression commentary: the app made auto-progression decisions listed below. Comment on whether they look right given the data.
|
|
84
124
|
3. Multi-cycle trends: if previous cycle data is provided, note meaningful trends. Don't force trends where there aren't any.
|
|
@@ -89,18 +129,32 @@ Only state what the data shows. Never claim how something "felt." Reference spec
|
|
|
89
129
|
|
|
90
130
|
If you catch yourself writing something that sounds like a performance review or a fitness influencer post, rewrite it. No -ing clauses that add fake depth. No bullet points or lists.`;
|
|
91
131
|
|
|
132
|
+
export const FIRST_WEEK_CYCLE_PROMPT = `You are a strength coach reviewing a trainee's first completed week on a new program. Write 2-3 short paragraphs separated by blank lines.
|
|
133
|
+
|
|
134
|
+
This is their first week — there are no prior cycles to compare against, no trends to analyze, and no progression history yet. Do NOT try to identify trends, compare to previous weeks, or analyze progression patterns. There is nothing to compare to.
|
|
135
|
+
|
|
136
|
+
Your job is to acknowledge the work they put in this week, referencing specific exercises and numbers from the data so it is obvious you actually looked at what they did. Set expectations clearly: this week is the baseline, and from next week onward you'll be able to track trends, flag plateaus, and give real coaching feedback. If there are PRs listed, mention them, but frame them as first-week baselines rather than breakthroughs.
|
|
137
|
+
|
|
138
|
+
Keep it short and direct. No fake enthusiasm, no cheerleading, no "great job!" filler. But do be genuinely encouraging — they showed up and logged real work, which is the hardest part. A matter-of-fact "solid first week" tone is right.
|
|
139
|
+
|
|
140
|
+
Write like a training partner, not a motivational poster. Short sentences, no filler. If you catch yourself writing something that sounds like a fitness influencer post, rewrite it. No bullet points or lists.`;
|
|
141
|
+
|
|
92
142
|
export async function generateCoachingSummary(cycleContext, { apiKey, model, timeoutMs } = {}) {
|
|
93
143
|
const userContent = formatCycleContext(cycleContext);
|
|
144
|
+
const isFirstWeek = cycleContext.cycleNumber === 1
|
|
145
|
+
&& (!cycleContext.previousCycles || cycleContext.previousCycles.length === 0);
|
|
146
|
+
const systemPrompt = isFirstWeek ? FIRST_WEEK_CYCLE_PROMPT : CYCLE_SUMMARY_PROMPT;
|
|
94
147
|
return callOpenRouter(
|
|
95
148
|
[
|
|
96
|
-
{ role: 'system', content:
|
|
149
|
+
{ role: 'system', content: systemPrompt },
|
|
97
150
|
{ role: 'user', content: userContent }
|
|
98
151
|
],
|
|
99
152
|
{
|
|
100
153
|
apiKey,
|
|
101
154
|
models: model ? [model] : SUMMARY_MODEL_CHAIN,
|
|
102
155
|
temperature: 0.5,
|
|
103
|
-
timeoutMs
|
|
156
|
+
timeoutMs,
|
|
157
|
+
race: !model
|
|
104
158
|
}
|
|
105
159
|
);
|
|
106
160
|
}
|
|
@@ -111,6 +165,15 @@ export function formatCycleContext(ctx) {
|
|
|
111
165
|
`Program: ${ctx.programName}, Week ${ctx.cycleNumber}${intentLabel}, ${ctx.totalSessions} session(s).`
|
|
112
166
|
];
|
|
113
167
|
|
|
168
|
+
if (ctx.prioritySignals?.length > 0) {
|
|
169
|
+
lines.push('');
|
|
170
|
+
lines.push('Priority signals (ranked):');
|
|
171
|
+
for (const signal of ctx.prioritySignals) {
|
|
172
|
+
lines.push(` [${signal.rank}] ${signal.summary} (score ${signal.score})`);
|
|
173
|
+
if (signal.detail) lines.push(` ${signal.detail}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
114
177
|
if (ctx.setCompletionRate) {
|
|
115
178
|
const pct = ctx.setCompletionRate.planned > 0
|
|
116
179
|
? Math.round(ctx.setCompletionRate.completed / ctx.setCompletionRate.planned * 100)
|
|
@@ -136,7 +199,9 @@ export function formatCycleContext(ctx) {
|
|
|
136
199
|
? `${ex.completedSets}/${ex.plannedSets} sets`
|
|
137
200
|
: `${ex.completedSets} sets`;
|
|
138
201
|
const topPart = ex.topSet
|
|
139
|
-
?
|
|
202
|
+
? ex.isBodyweight
|
|
203
|
+
? ` (top: BW×${ex.topSet.reps})`
|
|
204
|
+
: ` (top: ${ex.topSet.weight}×${ex.topSet.reps})`
|
|
140
205
|
: '';
|
|
141
206
|
lines.push(` ${ex.exerciseName}: ${setPart}${topPart}`);
|
|
142
207
|
}
|
|
@@ -150,6 +215,16 @@ export function formatCycleContext(ctx) {
|
|
|
150
215
|
}
|
|
151
216
|
}
|
|
152
217
|
|
|
218
|
+
if (ctx.bwPrsThisCycle?.length > 0) {
|
|
219
|
+
if (ctx.prsThisCycle.length === 0) {
|
|
220
|
+
lines.push('');
|
|
221
|
+
lines.push('PRs this cycle:');
|
|
222
|
+
}
|
|
223
|
+
for (const pr of ctx.bwPrsThisCycle) {
|
|
224
|
+
lines.push(` ${pr.exerciseName}: BW×${pr.reps} (prev best ${pr.previousBest} reps)`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
153
228
|
if (ctx.progressionDecisions?.length > 0) {
|
|
154
229
|
lines.push('');
|
|
155
230
|
lines.push('Progression decisions made by the app:');
|
|
@@ -191,8 +266,17 @@ export function formatCycleContext(ctx) {
|
|
|
191
266
|
lines.push('');
|
|
192
267
|
lines.push('Exercise trends (last 3 cycles):');
|
|
193
268
|
for (const et of ctx.exerciseTrends) {
|
|
194
|
-
|
|
195
|
-
|
|
269
|
+
if (et.isBodyweight) {
|
|
270
|
+
const trendStr = et.trend.map((t) =>
|
|
271
|
+
t.bestReps != null ? `${t.bestReps} reps` : `${t.e1RM} e1RM`
|
|
272
|
+
).join(' → ');
|
|
273
|
+
lines.push(` ${et.exerciseName} (BW) best reps: ${trendStr}`);
|
|
274
|
+
} else {
|
|
275
|
+
const trendStr = et.trend.map((t) =>
|
|
276
|
+
t.e1RM != null ? t.e1RM : `${t.bestReps} reps`
|
|
277
|
+
).join(' → ');
|
|
278
|
+
lines.push(` ${et.exerciseName} e1RM: ${trendStr}`);
|
|
279
|
+
}
|
|
196
280
|
}
|
|
197
281
|
}
|
|
198
282
|
|
|
@@ -226,11 +310,13 @@ export const WORKOUT_COACH_PROMPT = `You are reviewing a training session log. W
|
|
|
226
310
|
|
|
227
311
|
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. The data tells the story — your job is to interpret it honestly, not to make the user feel good.
|
|
228
312
|
|
|
229
|
-
Focus on plan deviations (exercises swapped, skipped, or added vs the plan), set completion (if they did fewer sets than planned, note it and ask about it), and cross-session patterns (volume direction on specific lifts, consistent cutoffs, same weight for weeks). If exercises are marked "
|
|
313
|
+
Focus on plan deviations (exercises swapped, skipped, or added vs the plan), set completion (if they did fewer sets than planned, note it and ask about it), and cross-session patterns (volume direction on specific lifts, consistent cutoffs, same weight for weeks). Use "Priority signals (ranked)" as the first pass for what to address. If exercises are marked "no prior sessions logged" they have zero prior history for that exact exercise — state this plainly. If the context says the program changed since the previous session, treat new exercises as part of that switch instead of framing them as unexplained experimentation. If this is an adhoc session, note any overlap with programmed exercises.
|
|
314
|
+
|
|
315
|
+
The app generates and assigns training programs automatically — the user does not choose them. Never ask why the user picked or switched to a particular program. If a program change occurred, acknowledge it factually and focus on how the new exercises went, not why the change was made.
|
|
230
316
|
|
|
231
|
-
Never name an exercise that does not appear in the workout data below.
|
|
317
|
+
Never name an exercise that does not appear in the workout data below. "No prior sessions logged" means no prior history for that exact exercise. It does not mean the user switched from another exercise unless the context explicitly shows a program change.
|
|
232
318
|
|
|
233
|
-
Ask 1-2 genuine questions about
|
|
319
|
+
Ask 1-2 genuine questions about in-workout decisions that look interesting or unusual, like swaps, cutoffs, repeated loads, or unexpected set outcomes. This is the most valuable thing you can do — a good question is worth more than restating what happened.
|
|
234
320
|
|
|
235
321
|
Only state what the data shows. Never claim how something "felt." Be specific — use numbers and exercise names. Don't soften with "suggests", "appears to", "seems", "might." State it. Don't start sentences with "The session shows", "Your performance indicates", "It's worth noting." Just say it.
|
|
236
322
|
|
|
@@ -247,7 +333,8 @@ export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, m
|
|
|
247
333
|
apiKey,
|
|
248
334
|
models: model ? [model] : SUMMARY_MODEL_CHAIN,
|
|
249
335
|
temperature: 0.5,
|
|
250
|
-
timeoutMs
|
|
336
|
+
timeoutMs,
|
|
337
|
+
race: !model
|
|
251
338
|
}
|
|
252
339
|
);
|
|
253
340
|
}
|
|
@@ -258,16 +345,28 @@ export function formatWorkoutContext(ctx) {
|
|
|
258
345
|
: `Session: ${ctx.dayName}, ${ctx.sessionDate}, program "${ctx.programName}", ${ctx.totalVolume} kg total volume.`;
|
|
259
346
|
const lines = [sessionLabel];
|
|
260
347
|
|
|
348
|
+
if (ctx.prioritySignals?.length > 0) {
|
|
349
|
+
lines.push('Priority signals (ranked):');
|
|
350
|
+
for (const signal of ctx.prioritySignals) {
|
|
351
|
+
lines.push(` [${signal.rank}] ${signal.summary} (score ${signal.score})`);
|
|
352
|
+
if (signal.detail) lines.push(` ${signal.detail}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
261
356
|
if (ctx.effortScore) {
|
|
262
357
|
lines.push(`Effort rating: ${ctx.effortScore}/10.`);
|
|
263
358
|
}
|
|
264
359
|
|
|
265
360
|
lines.push('Exercises:');
|
|
266
361
|
for (const ex of ctx.exercises) {
|
|
267
|
-
const topPart = ex.topSet
|
|
362
|
+
const topPart = ex.topSet
|
|
363
|
+
? ex.isBodyweight
|
|
364
|
+
? ` (top: BW×${ex.topSet.reps})`
|
|
365
|
+
: ` (top: ${ex.topSet.weight}×${ex.topSet.reps})`
|
|
366
|
+
: '';
|
|
268
367
|
const historyPart = ex.priorSessions === 0
|
|
269
|
-
? '
|
|
270
|
-
: `
|
|
368
|
+
? ' (no prior sessions logged)'
|
|
369
|
+
: ` (${ex.priorSessions} prior sessions)`;
|
|
271
370
|
lines.push(` ${ex.exerciseName}: ${ex.completedSets} sets${topPart}${historyPart}`);
|
|
272
371
|
}
|
|
273
372
|
|
|
@@ -278,6 +377,20 @@ export function formatWorkoutContext(ctx) {
|
|
|
278
377
|
}
|
|
279
378
|
}
|
|
280
379
|
|
|
380
|
+
if (ctx.bwPrs?.length > 0) {
|
|
381
|
+
lines.push('Bodyweight rep PRs this session:');
|
|
382
|
+
for (const pr of ctx.bwPrs) {
|
|
383
|
+
lines.push(` ${pr.exerciseName}: BW×${pr.reps} (prev best ${pr.previousBest} reps)`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (ctx.repPrs?.length > 0) {
|
|
388
|
+
lines.push('Rep PRs this session (most reps at weight or higher):');
|
|
389
|
+
for (const pr of ctx.repPrs) {
|
|
390
|
+
lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
281
394
|
if (ctx.recentComparisons.length > 0) {
|
|
282
395
|
lines.push('Recent same-day sessions for comparison:');
|
|
283
396
|
for (const comp of ctx.recentComparisons) {
|
|
@@ -306,6 +419,16 @@ export function formatWorkoutContext(ctx) {
|
|
|
306
419
|
}
|
|
307
420
|
}
|
|
308
421
|
|
|
422
|
+
if (ctx.programChange) {
|
|
423
|
+
const previousProgram = ctx.programChange.previousProgramName ?? 'adhoc training';
|
|
424
|
+
const currentProgram = ctx.programChange.currentProgramName ?? 'adhoc training';
|
|
425
|
+
lines.push(
|
|
426
|
+
`Program change since previous session: ${ctx.programChange.previousSessionDate} ` +
|
|
427
|
+
`${ctx.programChange.previousDayName} in "${previousProgram}" -> ` +
|
|
428
|
+
`${ctx.programChange.currentDayName} in "${currentProgram}".`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
309
432
|
// Recovery context
|
|
310
433
|
const recoveryParts = [];
|
|
311
434
|
if (ctx.restingHROnDay) recoveryParts.push(`resting HR ${Math.round(ctx.restingHROnDay)} bpm`);
|
|
@@ -326,6 +449,88 @@ export function formatWorkoutContext(ctx) {
|
|
|
326
449
|
if (w.effortScore) parts.push(`effort ${w.effortScore}/10`);
|
|
327
450
|
lines.push(` ${w.date} ${w.workoutType}: ${parts.join(', ')}`);
|
|
328
451
|
}
|
|
452
|
+
const totalSecs = ctx.nearbyCardio.reduce((sum, w) => sum + (w.durationSecs ?? 0), 0);
|
|
453
|
+
const totalMins = Math.round(totalSecs / 60);
|
|
454
|
+
const totalKm = ctx.nearbyCardio.reduce((sum, w) => sum + (w.distanceKm ?? 0), 0);
|
|
455
|
+
const distPart = totalKm > 0 ? `, ${totalKm.toFixed(1)} km total` : '';
|
|
456
|
+
lines.push(` Total: ${ctx.nearbyCardio.length} sessions, ${totalMins} min${distPart}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return lines.join('\n');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const VITALS_SUMMARY_PROMPT = `You are a concise fitness recovery coach. Given a user's current health vitals and recent training data, write a 2-3 sentence morning summary. Be direct and actionable. Focus on what matters today: recovery status, readiness to train, and any notable changes. If "Priority signals" are present, anchor your summary on those first. Do not list numbers — interpret them. If data is missing, focus on what's available. Never give medical advice.`;
|
|
463
|
+
|
|
464
|
+
export async function generateVitalsSummary(context, { apiKey, model, timeoutMs } = {}) {
|
|
465
|
+
return callOpenRouter(
|
|
466
|
+
[
|
|
467
|
+
{ role: 'system', content: VITALS_SUMMARY_PROMPT },
|
|
468
|
+
{ role: 'user', content: context }
|
|
469
|
+
],
|
|
470
|
+
{
|
|
471
|
+
apiKey,
|
|
472
|
+
models: model ? [model] : SUMMARY_MODEL_CHAIN,
|
|
473
|
+
temperature: 0.5,
|
|
474
|
+
maxTokens: 200,
|
|
475
|
+
timeoutMs,
|
|
476
|
+
race: !model
|
|
477
|
+
}
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export const CHECKPOINT_SUMMARY_PROMPT = `You are a strength coach reviewing a trainee's mid-plan checkpoint. They are partway through an 8-week strength plan with specific e1RM targets for each lift. Write 2-3 short paragraphs separated by blank lines.
|
|
482
|
+
|
|
483
|
+
Your job is to assess goal trajectory — are they on pace, ahead, or behind for each lift target? The app already shows raw numbers and progress bars — do NOT repeat those. Synthesize across exercises and identify patterns.
|
|
484
|
+
|
|
485
|
+
Cover in order of relevance (skip any that don't apply):
|
|
486
|
+
1. Overall trajectory: given current progress vs expected linear pace, will they hit their 8-week targets? Be honest if some goals look unrealistic at this point.
|
|
487
|
+
2. Exercise-level detail: which lifts are behind and why that might be (frequency, fatigue, technique plateau). Which are ahead. If this is a week 6 checkpoint and week 3 data is available, note acceleration or deceleration since then.
|
|
488
|
+
3. Actionable suggestions for the remaining weeks. Be specific — name exercises, rep ranges, or frequency changes. One or two concrete things, not a laundry list.
|
|
489
|
+
|
|
490
|
+
Only state what the data shows. Never claim how something "felt." Reference specific exercises, weights, and percentages — use numbers, not vague descriptions. Write like a training partner looking at a logbook. Short sentences, no filler, no cheerleading. If a goal is already hit, say so and suggest what to do with the remaining weeks.
|
|
491
|
+
|
|
492
|
+
If you catch yourself writing something that sounds like a performance review or a fitness influencer post, rewrite it. No -ing clauses that add fake depth. No bullet points or lists.`;
|
|
493
|
+
|
|
494
|
+
export async function generateCheckpointSummary(checkpointContext, { apiKey, model, timeoutMs } = {}) {
|
|
495
|
+
const userContent = formatCheckpointContext(checkpointContext);
|
|
496
|
+
return callOpenRouter(
|
|
497
|
+
[
|
|
498
|
+
{ role: 'system', content: CHECKPOINT_SUMMARY_PROMPT },
|
|
499
|
+
{ role: 'user', content: userContent }
|
|
500
|
+
],
|
|
501
|
+
{
|
|
502
|
+
apiKey,
|
|
503
|
+
models: model ? [model] : SUMMARY_MODEL_CHAIN,
|
|
504
|
+
temperature: 0.5,
|
|
505
|
+
timeoutMs,
|
|
506
|
+
race: !model
|
|
507
|
+
}
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function formatCheckpointContext(ctx) {
|
|
512
|
+
const lines = [
|
|
513
|
+
`Program: ${ctx.programName}, Checkpoint at week ${ctx.checkpointWeek} of ${ctx.totalWeeks}.`
|
|
514
|
+
];
|
|
515
|
+
|
|
516
|
+
lines.push('');
|
|
517
|
+
lines.push('Exercise targets:');
|
|
518
|
+
for (const ex of ctx.exercises) {
|
|
519
|
+
const gained = ex.currentE1RM - ex.startingE1RM;
|
|
520
|
+
const gainedStr = gained >= 0 ? `+${gained.toFixed(1)}` : gained.toFixed(1);
|
|
521
|
+
lines.push(` ${ex.name}: ${ex.startingE1RM} → ${ex.currentE1RM} (target ${ex.targetE1RM}), ${ex.progressPercent}% done, ${ex.status} (${gainedStr} kg)`);
|
|
522
|
+
if (ex.deltaFromLastCheckpoint != null) {
|
|
523
|
+
lines.push(` Since week 3 checkpoint: ${ex.deltaFromLastCheckpoint >= 0 ? '+' : ''}${ex.deltaFromLastCheckpoint.toFixed(1)} kg`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (ctx.previousCycleNotes.length > 0) {
|
|
528
|
+
lines.push('');
|
|
529
|
+
lines.push('Recent cycle coach notes:');
|
|
530
|
+
for (const note of ctx.previousCycleNotes) {
|
|
531
|
+
const firstLine = note.split('\n')[0].slice(0, 150);
|
|
532
|
+
lines.push(` "${firstLine}"`);
|
|
533
|
+
}
|
|
329
534
|
}
|
|
330
535
|
|
|
331
536
|
return lines.join('\n');
|
|
@@ -336,6 +541,7 @@ export const ASK_PROMPT = `You are a strength coach answering questions from the
|
|
|
336
541
|
Rules:
|
|
337
542
|
- Use only the data provided. If the data does not support a claim, do not make it.
|
|
338
543
|
- Focus on trend, weak points, tradeoffs, and next steps. Be specific with exercises, weights, reps, volume, and timing when relevant.
|
|
544
|
+
- If the context includes "Priority signals", prioritize those before broader commentary.
|
|
339
545
|
- Match the response length to the question. Short or playful prompts get a short conversational reply plus an invitation to ask something specific.
|
|
340
546
|
- Keep the tone natural and direct. No hype, no filler, no emoji, no "let's dive in", no performance-review language.
|
|
341
547
|
- Never name an exercise that does not appear in the training data below.
|
|
@@ -365,7 +571,8 @@ export async function generateAskAnswer(context, question, { apiKey, model, time
|
|
|
365
571
|
apiKey,
|
|
366
572
|
models: model ? [model] : ASK_MODEL_CHAIN,
|
|
367
573
|
temperature: 0.3,
|
|
368
|
-
timeoutMs: timeoutMs ?? ASK_TIMEOUT_MS
|
|
574
|
+
timeoutMs: timeoutMs ?? ASK_TIMEOUT_MS,
|
|
575
|
+
race: !model
|
|
369
576
|
}
|
|
370
577
|
);
|
|
371
578
|
}
|