incremnt 0.1.16 → 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/README.md CHANGED
@@ -7,10 +7,10 @@ Command-line tool and MCP server for querying your [incremnt](https://incremnt.a
7
7
  ```bash
8
8
  npm install -g incremnt
9
9
  incremnt login
10
- incremnt mcp install # registers with Claude Desktop and Claude Code
10
+ incremnt mcp install # registers with Claude Desktop, Claude Code, and Codex CLI
11
11
  ```
12
12
 
13
- This gives you two commands: `incremnt` (CLI) and `incremnt-mcp` (MCP server). The `mcp install` step auto-registers the MCP server — restart Claude for it to take effect.
13
+ This gives you two commands: `incremnt` (CLI) and `incremnt-mcp` (MCP server). The `mcp install` step auto-registers the MCP server with Claude Desktop, Claude Code, and Codex CLI — restart your AI assistant for it to take effect.
14
14
 
15
15
  ## CLI
16
16
 
@@ -72,7 +72,7 @@ If `--input` is omitted, the CLI checks `INCREMNT_SNAPSHOT`, then `ONEMORE_SNAPS
72
72
 
73
73
  The package includes an [MCP](https://modelcontextprotocol.io) server that exposes the same read queries and program proposal commands as tools for AI assistants like Claude.
74
74
 
75
- Run `incremnt mcp install` to auto-register the server with Claude Desktop and Claude Code (see [Setup](#setup) above).
75
+ Run `incremnt mcp install` to auto-register the server with Claude Desktop, Claude Code, and Codex CLI (see [Setup](#setup) above).
76
76
 
77
77
  To register manually instead, add to your Claude Code project config (`.mcp.json`):
78
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Command-line tool for querying your incremnt strength training data",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "scripts": {
15
15
  "test": "node --test",
16
16
  "dev:sync-fixture": "node ./scripts/dev-sync-fixture-server.js",
17
- "postinstall": "node -e \"process.stdout.write('\\n incremnt MCP server installed.\\n Run: incremnt mcp install\\n to register it with Claude.\\n\\n')\""
17
+ "postinstall": "node -e \"process.stdout.write('\\n incremnt MCP server installed.\\n Run: incremnt mcp install\\n to register it with Claude and Codex.\\n\\n')\""
18
18
  },
19
19
  "dependencies": {
20
20
  "@modelcontextprotocol/sdk": "^1.12.1",
package/src/contract.js CHANGED
@@ -1,4 +1,4 @@
1
- export const contractVersion = 1;
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/lib.js CHANGED
@@ -167,7 +167,26 @@ export async function runCli(argv, stdout, stderr) {
167
167
  await fs.writeFile(cliPath, JSON.stringify(cliConfig, null, 2) + '\n');
168
168
  installed.push(` Claude Code: ${cliPath}`);
169
169
 
170
- stdout.write(`Registered incremnt MCP server in:\n${installed.join('\n')}\n\nRestart Claude for the changes to take effect.\n`);
170
+ // Codex CLI
171
+ const codexPath = path.join(os.homedir(), '.codex', 'config.toml');
172
+ try {
173
+ let codexConfig = '';
174
+ try {
175
+ codexConfig = await fs.readFile(codexPath, 'utf8');
176
+ } catch { /* file doesn't exist yet */ }
177
+ const block = `[mcp_servers.incremnt]\ncommand = "${mcpBin}"\n`;
178
+ if (!codexConfig.includes('[mcp_servers.incremnt]')) {
179
+ await fs.mkdir(path.dirname(codexPath), { recursive: true });
180
+ await fs.writeFile(codexPath, codexConfig + (codexConfig && !codexConfig.endsWith('\n') ? '\n' : '') + block);
181
+ installed.push(` Codex CLI: ${codexPath}`);
182
+ } else {
183
+ installed.push(` Codex CLI: ${codexPath} (already registered)`);
184
+ }
185
+ } catch (error) {
186
+ installed.push(` Codex CLI: skipped (${error.message})`);
187
+ }
188
+
189
+ stdout.write(`Registered incremnt MCP server in:\n${installed.join('\n')}\n\nRestart Claude/Codex for the changes to take effect.\n`);
171
190
  return 0;
172
191
  } catch (error) {
173
192
  stderr.write(`${error.message}\n`);
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-20251201',
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-20251201'
7
+ 'deepseek/deepseek-v3.2'
8
8
  ];
9
- const TIMEOUT_PER_MODEL_MS = 12_000;
10
- const ASK_TIMEOUT_MS = 8_000;
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
- async function callOpenRouter(messages, { apiKey, models, temperature, maxTokens, timeoutMs }) {
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
- for (const model of chain) {
21
- const controller = new AbortController();
22
- const timer = setTimeout(() => controller.abort(), timeout);
23
- const startModel = Date.now();
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 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
- }
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
- text: content.trim(),
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: Date.now() - startModel });
63
- console.error(`OpenRouter ${model} failed (${Date.now() - startModel}ms): ${err.message}`);
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: CYCLE_SUMMARY_PROMPT },
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
- ? ` (top: ${ex.topSet.weight}×${ex.topSet.reps})`
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
- const trendStr = et.trend.map((t) => t.e1RM).join(' → ');
195
- lines.push(` ${et.exerciseName} e1RM: ${trendStr}`);
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 "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.
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. [first session] means no prior history for that exact exercise. It does not mean the user switched from another exercise.
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 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.
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 ? ` (top: ${ex.topSet.weight}×${ex.topSet.reps})` : '';
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
- ? ' [first session]'
270
- : ` [${ex.priorSessions} prior sessions]`;
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
  }