incremnt 0.1.13 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/openrouter.js CHANGED
@@ -1,11 +1,83 @@
1
- const DEFAULT_MODEL = 'meta-llama/llama-3.1-8b-instruct:free';
2
- const DEFAULT_TIMEOUT_MS = 15_000;
1
+ const SUMMARY_MODEL_CHAIN = [
2
+ 'deepseek/deepseek-v3.2-20251201',
3
+ 'anthropic/claude-3.5-haiku'
4
+ ];
5
+ const ASK_MODEL_CHAIN = [
6
+ 'anthropic/claude-3.5-haiku',
7
+ 'deepseek/deepseek-v3.2-20251201'
8
+ ];
9
+ const TIMEOUT_PER_MODEL_MS = 12_000;
10
+ const ASK_TIMEOUT_MS = 8_000;
3
11
  const DEFAULT_MAX_TOKENS = 500;
4
12
 
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.
13
+ async function callOpenRouter(messages, { apiKey, models, temperature, maxTokens, timeoutMs }) {
14
+ const chain = models ?? SUMMARY_MODEL_CHAIN;
15
+ const timeout = timeoutMs ?? TIMEOUT_PER_MODEL_MS;
16
+ const errors = [];
17
+
18
+ const startTotal = Date.now();
19
+
20
+ for (const model of chain) {
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), timeout);
23
+ const startModel = Date.now();
24
+
25
+ try {
26
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
27
+ method: 'POST',
28
+ headers: {
29
+ 'Authorization': `Bearer ${apiKey}`,
30
+ 'Content-Type': 'application/json',
31
+ 'HTTP-Referer': 'https://incremnt.app',
32
+ 'X-Title': 'incremnt'
33
+ },
34
+ body: JSON.stringify({
35
+ model,
36
+ messages,
37
+ max_tokens: maxTokens ?? DEFAULT_MAX_TOKENS,
38
+ temperature: temperature ?? 0.5
39
+ }),
40
+ signal: controller.signal
41
+ });
42
+
43
+ if (!response.ok) {
44
+ const text = await response.text().catch(() => '');
45
+ throw new Error(`OpenRouter API error ${response.status}: ${text}`);
46
+ }
47
+
48
+ const data = await response.json();
49
+ const content = data.choices?.[0]?.message?.content;
50
+ if (!content) {
51
+ throw new Error('No content in OpenRouter response');
52
+ }
53
+
54
+ return {
55
+ text: content.trim(),
56
+ model,
57
+ fallback: model !== chain[0],
58
+ durationMs: Date.now() - startTotal,
59
+ errors: errors.length > 0 ? errors : undefined
60
+ };
61
+ } catch (err) {
62
+ errors.push({ model, error: err.message, durationMs: Date.now() - startModel });
63
+ console.error(`OpenRouter ${model} failed (${Date.now() - startModel}ms): ${err.message}`);
64
+ } finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+
69
+ const err = new Error(`All models failed: ${errors.map((e) => `${e.model}: ${e.error}`).join('; ')}`);
70
+ err.modelErrors = errors;
71
+ err.durationMs = Date.now() - startTotal;
72
+ throw err;
73
+ }
74
+
75
+ export 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
76
 
7
77
  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
78
 
79
+ The data tells the story — your job is to interpret it honestly, not to make the trainee feel good.
80
+
9
81
  Cover these in order of relevance (skip any that don't apply):
10
82
  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
83
  2. Progression commentary: the app made auto-progression decisions listed below. Comment on whether they look right given the data.
@@ -13,61 +85,24 @@ Cover these in order of relevance (skip any that don't apply):
13
85
  4. Goal progress: if the trainee has strength goals, comment on trajectory.
14
86
  5. One concrete thing to watch or change next cycle. Be specific.
15
87
 
16
- Rules:
17
- - Only state what the data shows. Never claim how something "felt."
18
- - Reference specific exercises, weights, and reps. Use numbers, not vague praise.
19
- - If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
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.
24
- - No bullet points or lists.`;
88
+ Only state what the data shows. Never claim how something "felt." Reference specific exercises, weights, and reps — use numbers, not vague descriptions. If there are PRs, mention them matter-of-factly. If exercises were swapped from the plan, note the pattern and ask about it if recurring. Write like a training partner looking at a logbook. Short sentences, no filler, no cheerleading. Questions are good.
25
89
 
26
- export async function generateCoachingSummary(cycleContext, { apiKey, model, timeoutMs } = {}) {
27
- const resolvedModel = model || DEFAULT_MODEL;
28
- const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
29
-
30
- const controller = new AbortController();
31
- const timer = setTimeout(() => controller.abort(), resolvedTimeout);
32
-
33
- try {
34
- const userContent = formatCycleContext(cycleContext);
35
-
36
- const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
37
- method: 'POST',
38
- headers: {
39
- 'Authorization': `Bearer ${apiKey}`,
40
- 'Content-Type': 'application/json',
41
- 'HTTP-Referer': 'https://incremnt.app',
42
- 'X-Title': 'incremnt'
43
- },
44
- body: JSON.stringify({
45
- model: resolvedModel,
46
- messages: [
47
- { role: 'system', content: CYCLE_SUMMARY_PROMPT },
48
- { role: 'user', content: userContent }
49
- ],
50
- max_tokens: DEFAULT_MAX_TOKENS,
51
- temperature: 0.7
52
- }),
53
- signal: controller.signal
54
- });
55
-
56
- if (!response.ok) {
57
- const text = await response.text().catch(() => '');
58
- throw new Error(`OpenRouter API error ${response.status}: ${text}`);
59
- }
90
+ 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.`;
60
91
 
61
- const data = await response.json();
62
- const content = data.choices?.[0]?.message?.content;
63
- if (!content) {
64
- throw new Error('No content in OpenRouter response');
92
+ export async function generateCoachingSummary(cycleContext, { apiKey, model, timeoutMs } = {}) {
93
+ const userContent = formatCycleContext(cycleContext);
94
+ return callOpenRouter(
95
+ [
96
+ { role: 'system', content: CYCLE_SUMMARY_PROMPT },
97
+ { role: 'user', content: userContent }
98
+ ],
99
+ {
100
+ apiKey,
101
+ models: model ? [model] : SUMMARY_MODEL_CHAIN,
102
+ temperature: 0.5,
103
+ timeoutMs
65
104
  }
66
-
67
- return content.trim();
68
- } finally {
69
- clearTimeout(timer);
70
- }
105
+ );
71
106
  }
72
107
 
73
108
  export function formatCycleContext(ctx) {
@@ -161,80 +196,67 @@ export function formatCycleContext(ctx) {
161
196
  }
162
197
  }
163
198
 
199
+ const recoveryParts = [];
200
+ if (ctx.avgRestingHR) recoveryParts.push(`avg resting HR ${ctx.avgRestingHR} bpm`);
201
+ if (ctx.avgHRV) recoveryParts.push(`avg HRV ${ctx.avgHRV} ms`);
202
+ if (ctx.latestVO2Max) recoveryParts.push(`VO2 max ${ctx.latestVO2Max} ml/kg/min`);
203
+ if (ctx.avgSleepMins) recoveryParts.push(`avg sleep ${(ctx.avgSleepMins / 60).toFixed(1)}h`);
204
+ if (ctx.latestBodyWeightKg) recoveryParts.push(`body weight ${ctx.latestBodyWeightKg} kg`);
205
+ if (recoveryParts.length > 0) {
206
+ lines.push('');
207
+ lines.push(`Recovery metrics this cycle: ${recoveryParts.join(', ')}`);
208
+ }
209
+
210
+ if (ctx.cycleCardio?.length > 0) {
211
+ lines.push('');
212
+ lines.push('Cardio this cycle:');
213
+ for (const w of ctx.cycleCardio) {
214
+ const parts = [w.durationSecs ? `${Math.round(w.durationSecs / 60)} min` : '? min'];
215
+ if (w.distanceKm) parts.push(`${w.distanceKm.toFixed(1)} km`);
216
+ if (w.avgHR) parts.push(`avg HR ${w.avgHR} bpm`);
217
+ if (w.effortScore) parts.push(`effort ${w.effortScore}/10`);
218
+ lines.push(` ${w.date} ${w.workoutType}: ${parts.join(', ')}`);
219
+ }
220
+ }
221
+
164
222
  return lines.join('\n');
165
223
  }
166
224
 
167
225
  export const WORKOUT_COACH_PROMPT = `You are reviewing a training session log. Write 2-3 short paragraphs separated by blank lines.
168
226
 
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.
227
+ 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.
170
228
 
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.
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 "first session" they have zero prior history — state this plainly, don't frame it as "exploring" or "a new loading strategy." If this is an adhoc session, note any overlap with programmed exercises.
176
230
 
177
- Rules:
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.
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.
185
- - No bullet points or lists.`;
231
+ Never name an exercise that does not appear in the workout data below. [first session] means no prior history for that exact exercise. It does not mean the user switched from another exercise.
186
232
 
187
- export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, model, timeoutMs } = {}) {
188
- const resolvedModel = model || DEFAULT_MODEL;
189
- const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
190
-
191
- const controller = new AbortController();
192
- const timer = setTimeout(() => controller.abort(), resolvedTimeout);
193
-
194
- try {
195
- const userContent = formatWorkoutContext(workoutContext);
196
-
197
- const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
198
- method: 'POST',
199
- headers: {
200
- 'Authorization': `Bearer ${apiKey}`,
201
- 'Content-Type': 'application/json',
202
- 'HTTP-Referer': 'https://incremnt.app',
203
- 'X-Title': 'incremnt'
204
- },
205
- body: JSON.stringify({
206
- model: resolvedModel,
207
- messages: [
208
- { role: 'system', content: WORKOUT_COACH_PROMPT },
209
- { role: 'user', content: userContent }
210
- ],
211
- max_tokens: DEFAULT_MAX_TOKENS,
212
- temperature: 0.7
213
- }),
214
- signal: controller.signal
215
- });
216
-
217
- if (!response.ok) {
218
- const text = await response.text().catch(() => '');
219
- throw new Error(`OpenRouter API error ${response.status}: ${text}`);
220
- }
233
+ Ask 1-2 genuine questions about choices that look interesting or unusual. This is the most valuable thing you can do — a good question is worth more than restating what happened.
221
234
 
222
- const data = await response.json();
223
- const content = data.choices?.[0]?.message?.content;
224
- if (!content) {
225
- throw new Error('No content in OpenRouter response');
226
- }
235
+ 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.
227
236
 
228
- return content.trim();
229
- } finally {
230
- clearTimeout(timer);
231
- }
237
+ 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.`;
238
+
239
+ export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, model, timeoutMs } = {}) {
240
+ const userContent = formatWorkoutContext(workoutContext);
241
+ return callOpenRouter(
242
+ [
243
+ { role: 'system', content: WORKOUT_COACH_PROMPT },
244
+ { role: 'user', content: userContent }
245
+ ],
246
+ {
247
+ apiKey,
248
+ models: model ? [model] : SUMMARY_MODEL_CHAIN,
249
+ temperature: 0.5,
250
+ timeoutMs
251
+ }
252
+ );
232
253
  }
233
254
 
234
255
  export function formatWorkoutContext(ctx) {
235
- const lines = [
236
- `Session: ${ctx.dayName}, ${ctx.sessionDate}, ${ctx.totalVolume} kg total volume.`
237
- ];
256
+ const sessionLabel = ctx.isAdhoc
257
+ ? `Session: ${ctx.dayName}, ${ctx.sessionDate}, adhoc (no program), ${ctx.totalVolume} kg total volume.`
258
+ : `Session: ${ctx.dayName}, ${ctx.sessionDate}, program "${ctx.programName}", ${ctx.totalVolume} kg total volume.`;
259
+ const lines = [sessionLabel];
238
260
 
239
261
  if (ctx.effortScore) {
240
262
  lines.push(`Effort rating: ${ctx.effortScore}/10.`);
@@ -243,11 +265,14 @@ export function formatWorkoutContext(ctx) {
243
265
  lines.push('Exercises:');
244
266
  for (const ex of ctx.exercises) {
245
267
  const topPart = ex.topSet ? ` (top: ${ex.topSet.weight}×${ex.topSet.reps})` : '';
246
- lines.push(` ${ex.exerciseName}: ${ex.completedSets} sets${topPart}`);
268
+ const historyPart = ex.priorSessions === 0
269
+ ? ' [first session]'
270
+ : ` [${ex.priorSessions} prior sessions]`;
271
+ lines.push(` ${ex.exerciseName}: ${ex.completedSets} sets${topPart}${historyPart}`);
247
272
  }
248
273
 
249
274
  if (ctx.prs.length > 0) {
250
- lines.push('PRs this session:');
275
+ lines.push('PRs this session (only exercises with prior history):');
251
276
  for (const pr of ctx.prs) {
252
277
  lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps} (e1RM ${pr.estimatedOneRM})`);
253
278
  }
@@ -257,7 +282,8 @@ export function formatWorkoutContext(ctx) {
257
282
  lines.push('Recent same-day sessions for comparison:');
258
283
  for (const comp of ctx.recentComparisons) {
259
284
  const effort = comp.effortScore ? `, effort ${comp.effortScore}/10` : '';
260
- lines.push(` ${comp.date}: ${comp.totalVolume} kg volume${effort}`);
285
+ const prog = comp.programName ? ` (${comp.programName})` : '';
286
+ lines.push(` ${comp.date}: ${comp.totalVolume} kg volume${effort}${prog}`);
261
287
  }
262
288
  }
263
289
 
@@ -280,5 +306,66 @@ export function formatWorkoutContext(ctx) {
280
306
  }
281
307
  }
282
308
 
309
+ // Recovery context
310
+ const recoveryParts = [];
311
+ if (ctx.restingHROnDay) recoveryParts.push(`resting HR ${Math.round(ctx.restingHROnDay)} bpm`);
312
+ if (ctx.hrvOnDay) recoveryParts.push(`HRV ${Math.round(ctx.hrvOnDay)} ms`);
313
+ if (ctx.vo2MaxLatest) recoveryParts.push(`VO2 max ${ctx.vo2MaxLatest} ml/kg/min`);
314
+ if (ctx.sleepNight) recoveryParts.push(`sleep ${(ctx.sleepNight.durationMins / 60).toFixed(1)}h`);
315
+ if (ctx.bodyWeightKg) recoveryParts.push(`body weight ${ctx.bodyWeightKg} kg`);
316
+ if (recoveryParts.length > 0) {
317
+ lines.push(`Recovery (session day): ${recoveryParts.join(', ')}`);
318
+ }
319
+
320
+ if (ctx.nearbyCardio?.length > 0) {
321
+ lines.push('Cardio in the 7 days before this session:');
322
+ for (const w of ctx.nearbyCardio) {
323
+ const parts = [w.durationSecs ? `${Math.round(w.durationSecs / 60)} min` : '? min'];
324
+ if (w.distanceKm) parts.push(`${w.distanceKm.toFixed(1)} km`);
325
+ if (w.avgHR) parts.push(`avg HR ${w.avgHR} bpm`);
326
+ if (w.effortScore) parts.push(`effort ${w.effortScore}/10`);
327
+ lines.push(` ${w.date} ${w.workoutType}: ${parts.join(', ')}`);
328
+ }
329
+ }
330
+
283
331
  return lines.join('\n');
284
332
  }
333
+
334
+ export const ASK_PROMPT = `You are a strength coach answering questions from the user's training history. Give concrete, useful coaching, not hype.
335
+
336
+ Rules:
337
+ - Use only the data provided. If the data does not support a claim, do not make it.
338
+ - Focus on trend, weak points, tradeoffs, and next steps. Be specific with exercises, weights, reps, volume, and timing when relevant.
339
+ - Match the response length to the question. Short or playful prompts get a short conversational reply plus an invitation to ask something specific.
340
+ - Keep the tone natural and direct. No hype, no filler, no emoji, no "let's dive in", no performance-review language.
341
+ - Never name an exercise that does not appear in the training data below.
342
+ - If data is missing or ambiguous, say so plainly.
343
+
344
+ When the user asks for analysis, answer like a coach who has watched their training over time. When they ask for a plan, give a clear next-session recommendation. Bullet points are fine when they make the answer easier to use.`;
345
+
346
+ export async function generateAskAnswer(context, question, { apiKey, model, timeoutMs, history = [] } = {}) {
347
+ // First user message includes the workout context; follow-ups are plain questions
348
+ const firstUserContent = `${context}\n\nQuestion: ${question}`;
349
+ const isFollowUp = history.length > 0;
350
+ const newUserContent = isFollowUp ? question : firstUserContent;
351
+
352
+ const priorMessages = history.map((m) => ({ role: m.role, content: m.content }));
353
+ // Prepend context to the first user message in history if needed
354
+ if (isFollowUp && priorMessages.length > 0 && priorMessages[0].role === 'user') {
355
+ priorMessages[0] = { role: 'user', content: `${context}\n\nQuestion: ${priorMessages[0].content}` };
356
+ }
357
+
358
+ return callOpenRouter(
359
+ [
360
+ { role: 'system', content: ASK_PROMPT },
361
+ ...priorMessages,
362
+ { role: 'user', content: newUserContent }
363
+ ],
364
+ {
365
+ apiKey,
366
+ models: model ? [model] : ASK_MODEL_CHAIN,
367
+ temperature: 0.3,
368
+ timeoutMs: timeoutMs ?? ASK_TIMEOUT_MS
369
+ }
370
+ );
371
+ }