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/README.md +11 -9
- package/package.json +3 -2
- package/src/contract.js +25 -0
- package/src/lib.js +57 -1
- package/src/mcp.js +57 -30
- package/src/openrouter.js +206 -119
- package/src/queries.js +293 -10
- package/src/remote.js +39 -1
- package/src/sync-service.js +259 -18
package/src/openrouter.js
CHANGED
|
@@ -1,11 +1,83 @@
|
|
|
1
|
-
const
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|